choir_node/lib.rs
1//! L3 node daemon: a minimal git smart-HTTP server (DECISIONS.md D12).
2//!
3//! v1 wraps `git http-backend` (git's own CGI) over bare repositories, so
4//! the daemon is a thin, self-hostable shell over git plumbing, and
5//! platform behavior (sequencer, queue, identity) layers on top.
6//! ForgeMark benchmarks this surface directly.
7//!
8//! Authentication is per-actor basic auth ([`AuthTable`], `--auth-file`),
9//! plus the credentials self-service has issued ([`accounts`],
10//! `--accounts-file`); the platform API ([`platform`]) additionally
11//! verifies ed25519 op signatures. The bind stays loopback-only: beyond
12//! localhost you still need TLS or an SSH tunnel so tokens aren't sent in
13//! the clear.
14//!
15//! # Where this sits
16//!
17//! `docs/architecture.md` is the map of the whole workspace.
18//! This crate is L3, the daemon that serves both git smart-HTTP and the platform API.
19//!
20//! It builds on [`choir_fs`], [`choir_hash`], [`choir_identity`], [`choir_oplog`], [`choir_sequencer`] and [`choir_view`].
21//!
22//! The operator's guide to this daemon:
23//!
24#![doc = include_str!("../../../docs/operating/running-a-node.md")]
25
26use std::io::Read;
27use std::path::{Path, PathBuf};
28
29mod account_page;
30pub mod accounts;
31pub mod acl;
32mod bound;
33mod browse;
34mod card;
35mod downloads;
36pub mod followers;
37pub mod hooks;
38mod join_page;
39pub mod limits;
40pub mod platform;
41mod syntax;
42/// How many times this process has shelled out to git while serving.
43///
44/// Exposed so a test can budget it. A page's read latency is mostly its
45/// process spawns, and the count is the half of that which does not move
46/// with machine load -- see `tests/phase1_spawns.rs`.
47#[must_use]
48pub fn git_invocations() -> u64 {
49 browse::GIT_INVOCATIONS.load(std::sync::atomic::Ordering::Relaxed)
50}
51
52mod people_page;
53pub mod portable;
54mod prepare;
55pub mod profile;
56pub mod provision;
57pub mod queue;
58pub mod queue_api;
59pub mod quota;
60mod readme;
61pub mod reject;
62pub mod replica;
63mod session;
64mod signin_page;
65pub mod ssh;
66mod ui;
67mod work;
68
69pub use platform::Platform;
70
71/// The commit this binary was built from, or the literal `unknown` when
72/// the build had no way to find out. See `build.rs`.
73///
74/// `choirctl status` could already name the file that is serving; it
75/// could not say what that file was built from, and "the rebuild never
76/// reached the running process" is indistinguishable from "it did" until
77/// something the process itself reports says otherwise.
78pub const BUILD_COMMIT: &str = env!("CHOIR_BUILD_COMMIT");
79
80/// Where [`BUILD_COMMIT`] came from: `env` (the installer passed
81/// `CHOIR_GIT_HEAD`, the sound path), `git` (best-effort at build time),
82/// or `unavailable` (no commit could be determined).
83pub const BUILD_SOURCE: &str = env!("CHOIR_BUILD_SOURCE");
84
85/// Whether the build tree had uncommitted changes. Only meaningful under
86/// `BUILD_SOURCE == "git"`, and even then best-effort: cargo cannot rerun
87/// the build script on every source edit, so this can be stale where
88/// [`BUILD_COMMIT`] cannot.
89pub const BUILD_DIRTY: &str = env!("CHOIR_BUILD_DIRTY");
90
91/// The build stamp as served under `/api/view.build`.
92#[must_use]
93pub fn build_json() -> serde_json::Value {
94 serde_json::json!({
95 "format_version": 1,
96 "commit": BUILD_COMMIT,
97 "source": BUILD_SOURCE,
98 "dirty": BUILD_DIRTY == "true",
99 "dirty_trusted": BUILD_SOURCE == "git",
100 })
101}
102
103/// One line naming the running binary's provenance, for the startup log.
104#[must_use]
105pub fn build_line() -> String {
106 let commit = match BUILD_COMMIT.len() {
107 40 => &BUILD_COMMIT[..12],
108 _ => BUILD_COMMIT,
109 };
110 let dirty = if BUILD_DIRTY == "true" { " +dirty" } else { "" };
111 format!("build {commit}{dirty} (stamp source: {BUILD_SOURCE})")
112}
113
114/// The design tokens from `ui.css`, alone, for the documentation book.
115///
116/// The book and this daemon's own pages should look like one product,
117/// and the honest way to get that is one definition of the palette
118/// rather than two that agree today. What is shared is deliberately
119/// *only* the tokens: `ui.css` below the reset styles `h2` as a small
120/// uppercase eyebrow and gives `body` a centred max-width, which is
121/// right for a node page built out of `section` elements and wrong for
122/// a book with a sidebar. Components stay per-surface; colour, type
123/// scale, spacing and easing are shared.
124///
125/// The cut is the reset marker rather than a line number, and a missing
126/// marker is a panic rather than a silent half-file: this is rendered
127/// into a generated artifact that a staleness test compares, so a slice
128/// that quietly returned everything would put the whole sheet in the
129/// book and still look like it worked.
130///
131/// # Panics
132///
133/// If `ui.css` no longer carries the reset marker that separates its
134/// tokens from its component styles, or either theme selector this
135/// widens for mdBook.
136#[must_use]
137pub fn ui_tokens_css() -> String {
138 const SHEET: &str = include_str!("ui.css");
139 const RESET: &str = "/* --- global reset (token-only) ---";
140
141 // mdBook picks a palette by putting a class on `<html>`; this
142 // workspace picks one with `data-theme` (D56). Rather than keep a
143 // second copy of the palette in mdBook's spelling, each selector is
144 // widened to answer to both. Two literal rewrites, and a missing
145 // one is a panic: a silent no-op here renders the book's light
146 // theme in dark colours, which reads as a CSS bug rather than as a
147 // generator that stopped generating.
148 // The `:not()` is load-bearing and is not about matching: it is
149 // there for specificity. `ui.css` resolves "follow the system" with
150 // `@media (prefers-color-scheme:light){ :root:not([data-theme="dark"]) }`,
151 // which scores (0,2,0). A plain `html.coal` scores (0,1,1) and
152 // loses to it, so mdBook's dark themes rendered in light colours on
153 // a machine set to light -- a book that looked like the theme
154 // picker was broken rather than like a specificity bug. Each
155 // selector below scores (0,3,0) and beats it, while still matching
156 // exactly the same elements.
157 const THEMES: [(&str, &str); 2] = [
158 (
159 ":root[data-theme=\"dark\"]{",
160 ":root[data-theme=\"dark\"],\
161 :root.coal:not([data-theme=\"light\"]),\
162 :root.navy:not([data-theme=\"light\"]),\
163 :root.ayu:not([data-theme=\"light\"]){",
164 ),
165 (
166 ":root[data-theme=\"light\"]{",
167 ":root[data-theme=\"light\"],\
168 :root.light:not([data-theme=\"dark\"]),\
169 :root.rust:not([data-theme=\"dark\"]){",
170 ),
171 ];
172
173 let mut tokens = SHEET
174 .split_once(RESET)
175 .expect("ui.css must keep the reset marker that ends its token block")
176 .0
177 .trim_end()
178 .to_string();
179
180 for (from, to) in THEMES {
181 assert!(
182 tokens.contains(from),
183 "ui.css must keep the `{from}` selector the book's theme mapping widens"
184 );
185 tokens = tokens.replace(from, to);
186 }
187
188 format!(
189 "/* generated from crates/choir-node/src/ui.css -- do not edit.\n\
190 \x20 Regenerate: cargo run -p choir-cli --example gen-surface\n\
191 \x20\n\
192 \x20 The token block of that sheet, cut at its reset marker, with\n\
193 \x20 each theme selector widened to answer to mdBook's `<html>`\n\
194 \x20 class as well as this workspace's `data-theme`. Edit the\n\
195 \x20 tokens there and the node's pages and the book move together.\n\
196 \x20 Component styles are deliberately NOT shared: that sheet\n\
197 \x20 styles `h2` as an uppercase eyebrow and centres `body`,\n\
198 \x20 which is right for a node page and wrong for a book. */\n\n{tokens}\n"
199 )
200}
201
202/// Per-actor credentials: username → token, checked as HTTP basic auth
203/// (the standard git-over-HTTP shape; every forge client speaks it).
204///
205/// L8 note: usernames are actor ids and tokens are per-actor secrets
206/// minted by the operator; key-signature-based challenge auth can
207/// replace the token *check* later without changing the wire shape.
208pub type AuthTable = std::collections::HashMap<String, String>;
209
210/// Default maximum body size for every `/api/...` request: 1 MiB.
211pub const DEFAULT_API_BODY_BYTES: u64 = 1024 * 1024;
212
213/// Readiness refuses when the filesystem reports less than 1 GiB free.
214pub const DEFAULT_READY_MIN_FREE_BYTES: u64 = 1024 * 1024 * 1024;
215
216/// A running node daemon serving repos under a root directory.
217pub struct Node {
218 root: PathBuf,
219 server: std::sync::Arc<tiny_http::Server>,
220 port: u16,
221 auth: std::sync::Arc<Option<AuthTable>>,
222 platform: Option<std::sync::Arc<Platform>>,
223 /// What the merge queue needs before `/api/queue/run` can do
224 /// anything (D68). `None` answers 501: a node with no CI command
225 /// cannot decide whether a candidate is good, and a queue that
226 /// landed everything unchecked would be a worse `git push`.
227 queue: Option<queue_api::QueueConfig>,
228 /// The rounds running right now, so a second request for a target
229 /// already in flight is refused rather than raced.
230 queue_in_flight: std::sync::Arc<queue_api::InFlight>,
231 /// The scheme absolute URLs handed to *people* are written with:
232 /// invite links, page origins, and the `Secure` attribute on cookies.
233 ///
234 /// [`Node::behind_tls_proxy`] sets it, because a node terminating
235 /// plaintext on loopback behind a proxy is reached over https by
236 /// everyone except the proxy.
237 scheme: &'static str,
238 /// The scheme this node's own socket actually speaks.
239 ///
240 /// Separate from [`Node::scheme`] and never overridden, because the
241 /// two answer different questions and one field answering both is a
242 /// bug this repository has already shipped: git's `pre-receive` hook
243 /// calls back to `127.0.0.1` on this very socket, and when
244 /// `behind_tls_proxy` moved the single field the hook began speaking
245 /// TLS to a plaintext port and every push hung in the handshake.
246 /// Anything addressed to loopback uses this one.
247 listener_scheme: &'static str,
248 /// Loopback secret handed to repo hooks via env so their callback to
249 /// `/api/git-update` passes the auth gate without user credentials.
250 internal_token: String,
251 /// Trusted-keys file to watch, so `allowed_signers` tracks it
252 /// without a restart. `None` = generated once at startup.
253 keys_watch: Option<std::sync::Arc<KeysWatch>>,
254 /// Per-repository authorization table (D29), watched like the keys
255 /// file. `None` = no `--acl-file`, so every authenticated actor
256 /// reaches every repository, which is the pre-D29 behaviour.
257 acl_watch: Option<std::sync::Arc<AclWatch>>,
258 /// The browser page, prebuilt and keyed by view sequence. Shared
259 /// across request threads so one render serves every reader until
260 /// the state it describes changes.
261 ui_cache: std::sync::Arc<ui::UiCache>,
262 /// The attributed request log (D33). `None` = no `--request-log`, so
263 /// nothing is recorded, which is the pre-D33 behaviour.
264 request_log: Option<std::sync::Arc<limits::RequestLog>>,
265 /// Per-user token buckets (D33). `None` = no ceiling was configured,
266 /// so no request is ever refused for rate.
267 rate: Option<std::sync::Arc<limits::RateLimiter>>,
268 /// Self-service credentials (D36). `None` = no `--accounts-file`, so
269 /// the only credentials are the ones the operator wrote by hand.
270 accounts: Option<std::sync::Arc<accounts::Accounts>>,
271 /// Whether passkeys are usable on this node (D39, D71). Off unless
272 /// [`Node::enable_passkeys`] is called, and separate from
273 /// [`Node::accounts`] on purpose: enrolment and the WebAuthn write
274 /// path both live behind the accounts store, so without this switch
275 /// turning on self-service credentials would turn on browser
276 /// signing with them in the same move, and a deployment that says
277 /// it does not offer passkeys could not be telling the truth.
278 passkeys: bool,
279 /// Browser sessions opened by a passkey (D71). In memory, so they end
280 /// with the process.
281 sessions: std::sync::Arc<session::Sessions>,
282 /// Admission control for the pre-auth routes (D57).
283 ///
284 /// Always present, unlike [`Node::rate`]: those routes answer before
285 /// any credential is checked, so "no ceiling was configured" is not
286 /// an option there the way it is for an authenticated caller.
287 public_rate: std::sync::Arc<limits::PublicLimiter>,
288 /// Whether this node hands out ssh access, which decides whether the
289 /// join page offers a key field. Offering one on a node with no ssh
290 /// surface collects a key nothing will ever use.
291 ssh_enabled: bool,
292 /// The file table and the store's grants, merged and dated, cached
293 /// against the generations of both and the second it was dated at.
294 /// Rebuilt when any of the three moves, so authorization does not
295 /// rebuild a table per request and cannot serve a grant past its
296 /// deadline (D66).
297 acl_merged: std::sync::RwLock<Option<MergedAcl>>,
298 /// Per-user ceilings on push size and workspace count (D37). Both
299 /// unset = nothing is ever refused for quota, which is the pre-D37
300 /// behaviour.
301 quotas: quota::Quotas,
302 /// Absolute API request-body ceiling. Unlike per-user quotas this is
303 /// never exempt: parsing an operator's unbounded JSON consumes the
304 /// same memory as parsing anyone else's.
305 api_body_limit: std::num::NonZeroU64,
306 /// Whether authenticated review pages expose passkey-backed write
307 /// controls. The private beta disables this and keeps signed CLI
308 /// submissions as the only mutation path.
309 browser_writes: bool,
310 site_repo: Option<String>,
311 /// The release shelf (D79): a directory whose files are served
312 /// unauthenticated under [`downloads::PREFIX`], or `None` on a node
313 /// that publishes no binaries. Unset is the default, and a node that
314 /// never gets this flag has no route at all.
315 downloads: Option<std::path::PathBuf>,
316 /// Free-space floor used by the authenticated readiness endpoint.
317 ready_min_free_bytes: u64,
318 /// Running request totals, exported by `/metrics`. Shared with every
319 /// request thread rather than owned by one, since the counting
320 /// happens on whichever thread served the request.
321 counters: std::sync::Arc<limits::Counters>,
322 /// When this process began serving, as seconds since the epoch.
323 /// `choir_process_start_time_seconds`, which is how a restart-loop
324 /// alert sees a restart at all.
325 started_unix: u64,
326 /// Distinguishes an intentional `unblock()` from a receive timeout.
327 /// tiny_http reports both as `Ok(None)` from `recv_timeout`.
328 shutdown: std::sync::Arc<std::sync::atomic::AtomicBool>,
329}
330
331/// A watched trusted-keys file and the mtime last folded into
332/// `allowed_signers`.
333struct KeysWatch {
334 path: PathBuf,
335 mtime: std::sync::Mutex<Option<std::time::SystemTime>>,
336}
337
338/// The `, N expired` half of the ACL startup and reload lines (D66),
339/// or nothing at all when no grant has a deadline in the past.
340///
341/// Said out loud because a deadline that has already passed reads
342/// exactly like a grant that was never written: the holder is refused,
343/// and the file still shows the line. A count is the cheapest thing that
344/// tells those two apart.
345fn expired_note(table: &acl::Acl) -> String {
346 match table.expired(accounts::now_secs()) {
347 0 => String::new(),
348 n => format!(", {n} expired"),
349 }
350}
351
352/// The cached merged table and everything it is only valid for: the ACL
353/// file's epoch, the account store's generation, and the second it was
354/// dated at (D66). All three have to match or it is rebuilt.
355type MergedAcl = (u64, u64, u64, std::sync::Arc<acl::Effective>);
356
357/// A watched ACL file, the mtime last parsed, and the table in force.
358struct AclWatch {
359 path: PathBuf,
360 mtime: std::sync::Mutex<Option<std::time::SystemTime>>,
361 table: std::sync::RwLock<std::sync::Arc<acl::Acl>>,
362 /// Bumped on every successful reload. A counter rather than the
363 /// table's address, because an address can be reused by the next
364 /// allocation and a stale merge on an authorization path is exactly
365 /// the failure worth spending a `u64` to make impossible.
366 epoch: std::sync::atomic::AtomicU64,
367}
368
369impl Node {
370 /// Binds to `127.0.0.1:port` (0 = ephemeral) over `root`, with no
371 /// authentication — localhost/dev only.
372 ///
373 /// # Errors
374 ///
375 /// Returns an error when the socket cannot be bound or `root` cannot be
376 /// created.
377 pub fn bind(root: &Path, port: u16) -> std::io::Result<Self> {
378 Self::bind_with_auth(root, port, None)
379 }
380
381 /// Binds like [`Node::bind`]; when `auth` is `Some`, every request
382 /// must carry valid basic-auth credentials from the table or it is
383 /// answered with 401 before touching git.
384 ///
385 /// # Errors
386 ///
387 /// Same failure modes as [`Node::bind`].
388 pub fn bind_with_auth(
389 root: &Path,
390 port: u16,
391 auth: Option<AuthTable>,
392 ) -> std::io::Result<Self> {
393 Self::bind_full(root, "127.0.0.1", port, auth, None)
394 }
395
396 /// Full-control bind: address, port, auth, and optional TLS
397 /// (PEM certificate chain + PEM private key).
398 ///
399 /// A non-loopback `addr` is refused without TLS — plaintext basic
400 /// auth must never cross a real network (standing privacy rule:
401 /// nothing leaves loopback without an explicit, protected choice).
402 ///
403 /// # Errors
404 ///
405 /// Returns an error when the socket cannot be bound, `root` cannot
406 /// be created, the TLS material is invalid, or a non-loopback bind
407 /// is requested without TLS.
408 pub fn bind_full(
409 root: &Path,
410 addr: &str,
411 port: u16,
412 auth: Option<AuthTable>,
413 tls: Option<(Vec<u8>, Vec<u8>)>,
414 ) -> std::io::Result<Self> {
415 let loopback = addr
416 .parse::<std::net::IpAddr>()
417 .map(|ip| ip.is_loopback())
418 .unwrap_or(false);
419 if !loopback && tls.is_none() {
420 return Err(std::io::Error::new(
421 std::io::ErrorKind::InvalidInput,
422 "refusing non-loopback bind without TLS",
423 ));
424 }
425 std::fs::create_dir_all(root)?;
426 let scheme = if tls.is_some() { "https" } else { "http" };
427 let listener_scheme = scheme;
428 let server = match tls {
429 Some((certificate, private_key)) => tiny_http::Server::https(
430 (addr, port),
431 tiny_http::SslConfig {
432 certificate,
433 private_key,
434 },
435 ),
436 None => tiny_http::Server::http((addr, port)),
437 }
438 .map_err(|e| std::io::Error::other(e.to_string()))?;
439 let port = match server.server_addr().to_ip() {
440 Some(addr) => addr.port(),
441 None => 0,
442 };
443 Ok(Self {
444 root: root.to_path_buf(),
445 server: std::sync::Arc::new(server),
446 port,
447 auth: std::sync::Arc::new(auth),
448 platform: None,
449 queue: None,
450 queue_in_flight: std::sync::Arc::new(queue_api::InFlight::default()),
451 site_repo: None,
452 downloads: None,
453 scheme,
454 listener_scheme,
455 internal_token: choir_identity::ActorKey::generate().actor_id().to_hex(),
456 keys_watch: None,
457 acl_watch: None,
458 ui_cache: std::sync::Arc::new(ui::UiCache::new()),
459 request_log: None,
460 rate: None,
461 accounts: None,
462 passkeys: false,
463 sessions: std::sync::Arc::new(session::Sessions::default()),
464 // One ceiling for the whole pre-auth surface, because there
465 // is no per-client key to hold a second one against (D59).
466 // Sized for a node's worth of real joining rather than for one
467 // reader: a reader fetches the join page, submits it, and
468 // lands on the welcome page, which is three requests.
469 public_rate: std::sync::Arc::new(limits::PublicLimiter::new(600)),
470 ssh_enabled: false,
471 acl_merged: std::sync::RwLock::new(None),
472 quotas: quota::Quotas::default(),
473 api_body_limit: std::num::NonZeroU64::new(DEFAULT_API_BODY_BYTES)
474 .expect("the default API body limit is nonzero"),
475 browser_writes: true,
476 ready_min_free_bytes: DEFAULT_READY_MIN_FREE_BYTES,
477 counters: std::sync::Arc::new(limits::Counters::default()),
478 started_unix: std::time::SystemTime::now()
479 .duration_since(std::time::UNIX_EPOCH)
480 .map(|since| since.as_secs())
481 .unwrap_or_default(),
482 shutdown: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
483 })
484 }
485
486 /// Sets the absolute body ceiling for every `/api/...` route.
487 pub fn enable_api_body_limit(&mut self, bytes: std::num::NonZeroU64) {
488 self.api_body_limit = bytes;
489 }
490
491 /// Removes browser mutation controls and their preparation endpoint.
492 ///
493 /// **It withholds authorship, not credentials** (D73). A browser under
494 /// this flag renders no control that would put an operation in the op
495 /// log -- no verdict, no comment, no `/api/prepare` -- because that is
496 /// the launch gate D39 shipped behind and the thing an operator
497 /// switches on when they are ready for it.
498 ///
499 /// It does not withhold signing in, enrolling the passkey that signs
500 /// in, asking for access (D72), or the operator's console. None of
501 /// those reaches the log: the accounts store is a node-owned file and
502 /// revocation is deletion (D36). Withholding them was the same flag
503 /// doing two jobs, and the second job made the node's own manifest
504 /// untrue -- `passkeys=enabled` beside a posture that would not serve
505 /// the file the ceremony is written in.
506 pub fn disable_browser_writes(&mut self) {
507 self.browser_writes = false;
508 }
509
510 /// Presents one repository as this node's entire browser surface.
511 ///
512 /// `/` becomes that repository instead of the index, and the browser
513 /// answers for no other repository. This is what a node serving a
514 /// project's own domain wants: a reader arriving at
515 /// `git.example.com` came for that project, and an index naming
516 /// every other repository the host holds is both noise and a
517 /// disclosure.
518 ///
519 /// Presentation only. It changes no grant: git access stays the
520 /// ACL's answer, and a repository hidden here is still clonable by
521 /// whoever could clone it before.
522 /// # Errors
523 ///
524 /// Refuses a name this node could not hold, checked here rather than
525 /// at the call site: the router trusts this value against the disk,
526 /// so the grammar has to be enforced where it is stored.
527 pub fn serve_single_repository(&mut self, repo: &str) -> std::io::Result<()> {
528 let invalid = || {
529 std::io::Error::new(
530 std::io::ErrorKind::InvalidInput,
531 format!("not a repository this node can present: {repo}"),
532 )
533 };
534 let (owner, name) = repo.split_once('/').ok_or_else(invalid)?;
535 if !provision::safe_segment(owner) || !provision::safe_segment(name) {
536 return Err(invalid());
537 }
538 self.site_repo = Some(repo.to_string());
539 Ok(())
540 }
541
542 /// Serves `dir` as the release shelf: prebuilt binaries, their
543 /// digests, and an installer that fetches them from this node (D79).
544 ///
545 /// Everything under `/download/` is then readable with no
546 /// credential. That is the point of it — an install command a
547 /// stranger cannot run installs nothing — and it is why the consent
548 /// is this call rather than a grant in the ACL: D78 made the ACL the
549 /// answer for *a repository*, and a shelf is not one. It has no
550 /// owner, no history and no scope to name in the table, only files
551 /// an operator deliberately placed in one directory.
552 ///
553 /// Nothing is ever written here, and nothing is fetched. Placing a
554 /// release on the shelf is an operator action outside this process.
555 ///
556 /// # Errors
557 ///
558 /// Refuses a path that is not an existing directory. A shelf that
559 /// does not resolve is a route that answers `404` to every reader
560 /// while the operator believes they published something.
561 pub fn publish_downloads(&mut self, dir: std::path::PathBuf) -> std::io::Result<()> {
562 if !dir.is_dir() {
563 return Err(std::io::Error::new(
564 std::io::ErrorKind::NotFound,
565 format!("not a directory to publish: {}", dir.display()),
566 ));
567 }
568 self.downloads = Some(dir);
569 Ok(())
570 }
571
572 /// Sets the free-space floor below which `/readyz` refuses traffic.
573 pub fn enable_ready_min_free_bytes(&mut self, bytes: u64) {
574 self.ready_min_free_bytes = bytes;
575 }
576
577 /// Records every served request to `path` (D33), rotating it at
578 /// `max_bytes` — see [`limits::RequestLog`] for the line format, the
579 /// rotation rule, and what is deliberately never written.
580 ///
581 /// # Errors
582 ///
583 /// Returns the failure to open the file. Fatal by design: an operator
584 /// who asked for a record and did not get one should learn that at
585 /// startup rather than from its absence during an incident.
586 pub fn enable_request_log(&mut self, path: PathBuf, max_bytes: u64) -> std::io::Result<()> {
587 self.request_log = Some(std::sync::Arc::new(limits::RequestLog::open(
588 path, max_bytes,
589 )?));
590 Ok(())
591 }
592
593 /// Limits each authenticated user to the given requests per minute
594 /// per class (D33). `None` leaves that class unlimited.
595 ///
596 /// Never applied to the loopback hook callback, to a holder of a D29
597 /// `@node` grant, or on a node without authentication — see
598 /// [`Node::serve_forever`] for why each of those would be worse than
599 /// the flood it prevents.
600 pub fn enable_rate_limit(
601 &mut self,
602 api_per_minute: Option<std::num::NonZeroU32>,
603 git_per_minute: Option<std::num::NonZeroU32>,
604 ) {
605 let limiter = limits::RateLimiter::new(api_per_minute, git_per_minute);
606 self.rate = limiter.is_active().then(|| std::sync::Arc::new(limiter));
607 }
608
609 /// Sets the per-user quotas (D37): the largest git request body one
610 /// user may send, and the most workspaces one user may hold at once.
611 /// `None` leaves that ceiling off.
612 ///
613 /// Exempt exactly where the D33 rate limiter is exempt, and for the
614 /// same reason — see [`Node::serve_forever`]. A quota that can lock
615 /// an operator out of their own node is the failure this must not
616 /// cause.
617 pub fn enable_quotas(
618 &mut self,
619 push_bytes: Option<std::num::NonZeroU64>,
620 workspaces: Option<std::num::NonZeroU32>,
621 ) {
622 self.quotas = quota::Quotas {
623 push_bytes,
624 workspaces,
625 };
626 }
627
628 /// Enables the platform API (`/api/submit`, `/api/view`) backed by
629 /// `platform`. Call before [`Node::serve_forever`].
630 pub fn enable_platform(&mut self, platform: Platform) {
631 self.enable_shared_platform(std::sync::Arc::new(platform));
632 }
633
634 /// [`Node::enable_platform`] for a platform something else also holds:
635 /// a seed's replicator writes into the same platform this node serves
636 /// (D80).
637 pub fn enable_shared_platform(&mut self, platform: std::sync::Arc<Platform>) {
638 // One of the three halves of the join in `enable_accounts` and
639 // `enable_passkeys`: whichever flag is applied last attaches the
640 // store, so passkey verification does not depend on the order the
641 // daemon happens to configure in (D39). The store is withheld
642 // while passkeys are off, which is what keeps the write path shut
643 // rather than merely unadvertised.
644 if self.passkeys {
645 if let Some(store) = self.accounts.as_ref() {
646 platform.attach_accounts(store.clone());
647 }
648 }
649 self.platform = Some(platform);
650 }
651
652 /// Enables `POST /api/queue/run` (D5, D68).
653 ///
654 /// Without it the endpoint answers 501. There is deliberately no
655 /// timer: a node that spends CI on its own schedule surprises
656 /// whoever pays for it, and a round only a clock can start is one no
657 /// test can reach without waiting on wall-clock time. An operator's
658 /// cron, a hook, or a person decides the cadence.
659 pub fn enable_queue(&mut self, config: queue_api::QueueConfig) {
660 self.queue = Some(config);
661 }
662
663 /// The platform this node serves, or `None` if the platform API was
664 /// never enabled.
665 #[must_use]
666 pub fn platform(&self) -> Option<&Platform> {
667 self.platform.as_deref()
668 }
669
670 /// Brings the bare repos back into agreement with the view before the
671 /// node serves anything. See [`Platform::reconcile_git_refs`] for what
672 /// it repairs and what it refuses to.
673 ///
674 /// Separate from [`Node::enable_platform`] and from
675 /// [`Node::serve_forever`] so it is called deliberately: it writes git
676 /// refs and can append compensating ops, which is not something a
677 /// constructor should do behind a caller's back.
678 pub fn reconcile_refs(&self) -> crate::platform::RefReconciliation {
679 self.platform
680 .as_ref()
681 .map(|p| p.reconcile_git_refs(&self.root))
682 .unwrap_or_default()
683 }
684
685 /// Watches the trusted-keys file and regenerates
686 /// `<root>/.choir/allowed_signers` whenever its mtime moves, so
687 /// registering a *signing* key is "append a line" — the same
688 /// mechanism the platform registry already uses for submission
689 /// keys, which until now diverged from push-certificate
690 /// verification and left the two lists out of step.
691 pub fn watch_keys_file(&mut self, path: PathBuf) {
692 self.keys_watch = Some(std::sync::Arc::new(KeysWatch {
693 mtime: std::sync::Mutex::new(std::fs::metadata(&path).and_then(|m| m.modified()).ok()),
694 path,
695 }));
696 }
697
698 /// Rewrites `allowed_signers` if the watched keys file changed.
699 /// Runs on the accept loop, so rewrites never race each other.
700 ///
701 /// A malformed keys file leaves the existing signer list in place
702 /// (a partial list would silently stop verifying somebody's
703 /// pushes); the mtime is still recorded so the complaint is printed
704 /// once per edit rather than once per request.
705 fn refresh_allowed_signers(&self) {
706 let Some(watch) = &self.keys_watch else {
707 return;
708 };
709 let mtime = std::fs::metadata(&watch.path)
710 .and_then(|m| m.modified())
711 .ok();
712 let mut last = watch.mtime.lock().expect("keys mtime lock");
713 if mtime.is_none() || mtime == *last {
714 return;
715 }
716 *last = mtime;
717 match parse_keys_file(&watch.path) {
718 Ok(signers) => {
719 if let Err(e) = write_allowed_signers(&self.root, &signers) {
720 eprintln!("allowed_signers: write failed: {e}");
721 } else {
722 eprintln!("allowed_signers: reloaded ({} keys)", signers.len());
723 }
724 // Same file, same edit: pick up name bindings here too, so
725 // binding a name to an already-trusted key does not wait
726 // for the policy's failed-signature reload trigger.
727 if let Some(platform) = &self.platform {
728 platform.set_key_names(&signers);
729 }
730 }
731 Err(e) => eprintln!("allowed_signers: keys file unusable, keeping previous: {e}"),
732 }
733 }
734
735 /// Enforces per-repository authorization (D29) from `path`, reloaded
736 /// whenever its mtime moves — so granting access is "append a line",
737 /// the same discipline as the trusted-keys file.
738 ///
739 /// # Errors
740 ///
741 /// Returns a message when the file cannot be read or does not parse.
742 /// This is fatal by design: there is no previous table to fall back
743 /// to at startup, and an empty table under a fail-closed ACL locks
744 /// out everyone including the operator.
745 pub fn watch_acl_file(&mut self, path: PathBuf) -> Result<(), String> {
746 let table = acl::Acl::load(&path)?;
747 eprintln!(
748 "acl enabled ({} grants{})",
749 table.len(),
750 expired_note(&table)
751 );
752 self.acl_watch = Some(std::sync::Arc::new(AclWatch {
753 mtime: std::sync::Mutex::new(std::fs::metadata(&path).and_then(|m| m.modified()).ok()),
754 table: std::sync::RwLock::new(std::sync::Arc::new(table)),
755 epoch: std::sync::atomic::AtomicU64::new(0),
756 path,
757 }));
758 Ok(())
759 }
760
761 /// Declares that a TLS-terminating proxy sits in front of this node,
762 /// so absolute URLs it builds are written `https` and the cookies it
763 /// sets carry `Secure`.
764 ///
765 /// Declared by the operator rather than read from
766 /// `X-Forwarded-Proto`, because the node cannot tell a header its
767 /// proxy set from one a client sent: trusting it would mean any
768 /// caller that can reach the node decides how its invite links are
769 /// spelled. The proxy this repository ships already *sets* that
770 /// header rather than appending to it, for the same reason
771 /// `X-Forwarded-For` is cleared there (D59), and a declaration needs
772 /// no such care.
773 ///
774 /// The defect this exists for: an invite is a bearer credential
775 /// carried in a URL, and behind the proxy the node was writing that
776 /// URL with `http`. The recipient's first request would carry the
777 /// credential in cleartext and only then be redirected.
778 pub fn behind_tls_proxy(&mut self) {
779 // Only the public half. `listener_scheme` stays what this socket
780 // speaks, because the hook callbacks address loopback directly and
781 // never pass the proxy at all.
782 self.scheme = "https";
783 }
784
785 /// Turns on passkeys: WebAuthn enrolment and the browser write path
786 /// that verifies assertions against enrolled keys (D39, D71).
787 ///
788 /// Separate from [`Node::enable_accounts`] even though both need the
789 /// accounts store, because a node can reasonably offer self-service
790 /// credentials without offering browser signing, and the private beta
791 /// says in its manifest that it does exactly that. Without this
792 /// switch that sentence could not be true: the enrolment routes and
793 /// `platform`'s assertion check are both reachable the moment the
794 /// store exists.
795 pub fn enable_passkeys(&mut self) {
796 self.passkeys = true;
797 // The third way into the same join. Enabling passkeys after both
798 // of the others is the ordinary case, and without this the store
799 // would never reach the policy.
800 if let (Some(platform), Some(store)) = (self.platform.as_ref(), self.accounts.as_ref()) {
801 platform.attach_accounts(store.clone());
802 }
803 }
804
805 /// Turns on account and token self-service (D36) from the store at
806 /// `path`, optionally generating the `authorized_keys` D31's forced
807 /// commands live in.
808 ///
809 /// # Errors
810 ///
811 /// Refuses without `--auth-file` and without `--acl-file`, for the
812 /// reason D29 and D33 refuse the same combinations: a credential
813 /// issued on a node that authenticates nobody is not a credential,
814 /// and one issued on a node with no ACL is a credential to every
815 /// repository, which is the thing being issued *against*. Also
816 /// returns the store's own load failures.
817 /// `actor_keys` names the trusted-keys file a redemption may bind
818 /// one actor key into (`--invite-binds-keys`). `None` keeps the
819 /// pre-existing behaviour, in which an actor key reaches the node
820 /// only by an operator editing that file.
821 pub fn enable_accounts(
822 &mut self,
823 path: PathBuf,
824 keys_out: Option<accounts::SshKeysOut>,
825 actor_keys: Option<PathBuf>,
826 ) -> Result<(), String> {
827 let Some(table) = self.auth.as_ref().as_ref() else {
828 return Err(
829 "--accounts-file needs --auth-file: an issued token is checked where every \
830 other credential is"
831 .to_string(),
832 );
833 };
834 if self.acl_watch.is_none() {
835 return Err(
836 "--accounts-file needs --acl-file: without a table to grade them against, \
837 an issued grant would be a grant to every repository"
838 .to_string(),
839 );
840 }
841 // Every operator credential's name, so self-service can never
842 // issue an account that shadows one.
843 let reserved = table.keys().cloned().collect();
844 self.ssh_enabled = keys_out.is_some();
845 let mut store = accounts::Accounts::open(path, keys_out, reserved)?;
846 if let Some(actor_keys) = actor_keys {
847 store = store.binding_actor_keys_into(actor_keys);
848 }
849 eprintln!("accounts enabled ({} issued)", store.len());
850 let store = std::sync::Arc::new(store);
851 // Any order: whichever flag is applied last performs the join, so
852 // a passkey submission is verifiable regardless of how the daemon
853 // was configured (D39). Withheld while passkeys are off.
854 if self.passkeys {
855 if let Some(platform) = self.platform.as_ref() {
856 platform.attach_accounts(store.clone());
857 }
858 }
859 self.accounts = Some(store);
860 Ok(())
861 }
862
863 /// Reparses the ACL file if it changed. Runs on the accept loop, so
864 /// an edit takes effect on the *next* request with no restart.
865 ///
866 /// A malformed file leaves the previous table in force and complains
867 /// once per edit — the same rule as the keys file, for the same
868 /// reason: a partially parsed ACL would silently revoke access.
869 fn refresh_acl(&self) {
870 let Some(watch) = &self.acl_watch else {
871 return;
872 };
873 let mtime = std::fs::metadata(&watch.path)
874 .and_then(|m| m.modified())
875 .ok();
876 let mut last = watch.mtime.lock().expect("acl mtime lock");
877 if mtime.is_none() || mtime == *last {
878 return;
879 }
880 *last = mtime;
881 match acl::Acl::load(&watch.path) {
882 Ok(table) => {
883 eprintln!(
884 "acl: reloaded ({} grants{})",
885 table.len(),
886 expired_note(&table)
887 );
888 *watch.table.write().expect("acl write lock") = std::sync::Arc::new(table);
889 watch
890 .epoch
891 .fetch_add(1, std::sync::atomic::Ordering::Release);
892 }
893 Err(e) => eprintln!("acl: file unusable, keeping previous: {e}"),
894 }
895 }
896
897 /// The ACL table currently in force, if one is configured: the
898 /// operator's file, merged with the grants self-service has issued
899 /// (D36).
900 ///
901 /// Merged rather than checked separately so that every enforcement
902 /// point — the git chokepoint, the API's per-endpoint table, the
903 /// response filter, the D33 rate-limit exemption — keeps asking one
904 /// table one question. The merge is cached against both sources'
905 /// generations, so the ordinary request pays two atomic loads.
906 ///
907 /// The cache is keyed on the current second as well (D66), which is
908 /// the whole reason a deadline can be trusted here: a table merged
909 /// once and held would keep answering for a grant that has lapsed,
910 /// and no reload would happen to invalidate it because nothing about
911 /// the file changed. One rebuild per second per node is what that
912 /// costs, over a table of a few dozen rows.
913 fn acl_now(&self) -> Option<std::sync::Arc<acl::Effective>> {
914 let watch = self.acl_watch.as_ref()?;
915 let now = crate::accounts::now_secs();
916 let epoch = watch.epoch.load(std::sync::atomic::Ordering::Acquire);
917 let generation = self.accounts.as_ref().map_or(0, |store| store.generation());
918 if let Some((cached_epoch, cached_generation, cached_now, table)) = self
919 .acl_merged
920 .read()
921 .expect("merged acl read lock")
922 .as_ref()
923 {
924 if *cached_epoch == epoch && *cached_generation == generation && *cached_now == now {
925 return Some(std::sync::Arc::clone(table));
926 }
927 }
928 let file = std::sync::Arc::clone(&watch.table.read().expect("acl read lock"));
929 let merged = match self.accounts.as_ref() {
930 Some(store) => file.merged(&store.acl()),
931 None => (*file).clone(),
932 };
933 let effective = std::sync::Arc::new(merged.at(now));
934 *self.acl_merged.write().expect("merged acl write lock") =
935 Some((epoch, generation, now, std::sync::Arc::clone(&effective)));
936 Some(effective)
937 }
938
939 /// Port the daemon is listening on.
940 pub fn port(&self) -> u16 {
941 self.port
942 }
943
944 /// Writes the handoff file the SSH shim reads (D31): where this
945 /// daemon is listening, and the loopback secret its git hooks
946 /// authenticate with.
947 ///
948 /// Both are per-process values — an ephemeral port, a secret minted
949 /// at startup — so a forced command written once cannot carry them.
950 /// The secret is not returned to the caller, only written, at 0600.
951 ///
952 /// The ACL file goes in too, when one is configured, so a forced
953 /// command that forgot `--acl-file` still enforces what this daemon
954 /// enforces rather than reaching every repository.
955 ///
956 /// # Errors
957 ///
958 /// Any I/O error creating or writing the file.
959 pub fn write_ssh_handoff(&self, path: &Path) -> std::io::Result<()> {
960 let base = format!("{}://127.0.0.1:{}", self.scheme, self.port);
961 let acl = self.acl_watch.as_ref().map(|watch| watch.path.as_path());
962 // D36: the store goes in too, so the shim grades a self-served
963 // account by the same grants the HTTP route does. Call
964 // `enable_accounts` before this, or the line is absent and every
965 // issued grant is invisible over SSH.
966 let accounts = self.accounts.as_ref().map(|store| store.path());
967 ssh::write_handoff(path, &base, &self.internal_token, acl, accounts)
968 }
969
970 /// Creates a bare repo `name` (e.g. `"owner/repo.git"`) with pushes
971 /// enabled.
972 ///
973 /// # Errors
974 ///
975 /// Fails when the path exists or `git init` fails.
976 pub fn create_repo(&self, name: &str) -> std::io::Result<()> {
977 create_repo_in(&self.root, name).map(|_| ())
978 }
979
980 /// Brings an *existing* bare repo under this root back under the
981 /// sequencer: the same hook and the same git config
982 /// [`Node::create_repo`] installs.
983 ///
984 /// This is what a restore needs, and without it a restore is silently
985 /// unsound. Objects arrive as a bundle, which `git clone --bare` and
986 /// `git fetch` both unpack into a repo with **no `pre-receive` hook** —
987 /// and a repo with no hook is served normally while every push into it
988 /// bypasses the sequencer entirely, landing refs that no op in the log
989 /// ever records. The alternative order is worse: letting the node
990 /// create the repos empty and unpacking afterwards means startup
991 /// reconciliation sees a log naming commits git does not have, decides
992 /// the log is unbackable, and *appends retractions for every restored
993 /// ref* (see [`Platform::reconcile_git_refs`]).
994 ///
995 /// It also re-points `gpg.ssh.allowedSignersFile`, which
996 /// [`Node::create_repo`] wrote as an absolute path into the root that
997 /// existed then. A restore onto a different path leaves that config
998 /// naming a directory that may not exist, or worse, one that does and
999 /// holds someone else's keys.
1000 ///
1001 /// Idempotent, and safe to run on every start. The one value it does
1002 /// not overwrite is `receive.certNonceSeed`: rotating it would refuse
1003 /// the signed pushes already in flight against the old seed.
1004 ///
1005 /// # Errors
1006 ///
1007 /// Fails when the path does not exist, is not a git repository, or a
1008 /// `git config` call fails.
1009 pub fn adopt_repo(&self, name: &str) -> std::io::Result<()> {
1010 let path = self.repo_path(name)?;
1011 if !path.join("objects").is_dir() {
1012 return Err(std::io::Error::new(
1013 std::io::ErrorKind::NotFound,
1014 format!("{name}: not a bare git repository"),
1015 ));
1016 }
1017 self.configure_repo(&path)
1018 }
1019
1020 /// Adopts every bare repository already under this node's root,
1021 /// whatever put it there, and returns how many.
1022 ///
1023 /// Adoption is what installs the `pre-receive` hook, so until a
1024 /// repository has been adopted it is served with **no hook** and
1025 /// every push into it bypasses the sequencer, landing refs that no
1026 /// op in the log ever records — invariants 5 and 6 both, broken
1027 /// silently.
1028 ///
1029 /// That used to be reachable in ordinary operation, because
1030 /// adoption only ever happened for repositories named in
1031 /// `--create`. A repository restored from a bundle, moved in, or
1032 /// created against a running node was listed nowhere and hooked
1033 /// never. Walking the root closes the gap at its source: the
1034 /// question "what is this node about to serve" is answered by the
1035 /// filesystem, which is the same thing [`portable::export`] already
1036 /// asks.
1037 ///
1038 /// Idempotent, and meant to run on every start: the one value
1039 /// [`Node::adopt_repo`] does not overwrite is
1040 /// `receive.certNonceSeed`, so an ordinary restart costs a few `git
1041 /// config` calls and changes nothing.
1042 ///
1043 /// # Errors
1044 ///
1045 /// Fails when the root cannot be walked, or when a repository under
1046 /// it cannot be adopted. Both are fatal rather than skipped:
1047 /// serving an unadopted repository is the exact failure this
1048 /// prevents, so a node that cannot guarantee the hook must not
1049 /// start.
1050 pub fn adopt_existing_repos(&self) -> std::io::Result<usize> {
1051 let found = crate::portable::repos(&self.root).map_err(|why| {
1052 std::io::Error::new(
1053 std::io::ErrorKind::InvalidInput,
1054 format!("cannot list repositories under the root: {why}"),
1055 )
1056 })?;
1057 for name in &found {
1058 self.adopt_repo(name)?;
1059 }
1060 Ok(found.len())
1061 }
1062
1063 /// The configuration and hook shared by [`Node::create_repo`] and
1064 /// [`Node::adopt_repo`], applied to an initialized bare repo.
1065 fn configure_repo(&self, path: &Path) -> std::io::Result<()> {
1066 configure_repo_in(&self.root, path)
1067 }
1068}
1069
1070/// [`Node::configure_repo`], against a root rather than a bound node.
1071///
1072/// Split out because creating a repository needs nothing from a `Node`
1073/// except where its repositories live, and the API handler that now
1074/// creates them holds the root but not the node — the same reason
1075/// `/api/queue/run` is routed outside the platform.
1076fn configure_repo_in(root: &Path, path: &Path) -> std::io::Result<()> {
1077 // Seeded once and then left alone: `create_repo` reaches here with
1078 // it unset, `adopt_repo` with it already set from whenever the repo
1079 // was made, and re-seeding would invalidate every signed push
1080 // holding a nonce from the old seed.
1081 if !std::process::Command::new("git")
1082 .args(["config", "--get", "receive.certNonceSeed"])
1083 .current_dir(path)
1084 .stdout(std::process::Stdio::null())
1085 .status()?
1086 .success()
1087 && !std::process::Command::new("git")
1088 .args([
1089 "config",
1090 "receive.certNonceSeed",
1091 &choir_identity::ActorKey::generate().actor_id().to_hex(),
1092 ])
1093 .current_dir(path)
1094 .status()?
1095 .success()
1096 {
1097 return Err(std::io::Error::other("git config failed"));
1098 }
1099 let ok = std::process::Command::new("git")
1100 .args(["config", "http.receivepack", "true"])
1101 .current_dir(path)
1102 .status()?
1103 .success()
1104 // Pin hooks to this repo: a host-global core.hooksPath (set
1105 // by e.g. husky) would otherwise silently bypass the
1106 // sequencer hook below.
1107 && std::process::Command::new("git")
1108 .args(["config", "core.hooksPath", "hooks"])
1109 .current_dir(path)
1110 .status()?
1111 .success()
1112 // Advertise push certificates (`git push --signed`) and
1113 // verify their ssh signatures against the allowed-signers
1114 // file (per-actor keys, L8).
1115 && std::process::Command::new("git")
1116 .args(["config", "gpg.format", "ssh"])
1117 .current_dir(path)
1118 .status()?
1119 .success()
1120 && std::process::Command::new("git")
1121 .args(["config", "gpg.ssh.allowedSignersFile"])
1122 .arg(
1123 root.canonicalize()
1124 .unwrap_or_else(|_| root.to_path_buf())
1125 .join(".choir")
1126 .join("allowed_signers"),
1127 )
1128 .current_dir(path)
1129 .status()?
1130 .success()
1131 // Serve filtered fetches, which is what makes `git clone
1132 // --filter=blob:none --sparse` and `git sparse-checkout` work
1133 // against this node. Git refuses a filter without it (D48).
1134 //
1135 // This is the whole of choir's answer to lazy checkouts of a
1136 // large repository: the client already ships the feature, and
1137 // the alternative -- a userspace filesystem hydrating blobs on
1138 // demand -- would be our code on the read path of every file
1139 // access, needing a kernel extension per platform, to
1140 // reimplement something the transport already does. A node
1141 // that speaks git inherits partial clone; it should not
1142 // reimplement it.
1143 && std::process::Command::new("git")
1144 .args(["config", "uploadpack.allowFilter", "true"])
1145 .current_dir(path)
1146 .status()?
1147 .success()
1148 // A promisor client fetches missing blobs by exact oid, and
1149 // those oids are reachable-but-not-advertised as far as
1150 // upload-pack is concerned. Without this every lazy hydration
1151 // after the initial clone fails, which presents as a working
1152 // clone whose first `git checkout` cannot read a file.
1153 && std::process::Command::new("git")
1154 .args(["config", "uploadpack.allowAnySHA1InWant", "true"])
1155 .current_dir(path)
1156 .status()?
1157 .success();
1158 if !ok {
1159 return Err(std::io::Error::other("git config failed"));
1160 }
1161 // The pre-receive hook routes every ref update of a push through
1162 // the platform sequencer (pre-receive, not update: only
1163 // pre-/post-receive see GIT_PUSH_CERT_* for signed pushes, and
1164 // one invocation covers the whole push). Outside the daemon (no
1165 // CHOIR_API) it is a no-op.
1166 //
1167 // Git applies no ref until this hook exits zero, so a refusal on
1168 // the third ref of a push has already left two ops in the durable
1169 // log for refs git will never create. Those refs are then stuck:
1170 // the pusher's `old` is git's absent value while the view holds
1171 // the stranded one, so every retry loses the CAS. Hence the
1172 // retraction pass over what this push already had accepted, which
1173 // is a compensating op rather than an erasure -- the log is
1174 // append-only, and an aborted push belongs in the history.
1175 let hook = path.join("hooks").join("pre-receive");
1176 std::fs::write(
1177 &hook,
1178 concat!(
1179 "#!/bin/sh\n",
1180 "# choir: route this push's ref updates through the platform sequencer.\n",
1181 "if [ -z \"$CHOIR_API\" ]; then cat >/dev/null; exit 0; fi\n",
1182 // The response body is captured rather than discarded
1183 // (which is what `curl -f` did): the node's refusals name
1184 // a reason and a repair, and a pusher told only "rejected
1185 // by sequencer" has to ask a human what they did wrong.
1186 "reply=$(mktemp) || exit 1\n",
1187 "post() {\n",
1188 " payload=$(printf '{\"repo\":\"%s\",\"refname\":\"%s\",\"old\":\"%s\",\"new\":\"%s\",\"user\":\"%s\",\"cert_status\":\"%s\",\"signer\":\"%s\"}' \\\n",
1189 " \"$CHOIR_REPO\" \"$3\" \"$1\" \"$2\" \"$CHOIR_USER\" \"$GIT_PUSH_CERT_STATUS\" \"$GIT_PUSH_CERT_SIGNER\")\n",
1190 " code=$(curl -sk -o \"$reply\" -w '%{http_code}' \\\n",
1191 " --connect-timeout 5 --max-time 30 \\\n",
1192 " -X POST -H \"X-Choir-Internal: $CHOIR_INTERNAL\" \\\n",
1193 " -d \"$payload\" \"$4\")\n",
1194 // A connection that never completed reports 000, which
1195 // falls through to the failure branch with the others.
1196 //
1197 // The timeouts are what make that sentence true. Without
1198 // them curl waits forever, and this hook holds the push
1199 // open while it does: the client has already sent every
1200 // object and sits there with no output and no failure.
1201 // That is exactly how a wrong scheme in this URL was
1202 // experienced -- a TLS handshake against a plaintext
1203 // port, hanging rather than refusing. A push that cannot
1204 // reach the sequencer must be rejected, loudly and soon;
1205 // it must never become a push that never ends. 30s is far
1206 // above a healthy decision, which the gate holds under
1207 // 100ms at p99, and far below a person's patience.
1208 " case \"$code\" in 2??) return 0 ;; *) return 1 ;; esac\n",
1209 "}\n",
1210 "reason() {\n",
1211 " sed -n 's/.*\"error\":\"\\([^\"]*\\)\".*/\\1/p' \"$reply\" | head -1\n",
1212 "}\n",
1213 // A file, not a shell variable: this has to survive being
1214 // read back line by line, and the accepted list is the
1215 // only record of what needs undoing.
1216 "done_refs=$(mktemp) || exit 1\n",
1217 "abort() {\n",
1218 " while read -r a_old a_new a_ref; do\n",
1219 " post \"$a_old\" \"$a_new\" \"$a_ref\" \"$CHOIR_ABORT\" ||\n",
1220 " echo \"choir: could not retract $a_ref; the node's log now holds a ref \\\n",
1221 "this push did not create\" >&2\n",
1222 " done < \"$done_refs\"\n",
1223 "}\n",
1224 "while read old new ref; do\n",
1225 " if ! post \"$old\" \"$new\" \"$ref\" \"$CHOIR_API\"; then\n",
1226 " why=$(reason)\n",
1227 " if [ -n \"$why\" ]; then\n",
1228 " echo \"choir: $ref rejected: $why\" >&2\n",
1229 " else\n",
1230 " echo \"choir: ref update rejected by sequencer: $ref\" >&2\n",
1231 " fi\n",
1232 " abort\n",
1233 " rm -f \"$done_refs\" \"$reply\"\n",
1234 " exit 1\n",
1235 " fi\n",
1236 " printf '%s %s %s\\n' \"$old\" \"$new\" \"$ref\" >> \"$done_refs\"\n",
1237 "done\n",
1238 "rm -f \"$done_refs\" \"$reply\"\n",
1239 "exit 0\n",
1240 ),
1241 )?;
1242 #[cfg(unix)]
1243 {
1244 use std::os::unix::fs::PermissionsExt;
1245 std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755))?;
1246 }
1247 Ok(())
1248}
1249
1250/// `GET /api/repos` — which repositories this credential can see.
1251///
1252/// Answered from the filesystem rather than from the view, for the same
1253/// reason [`create_repo_request`] writes no op: a repository is not an
1254/// entity in the [`View`](choir_view::View), and `portable::repos`
1255/// already answers "which ones exist" by walking the root.
1256///
1257/// Narrowed rather than refused, the way the other aggregate reads are:
1258/// a reader granted one repository has a legitimate view of that
1259/// repository, and a list that answered 403 because it also contains
1260/// somebody else's would make the grant useless. With no ACL table
1261/// configured every repository is listed, because with no table there
1262/// is no one to narrow to.
1263fn repos_request(root: &Path, acl: Option<&acl::Effective>, user: &str) -> (u16, String) {
1264 // A root that cannot be walked is a broken node, not an empty one,
1265 // and saying "no repositories" to that question is the answer that
1266 // sends somebody looking in the wrong place.
1267 let found = match portable::repos(root) {
1268 Ok(found) => found,
1269 Err(error) => return (500, serde_json::json!({ "error": error }).to_string()),
1270 };
1271 let mut names: Vec<String> = found
1272 .into_iter()
1273 .filter(|name| acl.is_none_or(|t| t.allows_repo(user, name, acl::Level::Read)))
1274 .collect();
1275 names.sort();
1276 let body = serde_json::json!({
1277 "format_version": 1,
1278 "repos": names,
1279 // Named so a caller can tell "you can see none" from "there are
1280 // none", which are the same empty list and different problems.
1281 "narrowed": acl.is_some(),
1282 });
1283 (200, body.to_string())
1284}
1285
1286/// `POST /api/repo` — create a repository on a running node.
1287///
1288/// Until this existed, a repository could only be made by naming it in
1289/// `--create` at startup, so adding one to a live node meant stopping
1290/// it. That is the whole reason this endpoint is here: a person who has
1291/// just installed choir should be able to put a repository on their own
1292/// instance without restarting the thing they just started.
1293///
1294/// Nothing is appended to the log. A repository is not modelled in the
1295/// [`View`](choir_view::View) — `refs` is keyed by name and there is no
1296/// repository entity — and `portable::export` already answers "which
1297/// repositories exist" by walking the filesystem. Creating one is
1298/// therefore a node-local act with no op to write, and adding an
1299/// `OpKind` for it would be a format commitment bought for nothing. If
1300/// repositories should later become first-class in the total order that
1301/// stays open: the field would be additive, like every other.
1302///
1303/// The response carries the clone URL because the next thing the caller
1304/// does is clone, and making them assemble it from the name and the
1305/// base is how a trailing `.git` goes missing.
1306fn create_repo_request(root: &Path, body: &[u8]) -> (u16, String) {
1307 let refuse = |code: u16, error: &str, next: &str| {
1308 (
1309 code,
1310 serde_json::json!({ "error": error, "next": next }).to_string(),
1311 )
1312 };
1313 let Ok(request): Result<serde_json::Value, _> = serde_json::from_slice(body) else {
1314 return refuse(
1315 400,
1316 "the request body is not JSON",
1317 "POST {\"name\": \"owner/repo.git\"}",
1318 );
1319 };
1320 let Some(name) = request["name"].as_str() else {
1321 return refuse(
1322 400,
1323 "no `name` in the request",
1324 "POST {\"name\": \"owner/repo.git\"}",
1325 );
1326 };
1327 // A repository served without `.git` is one that `git clone` finds
1328 // by a name the node does not use, so the suffix is required rather
1329 // than guessed at. Appending it silently would make two spellings of
1330 // one repository, which is how an ACL entry comes to govern nothing.
1331 if !name.ends_with(".git") {
1332 return refuse(
1333 400,
1334 "a repository name must end in `.git`",
1335 "name it `owner/repo.git`",
1336 );
1337 }
1338 match create_repo_in(root, name) {
1339 Ok(_) => (
1340 201,
1341 serde_json::json!({ "format_version": 1, "name": name, "created": true }).to_string(),
1342 ),
1343 // Already there is not an error worth a 500: the caller asked
1344 // for a repository to exist and it does. It is still not a 201,
1345 // because a caller that would have pushed into an empty one
1346 // needs to know it is not empty.
1347 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => refuse(
1348 409,
1349 "that repository already exists",
1350 "clone it, or choose another name",
1351 ),
1352 Err(e) if e.kind() == std::io::ErrorKind::InvalidInput => refuse(
1353 400,
1354 "that is not a legal repository name",
1355 "use `owner/repo.git`, without `..`, `@` or `*`",
1356 ),
1357 Err(e) => (
1358 500,
1359 serde_json::json!({ "error": format!("could not create the repository: {e}") })
1360 .to_string(),
1361 ),
1362 }
1363}
1364
1365/// Creates a bare repository under `root` and brings it under the
1366/// sequencer, against a root rather than a bound node.
1367///
1368/// The whole create path needs nothing from a [`Node`] but where its
1369/// repositories live, which is what lets a request create one: the API
1370/// handler holds the root and not the node.
1371///
1372/// # Errors
1373///
1374/// Fails when the name is not a legal repository name, when the path
1375/// already exists, or when `git init` or a `git config` call fails.
1376fn create_repo_in(root: &Path, name: &str) -> std::io::Result<PathBuf> {
1377 let path = repo_path_in(root, name)?;
1378 if path.exists() {
1379 return Err(std::io::Error::new(
1380 std::io::ErrorKind::AlreadyExists,
1381 name.to_string(),
1382 ));
1383 }
1384 if !std::process::Command::new("git")
1385 .args(["init", "--bare", "-q"])
1386 .arg(&path)
1387 .status()?
1388 .success()
1389 {
1390 return Err(std::io::Error::other("git init failed"));
1391 }
1392 // `git init` takes the branch from the host's `init.defaultBranch`,
1393 // so without this the name in `HEAD` is decided by a config file
1394 // this node does not own -- and a node whose host says `master`
1395 // while every push says `main` advertises a default branch that
1396 // never comes into existence. Pinned rather than passed as
1397 // `--initial-branch`, which git only learned in 2.28.
1398 if !std::process::Command::new("git")
1399 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
1400 .current_dir(&path)
1401 .status()?
1402 .success()
1403 {
1404 return Err(std::io::Error::other("git symbolic-ref failed"));
1405 }
1406 configure_repo_in(root, &path)?;
1407 Ok(path)
1408}
1409
1410/// [`Node::repo_path`], against a root rather than a bound node.
1411fn repo_path_in(root: &Path, name: &str) -> std::io::Result<PathBuf> {
1412 // `@` is reserved so a repository can never alias the ACL's
1413 // `@node` pseudo-repository (D29); `*` likewise for its wildcard.
1414 if name.split('/').any(|c| c == ".." || c.is_empty())
1415 || name.starts_with('/')
1416 || name.contains('@')
1417 || name.contains('*')
1418 {
1419 return Err(std::io::Error::new(
1420 std::io::ErrorKind::InvalidInput,
1421 "bad repo name",
1422 ));
1423 }
1424 Ok(root.join(name))
1425}
1426
1427impl Node {
1428 /// Rejects path traversal and normalizes the repo path under root.
1429 fn repo_path(&self, name: &str) -> std::io::Result<PathBuf> {
1430 repo_path_in(&self.root, name)
1431 }
1432
1433 /// Serves requests until the process exits. Run on a dedicated thread.
1434 ///
1435 /// # Rate limiting and who is exempt (D33)
1436 ///
1437 /// The check runs on the request's own thread, after authentication —
1438 /// it has to be after, because the bucket is per authenticated user,
1439 /// and it must not be on the accept loop, which exists to accept.
1440 ///
1441 /// Three exemptions, each because applying the limit would be worse
1442 /// than the flood it prevents:
1443 ///
1444 /// 1. **The loopback hook callback.** A push of N refs makes N
1445 /// `/api/git-update` calls; refusing the fourth one fails the push
1446 /// halfway and drives the retraction path for refs git will never
1447 /// create. It carries a node-minted secret over loopback, so it is
1448 /// not an untrusted caller in the first place.
1449 /// 2. **A holder of a D29 `@node` grant.** That grant is already total
1450 /// authority over the node. Throttling the one actor who can repair
1451 /// it, during the incident the limiter is reporting, is the
1452 /// lockout this feature must not cause.
1453 /// 3. **Any node without `--auth-file`.** There is no per-user
1454 /// identity to bucket by, and a single shared `anon` bucket is a
1455 /// self-inflicted denial of service rather than a limit.
1456 ///
1457 /// The D37 quotas ([`Node::enable_quotas`]) take the same three,
1458 /// from the same computed value rather than a second copy of the
1459 /// rule: whether a request is metered is one question, and answering
1460 /// it twice is how the two answers start to differ.
1461 pub fn serve_forever(&self) {
1462 loop {
1463 // A writer that has failed a durability barrier refuses every
1464 // submission from then on. Staying up in that state is worse
1465 // than being down: process supervision only restarts a process
1466 // that *exits*, so the node would sit there looking healthy
1467 // to launchd while rejecting everything, and a transient fsync
1468 // error would become permanent downtime that reads as uptime.
1469 // Exiting hands it back to supervision, which restarts into
1470 // the same replay path the D20 flip proved with `kill -9`.
1471 if self
1472 .platform
1473 .as_ref()
1474 .is_some_and(|p| p.durability_failed())
1475 {
1476 eprintln!(
1477 "choir: the op log is no longer durable, so this node has stopped \
1478 accepting writes; exiting so supervision restarts it. Check the \
1479 filesystem backing the log."
1480 );
1481 // EX_TEMPFAIL: the condition may well clear on restart.
1482 std::process::exit(75);
1483 }
1484 // Do not block forever in `accept`: durability loss is
1485 // published by the writer thread and must take an otherwise
1486 // idle daemon down without waiting for another client to
1487 // arrive. One tenth of a second is far below supervisor and
1488 // human timescales while still avoiding a busy poll.
1489 let request = match self
1490 .server
1491 .recv_timeout(std::time::Duration::from_millis(100))
1492 {
1493 Ok(Some(request)) => request,
1494 Ok(None) if self.shutdown.load(std::sync::atomic::Ordering::Acquire) => return,
1495 Ok(None) => continue,
1496 Err(error) => {
1497 eprintln!("choir: HTTP accept loop failed: {error}");
1498 return;
1499 }
1500 };
1501 // Cheap stat between accepting a request and handling it, so
1502 // an edited keys file takes effect on *this* request: an
1503 // appended signing key becomes usable, and a newly bound
1504 // channel name becomes enforced, with no restart and no wait
1505 // for some later event.
1506 self.refresh_allowed_signers();
1507 // Same reasoning, same cost: an appended grant takes effect
1508 // on this request rather than on a restart.
1509 self.refresh_acl();
1510 // Same reasoning, quieter failure: the writer thread can only
1511 // record that an op missed the latency gate, never decide what
1512 // to do about it. Draining here puts the breach in the
1513 // operator's lag log while the node keeps serving.
1514 if let Some(platform) = self.platform.as_ref() {
1515 platform.drain_lag_log();
1516 }
1517 let root = self.root.clone();
1518 let auth = self.auth.clone();
1519 let platform = self.platform.clone();
1520 let queue = self.queue.clone();
1521 let queue_in_flight = std::sync::Arc::clone(&self.queue_in_flight);
1522 let ui_cache = std::sync::Arc::clone(&self.ui_cache);
1523 let acl = self.acl_now();
1524 let internal_token = self.internal_token.clone();
1525 let request_log = self.request_log.clone();
1526 let rate = self.rate.clone();
1527 let accounts = self.accounts.clone();
1528 let passkeys = self.passkeys;
1529 let sessions = self.sessions.clone();
1530 let quotas = self.quotas;
1531 let api_body_limit = self.api_body_limit;
1532 let browser_writes = self.browser_writes;
1533 let site_repo = self.site_repo.clone();
1534 let downloads = self.downloads.clone();
1535 let ready_min_free_bytes = self.ready_min_free_bytes;
1536 let counters = std::sync::Arc::clone(&self.counters);
1537 let started_unix = self.started_unix;
1538 let authenticated = self.auth.is_some();
1539 let port = self.port;
1540 let scheme = self.scheme;
1541 let listener_scheme = self.listener_scheme;
1542 let public_rate = std::sync::Arc::clone(&self.public_rate);
1543 let ssh_enabled = self.ssh_enabled;
1544 std::thread::spawn(move || {
1545 // D33. Started before anything else the thread does, so
1546 // the recorded duration is the node's whole cost. The
1547 // query string is dropped here and never carried further.
1548 let access = limits::Access::start(&request, std::sync::Arc::clone(&counters));
1549 let log = request_log.as_deref();
1550 // D39's client half, ahead of authentication and
1551 // deliberately so.
1552 //
1553 // It is a compile-time constant with no node state in
1554 // it, identical on every choir node, and the 401 it
1555 // would otherwise get names the realm anyway — so
1556 // there is nothing here a challenge would protect. What
1557 // a challenge *would* cost is the feature: a subresource
1558 // 401 that a browser declines to retry leaves the
1559 // ceremony `hidden`, which is not a visible failure but
1560 // an absent control, with the `<noscript>` sentence
1561 // suppressed because scripting is in fact enabled. A
1562 // write path that silently disappears is worse than a
1563 // public constant.
1564 //
1565 // Served whatever `--read-only-browser` says (D73). Each
1566 // ceremony in the file asks whether its own element is on
1567 // the page, and the pages decide that: a read-only browse
1568 // surface renders no `#verdict` and no `#comment`, so the
1569 // file that would drive them runs nothing. Withholding the
1570 // file instead took sign-in and D72's ask down with them,
1571 // which is a page whose button does nothing rather than a
1572 // page that offers no button.
1573 // The palette (D81) is served from the same arm and on
1574 // the same terms: ahead of authentication, because it
1575 // ships on every page including the ones a stranger
1576 // reads, and a script withheld until sign-in is a
1577 // shortcut that works only for people who already know
1578 // the node.
1579 // The stylesheet, on the same terms and for the same
1580 // reason: every page carries it, including the ones a
1581 // stranger reads, and a sheet withheld until sign-in is
1582 // an unstyled page.
1583 if request.url().split('?').next() == Some("/static/ui.css") {
1584 let outcome = respond_stylesheet(request);
1585 access.finish(log, "anon", &outcome);
1586 return;
1587 }
1588 let script = match request.url().split('?').next() {
1589 Some(ui::WEBAUTHN_JS_PATH) => Some(ui::WEBAUTHN_JS),
1590 Some(ui::PALETTE_JS_PATH) => Some(ui::PALETTE_JS),
1591 _ => None,
1592 };
1593 if let Some(body) = script {
1594 let outcome = respond_static_script(request, body);
1595 // "anon" and not a name: no credential was
1596 // evaluated on this path, and the request log must
1597 // say what happened rather than what was sent.
1598 access.finish(log, "anon", &outcome);
1599 return;
1600 }
1601 // D57's front door, also ahead of authentication, and for
1602 // a plainer reason than D39's: the people these pages are
1603 // for do not have a credential yet. That is the whole
1604 // point of them.
1605 //
1606 // Everything the auth gate would have done downstream has
1607 // to be done here instead, because a request that returns
1608 // from this block never reaches it. In order: admission
1609 // control, which is *not* the authenticated limiter and
1610 // takes no argument describing the caller (see
1611 // `PublicLimiter`: a map keyed on something an anonymous
1612 // caller supplies is the attack, and the one key that is
1613 // not supplied by them is their address, which this
1614 // deployment does not handle at all); a bounded read of
1615 // any body; and `access.finish` on every exit, since each
1616 // branch logs itself.
1617 let public_path = request
1618 .url()
1619 .split(['?', '#'])
1620 .next()
1621 .unwrap_or("")
1622 .to_string();
1623 let method = request.method().as_str();
1624 // D71's ceremony, public for the same reason D57's front
1625 // door is: a person signing in has no credential yet, and
1626 // the whole point of the page is to give them one. Both
1627 // API halves are pre-auth too, which is what makes the
1628 // challenge the one allocation an unauthenticated caller
1629 // can repeat, and why they sit behind the same
1630 // `PublicLimiter` as everything else in this block.
1631 //
1632 // The page and its form are public whatever `--passkeys`
1633 // says (D74): the credential an operator issued is what
1634 // every person holds before they hold anything else, and
1635 // the page that takes it is the page that replaced the
1636 // browser's own dialog. Only the ceremony's two halves
1637 // are gated on the switch that offers it.
1638 let signin_route = matches!(
1639 (method, public_path.as_str()),
1640 ("GET" | "POST", "/signin") | ("POST", "/api/signout")
1641 ) || (passkeys
1642 && matches!(
1643 (method, public_path.as_str()),
1644 ("POST", "/api/signin") | ("POST", "/api/signin/challenge")
1645 ));
1646 // D72's queue. Public for the same reason the front
1647 // door is: the caller holds no credential and the whole
1648 // point is that they can ask for one. What keeps it from
1649 // being an open registration is that it writes a row
1650 // that authorizes nothing, behind a proof of work, into
1651 // a capped table -- and that only an operator can turn
1652 // one of those rows into an invite.
1653 let asking_route = accounts.is_some()
1654 && matches!(
1655 (method, public_path.as_str()),
1656 ("POST", "/api/access") | ("POST", "/api/access/challenge")
1657 );
1658 // A live browser session, checked here as well as at the
1659 // auth gate below. The front door is for people who hold
1660 // nothing, and a session cookie is something: without
1661 // this the branch fired for anybody signed in through
1662 // D74's form, because that credential is a cookie and
1663 // never an `Authorization` header — so the brand link,
1664 // the "node" pill and the `303` that ends signing in all
1665 // led back to a page telling the reader to sign in.
1666 //
1667 // A closure rather than a value, and last in the `&&`
1668 // chain that uses it: this is a lock and a lookup, and
1669 // every git request would otherwise pay for it to answer
1670 // a question only two paths ask.
1671 let in_session = || {
1672 session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE)
1673 .is_some_and(|token| sessions.user(&token).is_some())
1674 };
1675 // A crawler asks for this by name and gets it whatever it
1676 // holds, because a robots policy withheld behind a `401`
1677 // is a robots policy nothing reads.
1678 let robots_route = method == "GET" && public_path == ui::ROBOTS_PATH;
1679 // Beside the crawl policy and public for the same
1680 // reason: it lists only what the ACL has already
1681 // published, so there is nothing in it a `401` would
1682 // protect, and a sitemap behind a wall is a sitemap
1683 // nothing reads.
1684 let sitemap_route = method == "GET" && public_path == ui::SITEMAP_PATH;
1685 // A browser asks for these by itself, on every page that
1686 // declares no icon. Behind the wall the answer was a
1687 // `401` carrying `WWW-Authenticate`, and the browser's
1688 // own credential box opened over a page the reader was
1689 // allowed to read and had asked nothing else of. There
1690 // is no icon to serve, so the answer is the `404` a
1691 // signed-in reader already got; what changes is that
1692 // nobody is challenged for it.
1693 let icon_route = matches!(method, "GET" | "HEAD") && is_icon_probe(&public_path);
1694 // D79. The shelf answers whatever the reader holds, for
1695 // the same reason the robots policy does: an install
1696 // command behind a `401` installs nothing, and the
1697 // operator already consented by naming the directory.
1698 // Absent the flag there is no route here at all, so a
1699 // node that publishes no binaries is unchanged.
1700 let downloads_route = downloads.is_some()
1701 && method == "GET"
1702 && (public_path == downloads::PREFIX.trim_end_matches('/')
1703 || public_path.starts_with(downloads::PREFIX));
1704 // D78 made publishing the ACL's answer, and the front door
1705 // has to give the same answer. A node serving source a
1706 // stranger may read has something better to show them than
1707 // a page saying what choir is, and with `--site-repo` that
1708 // something is the repository itself: `browse::scope` turns
1709 // `/` into its tree. A node that publishes nothing is
1710 // unchanged, which is where the landing page still earns
1711 // its place -- it is what a node shows when it has nothing
1712 // to show.
1713 //
1714 // A closure, and last but one in the chain that uses it, so
1715 // that no request other than one for the front door pays a
1716 // table lookup to answer a question only the front door
1717 // asks. `in_session` stays behind it because that one is a
1718 // lock as well as a lookup.
1719 let front_door_published = || {
1720 acl.as_ref()
1721 .is_some_and(|table| match site_repo.as_deref() {
1722 // `--site-repo` narrows `/` to one repository, so
1723 // whether the front door is public is that
1724 // repository's grant and not the table's in
1725 // general: a node presenting one project while
1726 // publishing a different one has published nothing
1727 // that a stranger typing the bare host name reaches.
1728 Some(site) => table.allows_repo(acl::ANON, site, acl::Level::Read),
1729 // Otherwise `/` is the index, which lists exactly
1730 // what this reader may read, so one grant is enough
1731 // to make it a page rather than an empty list.
1732 None => table.holds_anything(acl::ANON),
1733 })
1734 };
1735 let public = signin_route
1736 || asking_route
1737 || robots_route
1738 || sitemap_route
1739 || icon_route
1740 || downloads_route
1741 || matches!((method, public_path.as_str()), ("GET" | "POST", "/join"))
1742 || (method == "GET" && public_path == ui::CARD_PATH)
1743 // A card per repository, drawn from the name in the
1744 // URL and nothing else -- so this checks no ACL and
1745 // touches no disk. An image that only repeats its own
1746 // address back discloses nothing, which is what lets
1747 // it answer before the wall and what stops a card
1748 // becoming a way to ask whether a repository exists.
1749 || (method == "GET" && public_path.starts_with(card::PREFIX))
1750 // The landing page replaces the challenge only for a
1751 // request that presented nothing. A credential that
1752 // was presented and is wrong still gets the `401`,
1753 // because a reader who mistyped their password needs
1754 // the browser to ask again rather than a page telling
1755 // them what choir is.
1756 || (method == "GET"
1757 && matches!(public_path.as_str(), "/" | "/index.html")
1758 && authenticated
1759 && header(&request, "authorization").is_none()
1760 && !front_door_published()
1761 && !in_session());
1762 if public {
1763 if let Some(retry) = public_rate.check() {
1764 let outcome = respond_public_busy(request, retry);
1765 access.finish(log, "anon", &outcome);
1766 return;
1767 }
1768 let outcome = if signin_route {
1769 respond_signin(
1770 request,
1771 &public_path,
1772 Credentials {
1773 auth: auth.as_ref().as_ref(),
1774 accounts: accounts.as_deref(),
1775 },
1776 &sessions,
1777 passkeys,
1778 scheme,
1779 api_body_limit,
1780 )
1781 } else if asking_route {
1782 respond_access(
1783 request,
1784 &public_path,
1785 accounts.as_deref(),
1786 &sessions,
1787 scheme,
1788 api_body_limit,
1789 )
1790 } else if public_path == ui::CARD_PATH {
1791 respond_card(request)
1792 } else if public_path.starts_with(card::PREFIX) {
1793 respond_repo_card(request, &public_path)
1794 } else if robots_route {
1795 respond_robots(request, acl.as_deref(), downloads.is_some(), scheme)
1796 } else if sitemap_route {
1797 respond_sitemap(request, acl.as_deref(), downloads.is_some(), scheme)
1798 } else if icon_route {
1799 respond_plain(request, 404, "no such icon\n", b"no-store")
1800 } else if downloads_route {
1801 let shelf = downloads.as_deref().expect("the route needs a shelf");
1802 respond_download(request, &public_path, shelf, scheme, site_repo.as_deref())
1803 } else {
1804 respond_join(
1805 request,
1806 accounts.as_deref(),
1807 &public_path,
1808 join_page::Offers {
1809 ssh: ssh_enabled,
1810 passkeys,
1811 // D78: whether this node has published
1812 // anything is the ACL's answer, so the
1813 // page cannot claim a posture the table
1814 // does not hold.
1815 publishes: acl
1816 .as_ref()
1817 .is_some_and(|t| t.holds_anything(acl::ANON)),
1818 downloads: downloads.is_some(),
1819 },
1820 &sessions,
1821 scheme,
1822 &root,
1823 )
1824 };
1825 // "anon", like the script constant above: no
1826 // credential was evaluated, and the record must say
1827 // what happened rather than what was presented. The
1828 // invite id is deliberately not logged either — it is
1829 // half of a live credential.
1830 access.finish(log, "anon", &outcome);
1831 return;
1832 }
1833 // Hook callbacks authenticate with the loopback secret
1834 // instead of user credentials.
1835 let internal_ok = (request.url().starts_with("/api/git-update")
1836 || request.url().starts_with("/api/git-abort"))
1837 && header(&request, "X-Choir-Internal").as_deref() == Some(&internal_token);
1838 let mut user = "anon".to_string();
1839 // D36. Set when the presented credential is an unredeemed
1840 // invite rather than an account, which may reach exactly
1841 // one route.
1842 let mut invite: Option<String> = None;
1843 // A signed-in browser presents no credential at all: the
1844 // cookie is the whole claim, and it is checked before the
1845 // Authorization header so a stale header cannot shadow a
1846 // live session.
1847 let session_token =
1848 session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE);
1849 let session_user = session_token
1850 .as_deref()
1851 .and_then(|token| sessions.user(token));
1852 let session_user_present = session_user.is_some();
1853 if let Some(table) = auth.as_ref() {
1854 match session_user
1855 .map(accounts::Principal::Account)
1856 .or_else(|| authenticate(table, accounts.as_deref(), &request))
1857 {
1858 Some(accounts::Principal::Account(u)) => user = u,
1859 Some(accounts::Principal::Invite(id)) => {
1860 user.clone_from(&id);
1861 invite = Some(id);
1862 }
1863 None if internal_ok => {}
1864 // No credential, but this node has published
1865 // something and this request is asking for it.
1866 // The caller becomes `@anon` and every check
1867 // downstream runs unchanged: the browse handler
1868 // asks the same `readable()` it asks for a
1869 // signed-in reader, and the git route asks the
1870 // same `git_requirement`. There is no second
1871 // authorization rule here, which is the point --
1872 // a public repository is one the ACL says
1873 // `@anon` may read, and nothing else about the
1874 // node changes.
1875 None if anon_may_try(acl.as_deref(), &request) => {
1876 user = acl::ANON.to_string();
1877 }
1878 None => {
1879 // A browser that can run the ceremony is shown
1880 // the page instead of the challenge, because
1881 // `WWW-Authenticate` is answered by chrome no
1882 // page can style, explain, or offer a passkey
1883 // through. Everything else keeps the header:
1884 // git speaks it, every API client speaks it,
1885 // and a node with passkeys off has no other
1886 // way in.
1887 //
1888 // The git routes are excluded by name rather
1889 // than trusted to not send `text/html`, since
1890 // what a client sends is not a promise about
1891 // what it can do with the answer.
1892 // Not gated on `--passkeys` any more (D74).
1893 // The page's other half is a username and
1894 // password form, which is the way in on every
1895 // node whatever it offers, so withholding the
1896 // page from a node without passkeys withheld
1897 // the form too and left the grey box as the
1898 // whole answer.
1899 // A `fetch` gets the refusal and no
1900 // challenge. `WWW-Authenticate: Basic` is
1901 // answered by the browser's own credential
1902 // box -- a native dialog naming a host and a
1903 // port, over a page that cannot explain it,
1904 // dismiss it, or say what it is for. That is
1905 // bad enough on a navigation, which is why
1906 // `wants_page` exists; on a background
1907 // request it is worse, because nothing the
1908 // reader did asked for a sign-in at all.
1909 // They pressed a key and a password prompt
1910 // appeared.
1911 //
1912 // `X-Requested-With` is the client saying it
1913 // is script and will read the status itself.
1914 // It cannot be set cross-origin without a
1915 // preflight this node never answers, so it
1916 // is not a way for another site to turn the
1917 // challenge off for somebody.
1918 let from_script = header(&request, "x-requested-with").is_some();
1919 let wants_page = !request.url().contains(".git")
1920 && !from_script
1921 && header(&request, "accept")
1922 .is_some_and(|a| a.contains("text/html"));
1923 let outcome = if wants_page {
1924 let next = request.url().split('?').next().unwrap_or("/");
1925 let next = if next.starts_with('/') { next } else { "/" };
1926 let page = signin_page::render(
1927 passkeys,
1928 next,
1929 signin_page::Said::Nothing,
1930 reader_chrome(&request),
1931 );
1932 respond_scripted_page(request, page.status, page.html)
1933 } else {
1934 let body = "unauthorized\n";
1935 let mut response =
1936 tiny_http::Response::from_string(body).with_status_code(401);
1937 if !from_script {
1938 response.add_header(
1939 tiny_http::Header::from_bytes(
1940 &b"WWW-Authenticate"[..],
1941 &b"Basic realm=\"choir\""[..],
1942 )
1943 .expect("static header"),
1944 );
1945 }
1946 served(request, response, 401, body.len() as u64)
1947 };
1948 access.finish(log, &user, &outcome);
1949 return;
1950 }
1951 }
1952 }
1953 // The hook callbacks are privileged: they submit a ref op
1954 // under any user's name, spending authorization that the
1955 // git route which triggered them already checked.
1956 // Requiring the loopback secret keeps a user credential
1957 // from reaching them directly — which would otherwise
1958 // forge a ref update on any repository and walk straight
1959 // around the git-route check below.
1960 if (request.url().starts_with("/api/git-update")
1961 || request.url().starts_with("/api/git-abort"))
1962 && !internal_ok
1963 {
1964 let body = "{\"error\":\"internal endpoint\"}\n";
1965 let response = tiny_http::Response::from_string(body)
1966 .with_status_code(403)
1967 .with_header(
1968 tiny_http::Header::from_bytes(
1969 &b"Content-Type"[..],
1970 &b"application/json"[..],
1971 )
1972 .expect("static header"),
1973 );
1974 let outcome = served(request, response, 403, body.len() as u64);
1975 access.finish(log, &user, &outcome);
1976 return;
1977 }
1978 // D33's three exemptions, hoisted because D37's quotas
1979 // take exactly the same three: the callback that a push
1980 // fans out into, the actor who can repair the node, and a
1981 // node with no identity to meter. A metering rule that
1982 // exempted one of them and not the other would be two
1983 // rules for one question.
1984 //
1985 // Short-circuited on "is anything metered at all" so a
1986 // node running none of this pays no ACL lookup per
1987 // request for it.
1988 let metered = (rate.is_some() || quotas.is_active()) && {
1989 let node_wide = acl.as_deref().is_some_and(|table| {
1990 table.allows(&user, &acl::Scope::Node, acl::Level::Read)
1991 });
1992 !(internal_ok || !authenticated || node_wide)
1993 };
1994 // Operational endpoints are authenticated by reaching this
1995 // point. Readiness performs independent checks rather than
1996 // echoing liveness: verified log structure, a live durable
1997 // sequencer, writable storage, free space, and ref agreement.
1998 if request.method().as_str() == "GET"
1999 && matches!(request.url(), "/healthz" | "/readyz" | "/metrics")
2000 {
2001 let outcome = handle_observability(
2002 &root,
2003 platform.as_deref(),
2004 ready_min_free_bytes,
2005 &counters,
2006 started_unix,
2007 request,
2008 );
2009 access.finish(log, &user, &outcome);
2010 return;
2011 }
2012 // D33. After authentication, because the bucket is per
2013 // user; before any work, because a refused request should
2014 // cost the node as little as possible. The exemptions are
2015 // documented on `serve_forever`.
2016 if let Some(rate) = rate.as_deref() {
2017 let refusal = if metered {
2018 rate.check(&user, limits::class_of(access.path()))
2019 } else {
2020 None
2021 };
2022 if let Some(retry_after) = refusal {
2023 // A reader who refreshed too fast gets a page; an
2024 // agent gets the JSON it parses. Same refusal,
2025 // same `Retry-After`, told in the surface the
2026 // caller is already in.
2027 let outcome = if is_browser_route(access.path()) {
2028 let seconds = format!("{retry_after} seconds");
2029 let html = ui::refusal(
2030 "Too many requests, briefly",
2031 429,
2032 &ui::Refusal {
2033 code: "rate_limited",
2034 error: "This credential has spent its request allowance \
2035 for the moment. Nothing is wrong with the node \
2036 or with what you asked for.",
2037 expected: Some("requests within this node's per-user rate"),
2038 actual: Some(&seconds),
2039 next: "Wait, then reload. If this keeps happening while \
2040 you are reading rather than scripting, the \
2041 operator set the limit low enough to catch a \
2042 person and would want to know.",
2043 },
2044 &[],
2045 reader_chrome(&request),
2046 );
2047 respond_page(request, 429, html, Some(retry_after))
2048 } else {
2049 respond_rate_limited(request, access.path(), retry_after)
2050 };
2051 access.finish(log, &user, &outcome);
2052 return;
2053 }
2054 }
2055 // D36. An unredeemed invite is a credential for exactly
2056 // one thing. Refused here, ahead of every route, rather
2057 // than by each route remembering to ask: the ACL grades
2058 // accounts, and an invite is not one yet — it holds no
2059 // grant, so several endpoints that require none would
2060 // otherwise let it through.
2061 if invite.is_some()
2062 && !(request.method().as_str() == "POST"
2063 && request.url() == "/api/accounts/redeem")
2064 {
2065 // A person who was handed an invite and pasted the
2066 // node's URL into a browser lands here, and it is
2067 // very likely their first minute on this node. The
2068 // JSON below says the true thing and tells them
2069 // nothing they can act on without reading the source.
2070 let outcome = if is_browser_route(request.url()) {
2071 let html = ui::refusal(
2072 "That invite is not an account yet",
2073 403,
2074 &ui::Refusal {
2075 code: "invite_only",
2076 error: "The credential you signed in with is an unredeemed \
2077 invite. An invite may do exactly one thing — become \
2078 an account — and it holds no grants until it does.",
2079 expected: Some("an account token"),
2080 actual: Some("an unredeemed invite"),
2081 next: "Redeem it once, with the invite as the credential: \
2082 POST /api/accounts/redeem. It answers with a token; \
2083 sign in with that and this page will open. Invites \
2084 expire and are single-use, so do it now rather than \
2085 later.",
2086 },
2087 &[],
2088 reader_chrome(&request),
2089 );
2090 respond_page(request, 403, html, None)
2091 } else {
2092 let body = "{\"error\":\"an invite may only be redeemed\"}\n";
2093 let response = tiny_http::Response::from_string(body)
2094 .with_status_code(403)
2095 .with_header(
2096 tiny_http::Header::from_bytes(
2097 &b"Content-Type"[..],
2098 &b"application/json"[..],
2099 )
2100 .expect("static header"),
2101 );
2102 served(request, response, 403, body.len() as u64)
2103 };
2104 access.finish(log, &user, &outcome);
2105 return;
2106 }
2107 // D80. A seed writes nothing: every write, whichever
2108 // route it came by, is answered with the home it belongs
2109 // at. Here, after authentication and before any route
2110 // that could act on it, and for a push before `git
2111 // http-backend` is ever spawned. The body is text on the
2112 // git surface, where git prints it to the pusher, and the
2113 // rejection JSON everywhere else.
2114 if let Some(home) = platform.as_deref().and_then(Platform::seed_home) {
2115 if acl::is_write(request.method().as_str(), request.url()) {
2116 let outcome = respond_not_home(request, &home.url);
2117 access.finish(log, &user, &outcome);
2118 return;
2119 }
2120 }
2121 // Build one op's bytes for a browser to sign (D39).
2122 // Ahead of the platform API for the same reason the
2123 // account routes are: it needs no sequencer.
2124 if request.url().split('?').next().unwrap_or("") == "/api/prepare" {
2125 if !browser_writes {
2126 let body = r#"{"error":"browser writes are disabled; use the signed CLI"}"#;
2127 let response = tiny_http::Response::from_string(body)
2128 .with_status_code(403)
2129 .with_header(
2130 tiny_http::Header::from_bytes(
2131 &b"Content-Type"[..],
2132 &b"application/json"[..],
2133 )
2134 .expect("static header"),
2135 );
2136 let outcome = served(request, response, 403, body.len() as u64);
2137 access.finish(log, &user, &outcome);
2138 return;
2139 }
2140 let outcome = handle_prepare(&user, acl.as_deref(), api_body_limit, request);
2141 access.finish(log, &user, &outcome);
2142 return;
2143 }
2144 // The page a person enrols a passkey on (D39). Ahead
2145 // of the API block because it is a page, not an endpoint,
2146 // and it is gated by nothing but being authenticated: the
2147 // only account it can ever show is the caller's own.
2148 // D75. A passwordless account's one need for a
2149 // secret: git and the CLI speak basic auth and cannot
2150 // present a passkey. Minted here rather than at
2151 // redemption, so the caller is somebody this node has
2152 // already authenticated and is asking because something
2153 // wanted one.
2154 if request.url().split('?').next() == Some("/account/token") {
2155 let outcome = respond_account_token(
2156 request,
2157 accounts.as_deref(),
2158 &user,
2159 acl.as_deref(),
2160 &root,
2161 scheme,
2162 );
2163 access.finish(log, &user, &outcome);
2164 return;
2165 }
2166 if request.url().split('?').next().unwrap_or("") == "/account" {
2167 // No `--read-only-browser` refusal here (D73).
2168 // Enrolling a passkey writes to the accounts store and
2169 // never to the op log, and it is the prerequisite for
2170 // signing in -- so refusing it under a posture that
2171 // also advertises `passkeys=enabled` left the one
2172 // credential a browser can hold unobtainable from a
2173 // browser.
2174 if !passkeys {
2175 let html = ui::refusal(
2176 "Passkeys are not enabled",
2177 503,
2178 &ui::Refusal {
2179 code: "passkeys_disabled",
2180 error: "This node does not offer passkeys.",
2181 expected: Some("a node with passkeys enabled"),
2182 actual: Some("a passkey enrollment page"),
2183 next: "Ask the operator to start the node with --passkeys; \
2184 until then, sign in with the credential you were given.",
2185 },
2186 &[],
2187 reader_chrome(&request),
2188 );
2189 let outcome = respond_page(request, 503, html, None);
2190 access.finish(log, &user, &outcome);
2191 return;
2192 }
2193 let console = acl.as_deref().is_some_and(|table| {
2194 table
2195 .check(&user, &acl::Scope::Node, acl::Level::Write)
2196 .is_none()
2197 });
2198 let docs = docs_url(&root);
2199 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
2200 let page = account_page::render(
2201 accounts.as_deref(),
2202 &user,
2203 session_user_present,
2204 console,
2205 origin.as_deref(),
2206 browse::Chrome {
2207 site: None,
2208 theme: chosen_theme(&request),
2209 here: "/account",
2210 origin: origin.as_deref(),
2211 account: true,
2212 console,
2213 docs: docs.as_deref(),
2214 // Reaching this page at all means holding a
2215 // credential, so the bar takes the
2216 // signed-in shape without asking again.
2217 signed_in: Some(true),
2218 },
2219 );
2220 let bytes = page.html.len() as u64;
2221 // The same headers every other browser surface
2222 // carries, which this page was missing entirely: it
2223 // was the one page in the node running script with
2224 // nothing constraining it.
2225 let response = tiny_http::Response::from_string(page.html)
2226 .with_status_code(page.status)
2227 .with_header(
2228 tiny_http::Header::from_bytes(
2229 &b"Content-Type"[..],
2230 &b"text/html; charset=utf-8"[..],
2231 )
2232 .expect("static header"),
2233 )
2234 .with_header(
2235 tiny_http::Header::from_bytes(
2236 &b"Cache-Control"[..],
2237 &b"private, no-cache"[..],
2238 )
2239 .expect("static header"),
2240 )
2241 .with_header(
2242 tiny_http::Header::from_bytes(
2243 &b"Content-Security-Policy"[..],
2244 BROWSER_CSP,
2245 )
2246 .expect("static header"),
2247 );
2248 let outcome = served(request, response, page.status, bytes);
2249 access.finish(log, &user, &outcome);
2250 return;
2251 }
2252 // The operator's console (D72). A page, like `/account`
2253 // above, and ahead of the API block for the same reason.
2254 if request.url().split('?').next().unwrap_or("") == "/people" {
2255 let outcome = respond_people(
2256 request,
2257 accounts.as_deref(),
2258 acl.as_deref(),
2259 &user,
2260 &root,
2261 scheme,
2262 api_body_limit,
2263 );
2264 access.finish(log, &user, &outcome);
2265 return;
2266 }
2267 // Credential self-service (D36). Ahead of the platform
2268 // API because it needs no sequencer: a node that serves
2269 // git and nothing else still issues credentials.
2270 if request.url().split('?').next().unwrap_or("") == "/api/accounts"
2271 || request.url().starts_with("/api/accounts/")
2272 {
2273 let outcome = handle_accounts(
2274 SelfService {
2275 store: accounts.as_deref(),
2276 passkeys,
2277 },
2278 &user,
2279 invite.as_deref(),
2280 acl.as_deref(),
2281 api_body_limit,
2282 scheme,
2283 request,
2284 );
2285 access.finish(log, &user, &outcome);
2286 return;
2287 }
2288 // The surface as plain text, for an agent that has never
2289 // seen choir, and the sync contract it points at. Behind
2290 // auth like everything else; it describes the node
2291 // rather than exposing its contents. `llms.txt` naming a
2292 // file only a cloner can read would be worse than not
2293 // naming it, so the document a remote agent is told to
2294 // follow is served from the same place it is told about.
2295 // The same surface, machine-readable, with what *this*
2296 // node will actually accept merged in (D17). The static
2297 // half is generated and committed; the capabilities are
2298 // read off the live node, because a deployment's gates
2299 // are not a fact a committed file can carry honestly.
2300 // Search, for a caller that is not a browser (D62). Ahead
2301 // of the platform API for the same reason credentials
2302 // are: it reads git and needs no sequencer, so a node
2303 // serving nothing but repositories can still answer
2304 // "where is this". The grant closure is the browser's,
2305 // built here rather than passed down, because the two
2306 // surfaces answering differently about what a reader may
2307 // see is the one bug this endpoint could introduce.
2308 if request.url().split('?').next().unwrap_or("") == "/api/search" {
2309 let url = request.url().to_string();
2310 let readable = |repo: &str| match acl.as_deref() {
2311 Some(table) => table.allows_repo(&user, repo, acl::Level::Read),
2312 None => true,
2313 };
2314 let (status, body) = browse::api_search(
2315 &root,
2316 &readable,
2317 browse::raw_param(&url, "repo"),
2318 browse::param(&url, "rev").as_deref(),
2319 browse::param(&url, "q").unwrap_or_default().as_str(),
2320 browse::param(&url, "in").as_deref(),
2321 browse::param(&url, "limit").as_deref(),
2322 );
2323 let bytes = body.len() as u64;
2324 let response = tiny_http::Response::from_string(body)
2325 .with_status_code(status)
2326 .with_header(
2327 tiny_http::Header::from_bytes(
2328 &b"Content-Type"[..],
2329 &b"application/json"[..],
2330 )
2331 .expect("static header"),
2332 );
2333 let outcome = served(request, response, status, bytes);
2334 access.finish(log, &user, &outcome);
2335 return;
2336 }
2337 // One actor's standing (D63). It reads the view rather
2338 // than git, so unlike search it needs the sequencer --
2339 // but it takes the *filtered* view, the same body this
2340 // caller would get from `/api/view`, which is what keeps
2341 // it from disclosing a change or review the ACL withheld.
2342 if request.url().split('?').next().unwrap_or("") == "/api/profile" {
2343 let url = request.url().to_string();
2344 // `raw_param` and not `param`: a channel legitimately
2345 // carries a `/`, and `param`'s decoder refuses one
2346 // because a decoded slash invents a path segment --
2347 // the same split `raw_param` exists for on a
2348 // repository name. Decoded by the function the `/p/`
2349 // page uses, so the two surfaces cannot come to
2350 // disagree about what a channel name is. Until this,
2351 // every agent channel -- `operator/agent`, which is
2352 // the ordinary shape here -- answered 400 on this
2353 // endpoint while its page rendered.
2354 let channel = browse::raw_param(&url, "channel")
2355 .and_then(browse::decode_channel)
2356 .unwrap_or_default();
2357 let (status, body) = match (platform.as_deref(), channel.is_empty()) {
2358 (_, true) => (
2359 400,
2360 serde_json::json!({ "error": "channel is required" }).to_string()
2361 + "\n",
2362 ),
2363 (None, _) => (
2364 503,
2365 serde_json::json!({
2366 "error": "this node runs no sequencer, so it holds no view to \
2367 derive a profile from"
2368 })
2369 .to_string()
2370 + "\n",
2371 ),
2372 (Some(platform), false) => {
2373 let seen = visible_view(platform, acl.as_deref(), &user);
2374 match serde_json::from_str::<serde_json::Value>(&seen) {
2375 Ok(view) => (200, profile::of(&view, &channel).to_string() + "\n"),
2376 Err(error) => (
2377 500,
2378 serde_json::json!({
2379 "error": format!("the view did not parse: {error}")
2380 })
2381 .to_string()
2382 + "\n",
2383 ),
2384 }
2385 }
2386 };
2387 let bytes = body.len() as u64;
2388 let response = tiny_http::Response::from_string(body)
2389 .with_status_code(status)
2390 .with_header(
2391 tiny_http::Header::from_bytes(
2392 &b"Content-Type"[..],
2393 &b"application/json"[..],
2394 )
2395 .expect("static header"),
2396 );
2397 let outcome = served(request, response, status, bytes);
2398 access.finish(log, &user, &outcome);
2399 return;
2400 }
2401 if request.url().split('?').next().unwrap_or("") == "/api/schema" {
2402 let body = schema_with_capabilities(
2403 accounts.is_some(),
2404 acl.is_some(),
2405 platform.is_some(),
2406 );
2407 let bytes = body.len() as u64;
2408 let response = tiny_http::Response::from_string(body).with_header(
2409 tiny_http::Header::from_bytes(
2410 &b"Content-Type"[..],
2411 &b"application/json"[..],
2412 )
2413 .expect("static header"),
2414 );
2415 let outcome = served(request, response, 200, bytes);
2416 access.finish(log, &user, &outcome);
2417 return;
2418 }
2419 if let Some(text) = match request.url() {
2420 "/llms.txt" => Some(LLMS_TXT),
2421 "/sync.md" => Some(SYNC_MD),
2422 _ => None,
2423 } {
2424 let response = tiny_http::Response::from_string(text).with_header(
2425 tiny_http::Header::from_bytes(
2426 &b"Content-Type"[..],
2427 &b"text/plain; charset=utf-8"[..],
2428 )
2429 .expect("static header"),
2430 );
2431 let outcome = served(request, response, 200, text.len() as u64);
2432 access.finish(log, &user, &outcome);
2433 return;
2434 }
2435 // The node's own telemetry. Matched exactly so it can
2436 // never shadow a repository path: git routes are
2437 // `/owner/repo.git/...`, and a single bare segment
2438 // cannot name a repository, which always carries an
2439 // owner. It used to be the front door; `/` now belongs
2440 // to the repository index, because a reader arriving at
2441 // a code host is looking for code.
2442 //
2443 // The *path* is what is matched exactly, not the URL.
2444 // Comparing the whole URL made this the one page on the
2445 // node that a query string turned into a 404, so a
2446 // shared link carrying any `?…` — a cache-buster, a
2447 // tracking parameter a mail client appended — answered
2448 // "nothing is served at that address" about an address
2449 // that is served.
2450 // Setting the palette. A `GET` that mutates nothing but
2451 // one display cookie, so it is a link rather than a
2452 // form: the read surface must work with no script,
2453 // and a form would put a `POST` and a button in a bar
2454 // that is otherwise navigation.
2455 if request.url().split(['?', '#']).next().unwrap_or("") == "/theme" {
2456 let url = request.url().to_string();
2457 let set = browse::param(&url, "set").unwrap_or_default();
2458 let outcome = respond_theme(request, &set, &return_to(&url), scheme);
2459 access.finish(log, &user, &outcome);
2460 return;
2461 }
2462 if request.url().split(['?', '#']).next().unwrap_or("") == "/status" {
2463 let outcome = handle_ui(
2464 platform.as_deref(),
2465 &ui_cache,
2466 &user,
2467 acl.as_deref(),
2468 &root,
2469 passkeys,
2470 request,
2471 );
2472 access.finish(log, &user, &outcome);
2473 return;
2474 }
2475 // One actor's standing, as a page (D63). The reader who
2476 // wants this is looking at a verdict and asking who gave
2477 // it, so it is a link from a review rather than a
2478 // document they were going to fetch as JSON.
2479 if let Some(rest) = request
2480 .url()
2481 .split(['?', '#'])
2482 .next()
2483 .unwrap_or("")
2484 .strip_prefix("/p/")
2485 {
2486 let channel = browse::decode_channel(rest);
2487 let chrome = reader_chrome(&request);
2488 let rendered = match (platform.as_deref(), channel) {
2489 (Some(platform), Some(channel)) => {
2490 let seen = visible_view(platform, acl.as_deref(), &user);
2491 match serde_json::from_str::<serde_json::Value>(&seen) {
2492 Ok(view) => browse::profile_page(
2493 &channel,
2494 &profile::of(&view, &channel),
2495 chrome,
2496 ),
2497 Err(_) => browse::no_such_actor(chrome),
2498 }
2499 }
2500 // A name that is not a channel and a node with no
2501 // view answer the same way, for the same reason
2502 // an unreadable repository does: a reader is
2503 // never told which of the two it was.
2504 _ => browse::no_such_actor(chrome),
2505 };
2506 let outcome = respond_page(request, rendered.status, rendered.html, None);
2507 access.finish(log, &user, &outcome);
2508 return;
2509 }
2510 // Repository browsing (D30). Ahead of the git branch
2511 // below, but `browse::route` refuses any path carrying a
2512 // `.git` segment, so an owner named `r` keeps their
2513 // clone URL: this cannot shadow a repository, and the
2514 // check is theirs rather than this router's ordering.
2515 if let Some(page) = browse::route(request.url()) {
2516 // A single-repository node narrows the page here,
2517 // before anything reads the disk: `/` becomes that
2518 // repository, and a page about another one is the
2519 // same refusal a reader without a grant receives.
2520 let page = match site_repo.as_deref() {
2521 Some(site) => browse::scope(page, site),
2522 None => Some(page),
2523 };
2524 let outcome = match page {
2525 Some(page) => handle_browse(
2526 &BrowseContext {
2527 root: &root,
2528 user: &user,
2529 acl: acl.as_deref(),
2530 platform: platform.as_deref(),
2531 browser_writes,
2532 site: site_repo.as_deref(),
2533 scheme,
2534 self_service: accounts.is_some(),
2535 passkeys,
2536 },
2537 &page,
2538 request,
2539 ),
2540 None => {
2541 let denied = browse::no_such_repository(reader_chrome(&request));
2542 respond_page(request, denied.status, denied.html, None)
2543 }
2544 };
2545 access.finish(log, &user, &outcome);
2546 return;
2547 }
2548 if request.url().starts_with("/api/") {
2549 let base_url = format!("{listener_scheme}://127.0.0.1:{port}");
2550 // A hook callback carries the loopback secret rather
2551 // than a user's grants, so it is not an ACL subject.
2552 let acl_for_api = if internal_ok { None } else { acl.as_deref() };
2553 let outcome = handle_api(
2554 ApiRequestContext {
2555 platform: platform.as_deref(),
2556 root: &root,
2557 base_url: &base_url,
2558 user: &user,
2559 acl: acl_for_api,
2560 push_acl: acl.as_deref(),
2561 workspaces: metered.then_some(quotas.workspaces).flatten(),
2562 body_limit: api_body_limit,
2563 queue: queue.as_ref(),
2564 queue_in_flight: &queue_in_flight,
2565 },
2566 request,
2567 );
2568 access.finish(log, &user, &outcome);
2569 return;
2570 }
2571 // A mistyped address. Everything below is git
2572 // smart-HTTP, and every smart-HTTP path carries a `.git`
2573 // segment — the claim `browse.rs` makes and a test pins —
2574 // so a `GET` without one matched no route above and can
2575 // never be a git client. It is a person who typed
2576 // something slightly wrong, and `git http-backend`'s CGI
2577 // 404 tells them nothing about where they are. Answering
2578 // here also means the two named destinations are the
2579 // same whether or not an ACL is configured; before this,
2580 // an ACL node said "no such repository" and a node
2581 // without one said whatever git said.
2582 if repo_from_path(request.url()).is_none()
2583 && matches!(request.method().as_str(), "GET" | "HEAD")
2584 {
2585 let html = ui::refusal(
2586 "Nothing is served at that address",
2587 404,
2588 &ui::Refusal {
2589 code: "no_such_page",
2590 error: "This node answers on a small, fixed set of addresses, and \
2591 that is not one of them.",
2592 expected: Some(
2593 "/ for node state, /r/ for repositories, /api/… \
2594 for the JSON surface",
2595 ),
2596 actual: Some(access.path()),
2597 next: "Start from the node page and follow links; every address \
2598 this surface has is reachable from one of the two below. \
2599 A clone URL is different — it ends in .git.",
2600 },
2601 &[("/", "node state"), ("/r/", "repositories")],
2602 reader_chrome(&request),
2603 );
2604 let outcome = respond_page(request, 404, html, None);
2605 access.finish(log, &user, &outcome);
2606 return;
2607 }
2608 // Git smart-HTTP. The repository is in the URL, so this
2609 // decision needs nothing but the path — which is why it
2610 // sits here, once, rather than inside the CGI bridge. A
2611 // path naming no repository, or an operation outside the
2612 // smart-HTTP surface, is refused rather than handed to
2613 // `git http-backend`.
2614 if let Some(table) = acl.as_ref() {
2615 let method = request.method().as_str().to_string();
2616 let denial = match acl::git_requirement(&method, request.url()) {
2617 Some((repo, level)) => table.check(&user, &acl::Scope::Repo(repo), level),
2618 None => Some(acl::Denial {
2619 status: 404,
2620 reason: "no such repository".to_string(),
2621 }),
2622 };
2623 if let Some(denial) = denial {
2624 let outcome = respond_git_denial(request, &denial);
2625 access.finish(log, &user, &outcome);
2626 return;
2627 }
2628 }
2629 // The ref advertisement is the one request where an
2630 // unsettled `HEAD` is observable, so the repair happens
2631 // here rather than on every git request. Repositories
2632 // created before `create_repo_in` pinned the branch heal
2633 // on the next fetch anybody makes, which is what stops
2634 // this needing a migration.
2635 if request.method().as_str() == "GET" && request.url().contains("/info/refs") {
2636 if let Some(name) = repo_from_path(request.url()) {
2637 if let Ok(bare) = repo_path_in(&root, &name) {
2638 settle_head(&bare);
2639 }
2640 }
2641 }
2642 // Platform-enabled daemons pass the sequencer callback
2643 // into git's hook environment.
2644 let mut extra_env = Vec::new();
2645 if platform.is_some() {
2646 if let Some(repo) = repo_from_path(request.url()) {
2647 extra_env.push((
2648 "CHOIR_API".to_string(),
2649 format!("{listener_scheme}://127.0.0.1:{port}/api/git-update"),
2650 ));
2651 extra_env.push((
2652 "CHOIR_ABORT".to_string(),
2653 format!("{listener_scheme}://127.0.0.1:{port}/api/git-abort"),
2654 ));
2655 extra_env.push(("CHOIR_REPO".to_string(), repo));
2656 extra_env.push(("CHOIR_USER".to_string(), user.clone()));
2657 extra_env.push(("CHOIR_INTERNAL".to_string(), internal_token));
2658 }
2659 }
2660 let outcome = handle(
2661 root,
2662 request,
2663 &extra_env,
2664 metered.then_some(quotas.push_bytes).flatten(),
2665 );
2666 access.finish(log, &user, &outcome);
2667 });
2668 }
2669 }
2670
2671 /// Handle for stopping the accept loop (used by tests).
2672 pub fn unblock(&self) {
2673 self.shutdown
2674 .store(true, std::sync::atomic::Ordering::Release);
2675 self.server.unblock();
2676 }
2677}
2678
2679/// Value of the first header named `name`, if present.
2680fn header(request: &tiny_http::Request, name: &str) -> Option<String> {
2681 request
2682 .headers()
2683 .iter()
2684 .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(name))
2685 .map(|h| h.value.as_str().to_string())
2686}
2687
2688/// Writes `response` and reports what was served, so the caller can hand
2689/// the outcome to [`limits::Access::finish`] (D33). The byte count is the
2690/// body size the caller already knows; tiny_http does not expose it back.
2691///
2692/// **`X-Content-Type-Options: nosniff` is added here, to everything.**
2693/// It used to sit on the four responders somebody remembered to put it
2694/// on, which left git's own CGI output — a pusher's bytes, typed by git —
2695/// without it. That is the response `script-src 'self'` most needs it on:
2696/// the review page licenses any same-origin URL as a script source, and
2697/// a browser will happily execute a `text/plain` body as JavaScript
2698/// unless this header says not to. One funnel, so the claim in
2699/// [`BROWSER_CSP`] is true by construction rather than by
2700/// inspection.
2701fn served<R: std::io::Read>(
2702 request: tiny_http::Request,
2703 mut response: tiny_http::Response<R>,
2704 status: u16,
2705 bytes: u64,
2706) -> std::io::Result<(u16, u64)> {
2707 response.add_header(
2708 tiny_http::Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..])
2709 .expect("static header"),
2710 );
2711 request.respond(response).map(|()| (status, bytes))
2712}
2713
2714/// Answers a write on a seed: `421 Misdirected Request`, naming the home
2715/// the write belongs at (D80).
2716///
2717/// Text on the git surface, because git relays a `text/plain` refusal to
2718/// the pusher line by line and shows nothing of any other type; the
2719/// rejection JSON on the API, where `home` is a field a client follows.
2720fn respond_not_home(request: tiny_http::Request, home: &str) -> std::io::Result<(u16, u64)> {
2721 let (body, content_type) = if request.url().starts_with("/api/") {
2722 (reject::not_home(home).to_string(), &b"application/json"[..])
2723 } else {
2724 let repo = repo_from_path(request.url()).unwrap_or_default();
2725 (
2726 format!(
2727 "not_home: this node is a seed of {home} and writes nothing of its own.\n\
2728 Push to the home instead: {home}/{repo}\n"
2729 ),
2730 &b"text/plain; charset=utf-8"[..],
2731 )
2732 };
2733 let bytes = body.len() as u64;
2734 let response = tiny_http::Response::from_string(body)
2735 .with_status_code(421)
2736 .with_header(
2737 tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type)
2738 .expect("static header"),
2739 );
2740 served(request, response, 421, bytes)
2741}
2742
2743/// Answers a request that exhausted its per-user allowance (D33).
2744///
2745/// `Retry-After` in whole seconds is the machine-readable half; the body
2746/// repeats it because a git client shows the operator the body and
2747/// nothing else. Plain text on a git path for that reason, JSON on an API
2748/// path so a client that parses every response still can.
2749fn respond_rate_limited(
2750 request: tiny_http::Request,
2751 path: &str,
2752 retry_after: u64,
2753) -> std::io::Result<(u16, u64)> {
2754 let (body, content_type) = match limits::class_of(path) {
2755 limits::Class::Git => (
2756 format!("rate limit exceeded; retry in {retry_after}s\n"),
2757 &b"text/plain; charset=utf-8"[..],
2758 ),
2759 limits::Class::Api => (
2760 serde_json::json!({
2761 "error": format!("rate limit exceeded; retry in {retry_after}s"),
2762 "retry_after_secs": retry_after,
2763 })
2764 .to_string(),
2765 &b"application/json"[..],
2766 ),
2767 };
2768 let bytes = body.len() as u64;
2769 let response = tiny_http::Response::from_string(body)
2770 .with_status_code(429)
2771 .with_header(
2772 tiny_http::Header::from_bytes(&b"Content-Type"[..], content_type)
2773 .expect("static header"),
2774 )
2775 .with_header(
2776 tiny_http::Header::from_bytes(&b"Retry-After"[..], retry_after.to_string().as_bytes())
2777 .expect("retry-after header"),
2778 );
2779 served(request, response, 429, bytes)
2780}
2781
2782/// Answers a git request whose body was over the per-user push ceiling
2783/// (D37).
2784///
2785/// `413` is the exact HTTP meaning, and the body is plain text because a
2786/// git client shows the operator the body and nothing else. It names both
2787/// numbers: a refusal that says only "too large" leaves the pusher
2788/// guessing how much to split by.
2789fn respond_push_too_large(
2790 request: tiny_http::Request,
2791 limit: u64,
2792 size: u64,
2793) -> std::io::Result<(u16, u64)> {
2794 let body = format!(
2795 "push refused: {size} bytes, and this node's per-user limit is {limit} bytes per \
2796 request\npush fewer objects at a time, or ask the operator to raise the limit\n"
2797 );
2798 let bytes = body.len() as u64;
2799 let response = tiny_http::Response::from_string(body)
2800 .with_status_code(413)
2801 .with_header(
2802 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
2803 .expect("static header"),
2804 );
2805 served(request, response, 413, bytes)
2806}
2807
2808/// Answers an API request whose body exceeded the node-wide ceiling.
2809fn respond_api_too_large(
2810 request: tiny_http::Request,
2811 limit: u64,
2812 size: u64,
2813) -> std::io::Result<(u16, u64)> {
2814 let body = serde_json::json!({
2815 "error": "request body too large",
2816 "limit_bytes": limit,
2817 "actual_bytes": size,
2818 })
2819 .to_string();
2820 let bytes = body.len() as u64;
2821 let response = tiny_http::Response::from_string(body)
2822 .with_status_code(413)
2823 .with_header(
2824 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
2825 .expect("static header"),
2826 );
2827 served(request, response, 413, bytes)
2828}
2829
2830/// A `304`, carrying the headers a client must not keep a stale copy of.
2831///
2832/// **A `304` is a header update, not just "nothing changed".** RFC 9111
2833/// has a cache replace its stored response's headers with the ones the
2834/// `304` carries; any header the `304` omits keeps whatever value it had
2835/// when the body was first stored. So a policy header left out of a
2836/// `304` is not merely absent for one exchange — it is *frozen* at the
2837/// version the client first saw, for as long as the entry lives.
2838///
2839/// This is not hypothetical. The browse pages' `ETag` is derived from a
2840/// commit, so it survives a daemon upgrade; the `304` carried only the
2841/// tag; and a reader whose cache held a page from before
2842/// [`BROWSER_CSP`] gained `form-action 'self'` kept the old
2843/// `form-action 'none'` through every reload. The search box rendered,
2844/// focused, took a term, and was refused by a policy the server had
2845/// already stopped sending. Only a cache-bypassing reload fixed it,
2846/// which is not a thing a reader knows to do.
2847///
2848/// One function rather than four call sites, for the reason
2849/// [`BROWSER_CSP`] is one constant: a fourth hand-assembled `304` is how
2850/// one of them ends up missing a header again.
2851fn not_modified(tag: &str, csp: &[u8]) -> tiny_http::Response<std::io::Empty> {
2852 tiny_http::Response::empty(304)
2853 .with_header(
2854 tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
2855 )
2856 .with_header(
2857 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], csp)
2858 .expect("static header"),
2859 )
2860 .with_header(
2861 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
2862 .expect("static header"),
2863 )
2864 .with_header(
2865 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
2866 .expect("static header"),
2867 )
2868}
2869
2870/// The referrer policy every response on this surface carries.
2871///
2872/// **`same-origin`, and it may not be `no-referrer`.** It was
2873/// `no-referrer` in ten hand-typed copies, and that value silently
2874/// disabled every browser write path on this node. The Fetch standard
2875/// sets a non-`GET` request's `Origin` header from the referrer policy:
2876/// under `no-referrer` the origin is serialized as the string `null`,
2877/// for same-origin submissions as much as for cross-site ones. So every
2878/// form on this surface posted `Origin: null`, `same_origin` compared it
2879/// against this node's own address, refused, and told the reader their
2880/// form came from another site. Sign-in, the operator console's four
2881/// forms and the account page were all unreachable from a browser while
2882/// every `curl` path stayed green, because a command-line client applies
2883/// no referrer policy and sends the header the check expects.
2884///
2885/// `same-origin` keeps the privacy this was for and stops defeating the
2886/// check. A `Referer` is still withheld from every other site, which is
2887/// the property `/join` depends on: its URL carries an invite secret in
2888/// the query string. What changes is that a *same-origin* request keeps
2889/// its real `Origin`, and only a genuinely cross-site one is nulled --
2890/// which is the request `same_origin` exists to refuse.
2891///
2892/// One constant rather than a copy per responder, for the reason
2893/// [`BROWSER_CSP`] gives: a value carrying a constraint this subtle,
2894/// hand-typed ten times, is a value that drifts.
2895const REFERRER_POLICY: &[u8] = b"same-origin";
2896
2897/// The policy every page on the browser surface carries.
2898///
2899/// One constant rather than a copy per responder: it is the layer that
2900/// holds if the escaper ever misses something, and a fifth hand-typed
2901/// copy is how one of them ends up subtly weaker than the rest.
2902///
2903/// **`form-action 'self'`, not `'none'`.** It was `'none'` for as long as
2904/// this surface had no form, and that was the right value then. It is a
2905/// one-word difference with a silent failure mode: a browser blocks the
2906/// submission and reports it to the console *only*, so the search box
2907/// rendered correctly, focused correctly, accepted a term, and did
2908/// nothing at all on `Enter`. Nothing in the served HTML was wrong, which
2909/// is why no assertion over the HTML could have caught it.
2910///
2911/// `img-src 'self' data:` is what lets a README render a diagram or a
2912/// screenshot the repository itself carries. It is the narrowest widening
2913/// that renders anything: `'self'` is this node, `data:` is bytes already
2914/// in the page, and every third-party host stays refused -- so a badge
2915/// from a shield service still does not load, and cannot, because
2916/// fetching one would tell that service who is reading this repository
2917/// and when. That is D28 and D59's whole point, and it is worth more than
2918/// a row of badges.
2919///
2920/// **`script-src 'self'` and `connect-src 'self'` are node-wide as of
2921/// D81, and that is a real loss worth naming.** Before it, a page with
2922/// no ceremony on it carried `default-src 'none'` with no `script-src`
2923/// at all: the read surface could not run anything even if an escaping
2924/// miss put a `<script>` in it, and the two pages that could run
2925/// something were the two a reader had to navigate to on purpose. The
2926/// command palette ships in the chrome, so it ships everywhere, and a
2927/// policy that licensed it on some pages would be licensing it on the
2928/// pages an attacker would pick anyway.
2929///
2930/// What still holds is the part that was ever a guarantee about other
2931/// people: `'self'` is this node and nowhere else. Nothing on this
2932/// surface can fetch, post to, frame, or load script from a third
2933/// party, and `X-Content-Type-Options: nosniff` on every response is
2934/// what keeps `'self'` from meaning "any bytes this node ever served" --
2935/// a browser will execute only what is typed as JavaScript, and the two
2936/// URLs that are typed that way are [`ui::WEBAUTHN_JS_PATH`] and
2937/// [`ui::PALETTE_JS_PATH`], both compile-time constants.
2938///
2939/// `'self'` is still the whole guarantee that matters here: a form on
2940/// this surface can submit to this origin and to no other, so no page
2941/// this node renders can be turned into a way of posting a reader's
2942/// input somewhere else.
2943const BROWSER_CSP: &[u8] = b"default-src 'none'; img-src 'self' data:; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'; script-src 'self'; connect-src 'self'";
2944
2945/// [`BROWSER_CSP`] with the two script directives taken back off, for
2946/// the documents that carry no script at all.
2947///
2948/// **The split runs the other way round from the one D81 removed.** That
2949/// one had a strict default and widened two pages; this one has the
2950/// surface's policy as the default and narrows the pages that opted out
2951/// of it. The difference matters because the failure modes are
2952/// opposite: a page that forgets to widen renders a dead button, which
2953/// is visible, while a page that forgets to narrow is merely less
2954/// defended, which is not.
2955///
2956/// **The invite page is the reason this exists rather than being
2957/// tidied away.** Its own address is a live credential (D36): the
2958/// secret is in the URL, so a script on that page is a script holding
2959/// somebody's invite in `location`, and `connect-src 'self'` would be
2960/// somewhere to send it. It runs nothing today and `join_page` builds
2961/// its own document precisely so that stays true -- but the header is
2962/// the layer that holds when an escaper misses something, and it is the
2963/// layer that would have had to hold. `/people` takes it for the
2964/// plainer reason its own module gives: the console renders forms and
2965/// no script, and a policy admitting one would be permission granted to
2966/// a page with no use for it.
2967const UNSCRIPTED_PAGE_CSP: &[u8] = b"default-src 'none'; img-src 'self' data:; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
2968
2969/// The policy a repository's own picture is served under.
2970///
2971/// Not [`BROWSER_CSP`], because these bytes are a different kind of
2972/// document: a file somebody pushed, served as itself. An `<img>` runs
2973/// nothing whatever the file holds, so the policy is for the other way
2974/// to reach this address, which is typing it. An SVG opened directly is
2975/// a document, and a document may carry script. `sandbox` and
2976/// `default-src 'none'` are what make it one that runs none and fetches
2977/// nothing, on the origin a reader is signed in to.
2978///
2979/// `style-src 'unsafe-inline'` is the one thing admitted, and it is
2980/// needed: an SVG's colours are commonly a `<style>` block inside it,
2981/// and under `'self'` every figure that has one would draw in black.
2982/// A style block in a sandboxed document that can load nothing has
2983/// nowhere to send what it can see.
2984const RAW_CSP: &[u8] =
2985 b"default-src 'none'; style-src 'unsafe-inline'; sandbox; frame-ancestors 'none'";
2986
2987/// Serves [`ui::UI_CSS`], addressed by a digest of its own bytes.
2988///
2989/// `immutable`, and that is only honest because the URL carries the
2990/// digest: a deploy that changes the sheet changes the address, so
2991/// nothing cached under the old one is ever wrong. A year is the
2992/// conventional maximum and the number itself does not matter -- what
2993/// matters is that a reader moving between pages fetches this once.
2994fn respond_stylesheet(request: tiny_http::Request) -> std::io::Result<(u16, u64)> {
2995 let tag = format!(
2996 "\"{}\"",
2997 &choir_oplog::ContentHash::blake3(ui::UI_CSS.as_bytes()).to_hex()[..18]
2998 );
2999 if header(&request, "If-None-Match").as_deref() == Some(tag.as_str()) {
3000 return served(request, not_modified(&tag, BROWSER_CSP), 304, 0);
3001 }
3002 let bytes = ui::UI_CSS.len() as u64;
3003 let response = tiny_http::Response::from_string(ui::UI_CSS)
3004 .with_header(
3005 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/css; charset=utf-8"[..])
3006 .expect("static header"),
3007 )
3008 .with_header(
3009 tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
3010 )
3011 .with_header(
3012 tiny_http::Header::from_bytes(
3013 &b"Cache-Control"[..],
3014 &b"public, max-age=31536000, immutable"[..],
3015 )
3016 .expect("static header"),
3017 )
3018 .with_header(
3019 tiny_http::Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..])
3020 .expect("static header"),
3021 )
3022 .with_header(
3023 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
3024 .expect("static header"),
3025 )
3026 .with_header(
3027 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3028 .expect("static header"),
3029 );
3030 served(request, response, 200, bytes)
3031}
3032
3033/// Serves one of the node's two scripts: [`ui::WEBAUTHN_JS`] or
3034/// [`ui::PALETTE_JS`].
3035///
3036/// A weak `ETag` over the bytes rather than a version string: the file
3037/// changes when the binary does and never otherwise, so a digest of what
3038/// is being sent is both the cheapest correct tag and the one that cannot
3039/// go stale against a rebuild. `no-cache` with a tag means a browser
3040/// revalidates and is answered `304` with an empty body, which is one
3041/// round trip per page load and no bytes.
3042///
3043/// The response carries `nosniff` for the same reason every other one
3044/// does, and here it is load-bearing in the other direction: this is the
3045/// single URL on the node that *is* JavaScript, and a browser under
3046/// `script-src 'self'` must be able to tell it apart from everything
3047/// else.
3048fn respond_static_script(
3049 request: tiny_http::Request,
3050 body: &'static str,
3051) -> std::io::Result<(u16, u64)> {
3052 // Per body rather than one cell for the pair: two files that shared
3053 // a tag would each be served the other's `304`, and the second
3054 // script is what turned that from a latent bug into a reachable
3055 // one.
3056 let tag = format!(
3057 "W/\"{}\"",
3058 &choir_oplog::ContentHash::blake3(body.as_bytes()).to_hex()[..18]
3059 );
3060 if header(&request, "If-None-Match").as_deref() == Some(tag.as_str()) {
3061 return served(request, not_modified(&tag, BROWSER_CSP), 304, 0);
3062 }
3063 let bytes = body.len() as u64;
3064 let response = tiny_http::Response::from_string(body)
3065 .with_header(
3066 tiny_http::Header::from_bytes(
3067 &b"Content-Type"[..],
3068 &b"text/javascript; charset=utf-8"[..],
3069 )
3070 .expect("static header"),
3071 )
3072 .with_header(
3073 tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
3074 )
3075 .with_header(
3076 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
3077 .expect("static header"),
3078 )
3079 .with_header(
3080 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
3081 .expect("static header"),
3082 )
3083 .with_header(
3084 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3085 .expect("static header"),
3086 );
3087 served(request, response, 200, bytes)
3088}
3089
3090/// Whether a request carried a credential this node could name.
3091///
3092/// `anon` is the name every unidentified request is logged and
3093/// authorized under, and the empty string is what a page rendered
3094/// outside a request has. Everything else is somebody. One function
3095/// rather than the comparison at each call site, because the bar's two
3096/// shapes and the comment box's gate are the same question and must not
3097/// start disagreeing about the answer.
3098///
3099/// [`acl::ANON`] is the third name and the one that was missing. D78
3100/// gives a reader who presented nothing a principal, so `user` became
3101/// `@anon` rather than `anon` and every test here said "somebody". The
3102/// bar then took its signed-in shape for a stranger: no way in, and
3103/// links to `/reviews` and `/account`, both of which answered them with
3104/// the sign-in page. It is spelled `@anon` precisely because no account
3105/// can be, so it belongs here beside the other name for nobody.
3106fn identified(user: &str) -> bool {
3107 !user.is_empty() && user != "anon" && user != acl::ANON
3108}
3109
3110/// The chrome facts for one request: the palette this reader chose and
3111/// the address they are on.
3112///
3113/// One helper rather than two lines at every refusal, because a refusal
3114/// rendered without it is a page that flips to the system palette at the
3115/// worst moment — the reader has just hit a wall, and the page changing
3116/// colour reads as a second thing going wrong.
3117fn reader_chrome(request: &tiny_http::Request) -> browse::Chrome<'static> {
3118 browse::Chrome {
3119 site: None,
3120 // No preview on a refusal. These pages are a `404` and a `403`
3121 // that must stay byte-identical, and they are not addresses
3122 // anybody shares; the signature is `'static` for the same
3123 // reason, so there is nowhere to borrow a host from anyway.
3124 origin: None,
3125 theme: chosen_theme(request),
3126 // Deliberately not the failing address: these pages are
3127 // refusals, and a palette link that returns the reader to the
3128 // page that just refused them is a link back into a wall. The
3129 // empty string is the front door.
3130 here: "",
3131 // A refusal offers no navigation of its own. Half of these pages
3132 // are rendered for a caller who has not been identified yet, so
3133 // "does this reader hold `@node write`" has no answer here, and
3134 // guessing `false` would be a console link that vanishes on a
3135 // 404 and reappears everywhere else. `ui::refusal` takes the
3136 // links each refusal actually wants as its own argument.
3137 account: false,
3138 console: false,
3139 docs: None,
3140 // For the same reason as the two above: half of these pages are
3141 // rendered before the caller has been identified, so "is this
3142 // reader signed in" has no answer here, and guessing would put
3143 // a `sign in` link in front of somebody who already is.
3144 signed_in: None,
3145 }
3146}
3147
3148/// The theme this reader has chosen, or `None` for "follow the system".
3149///
3150/// The stylesheet has carried `:root[data-theme="light"]` and its dark
3151/// twin since it was written, and nothing ever set the attribute, so the
3152/// manual override was decoration: a reader whose system said light read
3153/// a light page and had no way to say otherwise.
3154///
3155/// A cookie rather than a query parameter, because a preference that
3156/// only holds for the link you clicked is not a preference. It carries
3157/// no identity, is not a credential, and is never trusted for anything
3158/// but which palette to paint — which is why the parser accepts exactly
3159/// two spellings and treats everything else, including a value some
3160/// other software set, as absent.
3161fn chosen_theme(request: &tiny_http::Request) -> Option<&'static str> {
3162 let jar = header(request, "cookie")?;
3163 jar.split(';').find_map(|pair| {
3164 let (name, value) = pair.split_once('=')?;
3165 if name.trim() != "theme" {
3166 return None;
3167 }
3168 match value.trim() {
3169 "dark" => Some("dark"),
3170 "light" => Some("light"),
3171 _ => None,
3172 }
3173 })
3174}
3175
3176/// Where `/theme` may send a reader back to.
3177///
3178/// One leading slash and nothing that leaves this origin. `//evil.test`
3179/// is the case worth naming: a browser reads a protocol-relative URL as
3180/// another host, so a redirector that checks only "starts with `/`" is
3181/// an open redirect. A backslash is refused for the same reason — some
3182/// clients normalize it to a slash before resolving.
3183///
3184/// Anything that fails lands on the front door rather than being
3185/// reported: this is a preference control, and a reader who arrives at
3186/// the repository list with their theme changed has lost nothing.
3187/// The `to=` parameter of a `/theme` request, decoded and vetted.
3188///
3189/// Read here rather than through [`browse::param`], which refuses any
3190/// value that decodes to contain a `/` — right for a path *segment*,
3191/// which is all it was ever asked for, and wrong for a whole path.
3192/// Reusing it silently sent every reader to the front door instead of
3193/// back to the page they were on: the exact shape of the D52 change-id
3194/// bug, where a segment grammar was applied to something that is not a
3195/// segment.
3196///
3197/// Everything that fails lands on the front door via
3198/// [`safe_return_to`], including a value that is not UTF-8 at all.
3199fn return_to(url: &str) -> String {
3200 let Some((_, query)) = url.split_once('?') else {
3201 return "/".to_string();
3202 };
3203 let raw = query
3204 .split('&')
3205 .find_map(|pair| pair.strip_prefix("to="))
3206 .unwrap_or("");
3207 let bytes = raw.as_bytes();
3208 let mut out = Vec::with_capacity(bytes.len());
3209 let mut i = 0;
3210 while i < bytes.len() {
3211 if bytes[i] == b'%' {
3212 let Some(hex) = raw
3213 .get(i + 1..i + 3)
3214 .and_then(|h| u8::from_str_radix(h, 16).ok())
3215 else {
3216 return "/".to_string();
3217 };
3218 out.push(hex);
3219 i += 3;
3220 } else {
3221 out.push(bytes[i]);
3222 i += 1;
3223 }
3224 }
3225 match String::from_utf8(out) {
3226 Ok(path) => safe_return_to(&path),
3227 Err(_) => "/".to_string(),
3228 }
3229}
3230
3231fn safe_return_to(to: &str) -> String {
3232 let ok = to.starts_with('/')
3233 && !to.starts_with("//")
3234 && !to.contains('\\')
3235 && !to.chars().any(char::is_control);
3236 if ok {
3237 to.to_string()
3238 } else {
3239 "/".to_string()
3240 }
3241}
3242
3243/// Whether this URL is one a person is reading in a browser.
3244///
3245/// The browser surface is the node page, its alias, and everything under
3246/// the D30 browse prefix — minus anything carrying a `.git` segment,
3247/// which belongs to git however it is spelled (an owner really named `r`
3248/// has a clone URL under `/r/`, and this must not claim it).
3249///
3250/// Decided on the route rather than on `Accept`: that header says what a
3251/// client will *take*, not who it is, and every agent in this workspace
3252/// sends `*/*`. Keying on the route is what lets a refusal be a page for
3253/// the reader who met a wall while staying the JSON an agent can parse
3254/// everywhere else.
3255fn is_browser_route(url: &str) -> bool {
3256 if repo_from_path(url).is_some() {
3257 return false;
3258 }
3259 let path = url.split(['?', '#']).next().unwrap_or(url);
3260 path == "/"
3261 || path == "/index.html"
3262 || path == "/status"
3263 || path == "/theme"
3264 // The reader's own review queue. It is not under `/r/` because
3265 // it is not about one repository, so it has to be named here or
3266 // an anonymous reader meets a bare `401` and a browser dialog
3267 // instead of the page that signs them in (D74).
3268 || path == "/reviews"
3269 || path == "/r"
3270 || path.starts_with("/r/")
3271}
3272
3273/// Whether this path is one a browser requests by itself, for a page
3274/// that declares no icon: `/favicon.ico` everywhere, and the
3275/// `/apple-touch-icon*.png` family from Safari.
3276fn is_icon_probe(path: &str) -> bool {
3277 path == "/favicon.ico" || (path.starts_with("/apple-touch-icon") && path.ends_with(".png"))
3278}
3279
3280/// Whether an unauthenticated request may be evaluated as
3281/// [`acl::ANON`] instead of refused.
3282///
3283/// **This grants nothing.** It decides only whether the ACL is asked at
3284/// all; the answer still comes from the table, under a principal that
3285/// can hold at most `read` on repositories somebody named. A node whose
3286/// ACL mentions `@anon` nowhere is unchanged by this function, because
3287/// the first question it asks is whether the principal holds anything.
3288///
3289/// The route test is an allowlist and not a denylist, which is the
3290/// whole of its security argument. `/api/view` and `/api/log` are `GET`
3291/// requests that serve the op log, so "a safe method" would have
3292/// published the log; the reachable set is the browse surface plus the
3293/// read half of git smart-HTTP, and a route that is neither is refused
3294/// here whatever it would have answered.
3295///
3296/// `/reviews` is excluded from the browse half deliberately: it is the
3297/// *reader's own* queue, so for a caller who is every stranger at once
3298/// it is either empty or somebody else's, and neither is a page worth
3299/// serving.
3300fn anon_may_try(acl: Option<&acl::Effective>, request: &tiny_http::Request) -> bool {
3301 let Some(table) = acl else {
3302 // No ACL means every authenticated credential reaches every
3303 // repository, which is exactly the deployment in which silently
3304 // adding an unauthenticated one would be worst.
3305 return false;
3306 };
3307 if !table.holds_anything(acl::ANON) {
3308 return false;
3309 }
3310 let method = request.method().as_str();
3311 let url = request.url();
3312 let path = url.split(['?', '#']).next().unwrap_or(url);
3313 if repo_from_path(url).is_some() {
3314 // The git half: whatever `git_requirement` calls a read. Asking
3315 // it rather than matching on the path keeps this in step with
3316 // the route table it is about; a push is `Level::Propose`, which
3317 // `@anon` cannot parse its way into holding.
3318 return matches!(
3319 acl::git_requirement(method, url),
3320 Some((_, acl::Level::Read))
3321 );
3322 }
3323 if method != "GET" && method != "HEAD" {
3324 return false;
3325 }
3326 // The palette. Presentation and nothing else: it reads no
3327 // repository, writes one cookie, and its only side effect on the
3328 // response is a `Location` that `safe_return_to` has already
3329 // reduced to a single-leading-slash path. Withholding it put the
3330 // one control every page renders behind the wall, so a stranger
3331 // clicking `dark` was answered with the sign-in page.
3332 path == "/theme"
3333 || path == "/"
3334 || path == "/index.html"
3335 || path == "/r"
3336 || path.starts_with("/r/")
3337 // Search, on exactly the terms the page beside it already has.
3338 // `/r/<repo>/search/<rev>` is in the set above and `/api/search`
3339 // was not, so the same reader could search a published
3340 // repository by loading a page and not by asking the endpoint
3341 // that page's own three functions answer -- which is how D81's
3342 // palette met a `401` on a repository it was reading at the
3343 // time. There is no second authorization rule here either:
3344 // `api_search` applies the same `readable()` grant the index
3345 // walk applies, and a repository the caller was not granted is
3346 // answered exactly as one that does not exist (D62), so this
3347 // widens what can be *asked* and nothing about what is told.
3348 || path == "/api/search"
3349}
3350
3351/// Answers a browser-surface request with a page.
3352///
3353/// Carries the same headers the D28 and D30 pages carry, because a
3354/// refusal renders content this node did not author just as they do —
3355/// a ref name in an error message is still a ref name somebody chose.
3356fn respond_page(
3357 request: tiny_http::Request,
3358 status: u16,
3359 html: String,
3360 retry_after: Option<u64>,
3361) -> std::io::Result<(u16, u64)> {
3362 let bytes = html.len() as u64;
3363 let mut response = tiny_http::Response::from_string(html)
3364 .with_status_code(status)
3365 .with_header(
3366 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
3367 .expect("static header"),
3368 )
3369 .with_header(
3370 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
3371 .expect("static header"),
3372 )
3373 .with_header(
3374 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
3375 .expect("static header"),
3376 )
3377 .with_header(
3378 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3379 .expect("static header"),
3380 )
3381 // The palette is chosen by a cookie, so two readers of the same
3382 // address can be owed two different documents. `private` already
3383 // keeps a shared cache out; this says *why* the body varies, for
3384 // anything that stores it anyway.
3385 .with_header(
3386 tiny_http::Header::from_bytes(&b"Vary"[..], &b"Cookie"[..]).expect("static header"),
3387 );
3388 if let Some(secs) = retry_after {
3389 response.add_header(
3390 tiny_http::Header::from_bytes(&b"Retry-After"[..], secs.to_string().as_bytes())
3391 .expect("retry-after header"),
3392 );
3393 }
3394 served(request, response, status, bytes)
3395}
3396
3397/// A page that carries the ceremony script, and must never be cached.
3398///
3399/// The policy is no longer what separates this from [`respond_page`]:
3400/// D81 put `script-src 'self'` on the whole surface, so both send
3401/// [`BROWSER_CSP`]. What is left is `Cache-Control: no-store`, which is
3402/// the part that was always specific to a ceremony -- a page holding a
3403/// challenge is a page that must not be re-served from a disk, and a
3404/// reader who goes back to one must be handed a fresh one rather than
3405/// the spent challenge a shared cache kept.
3406fn respond_scripted_page(
3407 request: tiny_http::Request,
3408 status: u16,
3409 html: String,
3410) -> std::io::Result<(u16, u64)> {
3411 let bytes = html.len() as u64;
3412 let response = tiny_http::Response::from_string(html)
3413 .with_status_code(status)
3414 .with_header(
3415 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
3416 .expect("static header"),
3417 )
3418 .with_header(
3419 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3420 .expect("static header"),
3421 )
3422 .with_header(
3423 tiny_http::Header::from_bytes(&b"X-Content-Type-Options"[..], &b"nosniff"[..])
3424 .expect("static header"),
3425 )
3426 .with_header(
3427 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
3428 .expect("static header"),
3429 )
3430 .with_header(
3431 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
3432 .expect("static header"),
3433 );
3434 served(request, response, status, bytes)
3435}
3436
3437/// A JSON body with a status, for the pre-auth ceremony that has no
3438/// other responder to borrow.
3439fn respond_json(
3440 request: tiny_http::Request,
3441 status: u16,
3442 body: &str,
3443) -> std::io::Result<(u16, u64)> {
3444 let bytes = body.len() as u64;
3445 let response = tiny_http::Response::from_string(body.to_string())
3446 .with_status_code(status)
3447 .with_header(
3448 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
3449 .expect("static header"),
3450 )
3451 .with_header(
3452 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3453 .expect("static header"),
3454 );
3455 served(request, response, status, bytes)
3456}
3457
3458/// base64url as WebAuthn writes it, tolerating the padded spelling.
3459///
3460/// `clientDataJSON` is required to be unpadded base64url, but the node
3461/// reads it rather than writing it, and a decoder that refuses padding
3462/// would make this node the one that rejects an otherwise valid
3463/// authenticator over a spelling nobody would think to check.
3464fn base64url_any(input: &str) -> Option<Vec<u8>> {
3465 let standard: String = input
3466 .chars()
3467 .map(|c| match c {
3468 '-' => '+',
3469 '_' => '/',
3470 other => other,
3471 })
3472 .filter(|c| *c != '=')
3473 .collect();
3474 base64_decode(&standard)
3475}
3476
3477/// Serves the three halves of the passkey sign-in ceremony (D71): the
3478/// page, the challenge it spends, and the assertion it posts back.
3479///
3480/// All three answer before any credential has been evaluated, so this
3481/// function does what the auth gate would have done: it never trusts a
3482/// name from the body, it bounds the read, and it returns an outcome the
3483/// caller logs as `anon`.
3484///
3485/// The account is looked up from the credential id in the assertion
3486/// rather than from anything the caller says they are. That is the
3487/// property that makes a sign-in unforgeable without also making it
3488/// enumerable: a wrong credential id and a wrong signature produce the
3489/// same refusal, so the endpoint never says whether an account exists.
3490#[allow(clippy::too_many_arguments)]
3491/// The two tables a presented username and password are graded against.
3492///
3493/// One argument rather than two because they answer one question -- is
3494/// this a credential this node issued or an operator wrote down -- and
3495/// the sign-in form has to ask both. An operator's own credential lives
3496/// in the auth file and never in the store, so a form that consulted
3497/// only the store would refuse the one person who has to be able to get
3498/// in before anybody else does.
3499#[derive(Clone, Copy)]
3500struct Credentials<'a> {
3501 auth: Option<&'a AuthTable>,
3502 accounts: Option<&'a accounts::Accounts>,
3503}
3504
3505impl Credentials<'_> {
3506 /// The account name a `user`/`secret` pair proves, or `None`.
3507 ///
3508 /// An unredeemed invite is deliberately not a sign-in: it reaches one
3509 /// route, its own redemption, and a session opened for it would be a
3510 /// session for an account that does not exist yet.
3511 fn account_for(&self, user: &str, secret: &str) -> Option<String> {
3512 if let Some(expected) = self.auth.and_then(|table| table.get(user)) {
3513 if constant_time_eq(expected.as_bytes(), secret.as_bytes()) {
3514 return Some(user.to_string());
3515 }
3516 }
3517 match self.accounts?.authenticate(user, secret) {
3518 Some(accounts::Principal::Account(name)) => Some(name),
3519 _ => None,
3520 }
3521 }
3522}
3523
3524fn respond_signin(
3525 mut request: tiny_http::Request,
3526 path: &str,
3527 credentials: Credentials<'_>,
3528 sessions: &session::Sessions,
3529 passkeys: bool,
3530 scheme: &'static str,
3531 body_limit: std::num::NonZeroU64,
3532) -> std::io::Result<(u16, u64)> {
3533 let accounts = credentials.accounts;
3534 if path == "/api/signout" {
3535 // Forgetting the token is the whole mechanism: nothing else
3536 // anywhere would still honour it. Idempotent on purpose, and it
3537 // never says whether the token was live, because a caller signing
3538 // out has no use for that answer and a caller guessing tokens
3539 // would.
3540 if let Some(token) = session::cookie(header(&request, "Cookie").as_deref(), session::COOKIE)
3541 {
3542 sessions.close(&token);
3543 }
3544 // A redirect and a cleared cookie rather than JSON, so the control
3545 // that calls this can be an ordinary form and work with scripting
3546 // off, like every other control on these pages.
3547 let secure = if scheme == "https" { "; Secure" } else { "" };
3548 let cleared = format!(
3549 "{}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly{secure}",
3550 session::COOKIE
3551 );
3552 let response = tiny_http::Response::empty(303)
3553 .with_header(
3554 tiny_http::Header::from_bytes(&b"Location"[..], &b"/"[..])
3555 .expect("location header"),
3556 )
3557 .with_header(
3558 tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cleared.as_bytes())
3559 .expect("set-cookie header"),
3560 )
3561 .with_header(
3562 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3563 .expect("static header"),
3564 );
3565 return served(request, response, 303, 0);
3566 }
3567 if path == "/signin" && request.method().as_str() != "POST" {
3568 let next = safe_next(
3569 request
3570 .url()
3571 .split_once("?next=")
3572 .map(|(_, raw)| raw.split('&').next().unwrap_or("").to_string()),
3573 "/",
3574 );
3575 let chrome = reader_chrome(&request);
3576 let page = signin_page::render(passkeys, &next, signin_page::Said::Nothing, chrome);
3577 return respond_scripted_page(request, page.status, page.html);
3578 }
3579 // The form (D74). Plain `POST`, so it works with scripting off and a
3580 // browser's password manager can offer to keep what was typed.
3581 if path == "/signin" {
3582 if let Some((expected, found)) = origin_mismatch(&request, scheme) {
3583 // `null` is not somebody else's address, it is no address:
3584 // a browser sends it after following a redirect that crossed
3585 // origins, and the redirect this node's own reader hits is
3586 // `http` -> `https`, which a 308 performs with the POST
3587 // intact. So the sign-in page was loaded over `http`, its
3588 // relative form posted to `http`, and the browser arrived
3589 // here with its origin erased. Saying "another site" for
3590 // that is what sent a reader hunting an attacker.
3591 let opaque = found == "null";
3592 let html = ui::refusal(
3593 "Cross-origin sign-in refused",
3594 403,
3595 &ui::Refusal {
3596 code: "cross_origin",
3597 error: if opaque {
3598 "That form was loaded over http and redirected here, which erases \
3599 the browser's record of where it came from."
3600 } else {
3601 "That form did not come from this node's own sign-in page."
3602 },
3603 expected: Some(&expected),
3604 actual: Some(&found),
3605 next: if opaque {
3606 "Load the address under `expected` directly, with https, and sign \
3607 in from that page."
3608 } else {
3609 "Open the address under `expected` and sign in there."
3610 },
3611 },
3612 &[],
3613 reader_chrome(&request),
3614 );
3615 return respond_page(request, 403, html, None);
3616 }
3617 let chrome = reader_chrome(&request);
3618 let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3619 quota::Body::Complete(body) => String::from_utf8_lossy(&body).into_owned(),
3620 quota::Body::OverLimit { limit, size } => {
3621 return respond_api_too_large(request, limit, size)
3622 }
3623 };
3624 let next = safe_next(join_page::form_value(&body, "next"), "/");
3625 let signed_in = join_page::form_value(&body, "user")
3626 .zip(join_page::form_value(&body, "secret"))
3627 .and_then(|(user, secret)| credentials.account_for(user.trim(), &secret));
3628 let Some(user) = signed_in else {
3629 // The same page again, at the same status, saying the one
3630 // thing it may say. No redirect: a `303` here would put the
3631 // failure in history and lose what was typed in the other
3632 // field.
3633 let page = signin_page::render(passkeys, &next, signin_page::Said::NoMatch, chrome);
3634 return respond_scripted_page(request, page.status, page.html);
3635 };
3636 // Straight to the page that enrols a passkey, when this node
3637 // offers them and this account has none (D74). That is the step
3638 // everybody has to take exactly once and the one nobody knows to
3639 // look for, and a redirect is a cheaper way to say it than a
3640 // sentence somebody has to read.
3641 let enrol_first = passkeys
3642 && accounts.is_some_and(|store| {
3643 store.has_account(&user) && store.passkeys_json(&user).is_empty()
3644 });
3645 let land = if enrol_first {
3646 "/account"
3647 } else {
3648 next.as_str()
3649 };
3650 let token = sessions.open(&user);
3651 return respond_session(request, &token, land, scheme);
3652 }
3653
3654 let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
3655 quota::Body::Complete(body) => body,
3656 quota::Body::OverLimit { limit, size } => {
3657 return respond_api_too_large(request, limit, size)
3658 }
3659 };
3660
3661 if path == "/api/signin/challenge" {
3662 let issued = sessions.issue_challenge();
3663 // WebAuthn wants the challenge base64url, and what it stands for
3664 // is the hex of a hash: the same bytes `webauthn_challenge` hands
3665 // a browser approving an operation, so one verifier checks both.
3666 let answer = serde_json::json!({
3667 "format_version": 1,
3668 "challenge": prepare::base64url_nopad(issued.to_hex().as_bytes()),
3669 })
3670 .to_string();
3671 return respond_json(request, 200, &answer);
3672 }
3673
3674 // `/api/signin`.
3675 let refuse = |request| {
3676 respond_json(
3677 request,
3678 401,
3679 r#"{"error":"that passkey did not sign this node's challenge"}"#,
3680 )
3681 };
3682 let Some(store) = accounts else {
3683 return respond_json(
3684 request,
3685 503,
3686 r#"{"error":"account self-service is not enabled on this node"}"#,
3687 );
3688 };
3689 let Ok(body) = serde_json::from_slice::<serde_json::Value>(&body) else {
3690 return respond_json(request, 400, r#"{"error":"body must be JSON"}"#);
3691 };
3692 let field = |name: &str| {
3693 body.get(name)
3694 .and_then(serde_json::Value::as_str)
3695 .map(str::to_string)
3696 };
3697 let (Some(key_id), Some(signature), Some(authenticator), Some(client_data)) = (
3698 field("key_id"),
3699 field("signature_hex"),
3700 field("authenticator_data_hex"),
3701 field("client_data_json_hex"),
3702 ) else {
3703 return respond_json(request, 400, r#"{"error":"the assertion is incomplete"}"#);
3704 };
3705 let Some((user, spki)) = store.account_for_credential(&key_id) else {
3706 return refuse(request);
3707 };
3708 let witness = choir_oplog::Witness {
3709 key_id,
3710 scheme: Some(choir_oplog::scheme::WEBAUTHN_ES256),
3711 signature: match platform::hex_decode(&signature) {
3712 Some(bytes) => bytes,
3713 None => return refuse(request),
3714 },
3715 authenticator_data: platform::hex_decode(&authenticator),
3716 client_data_json: platform::hex_decode(&client_data),
3717 credential_key: None,
3718 };
3719 // The challenge inside the assertion decides which challenge is being
3720 // spent, and spending it is what stops the same assertion opening a
3721 // second session. Read before verification only because verification
3722 // needs to know which bytes were promised; an assertion naming a
3723 // challenge this node never issued is refused here.
3724 let Some(claimed) = claimed_challenge(&witness) else {
3725 return refuse(request);
3726 };
3727 if !sessions.spend_challenge(&claimed) {
3728 return refuse(request);
3729 }
3730 if choir_identity::verify_webauthn_assertion(&spki, &claimed, &witness).is_err() {
3731 return refuse(request);
3732 }
3733
3734 let token = sessions.open(&user);
3735 let secure = if scheme == "https" { "; Secure" } else { "" };
3736 let cookie = format!(
3737 "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
3738 session::COOKIE
3739 );
3740 let answer = serde_json::json!({ "format_version": 1, "user": user }).to_string();
3741 let bytes = answer.len() as u64;
3742 let response = tiny_http::Response::from_string(answer)
3743 .with_status_code(200)
3744 .with_header(
3745 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
3746 .expect("static header"),
3747 )
3748 .with_header(
3749 tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3750 .expect("set-cookie header"),
3751 )
3752 .with_header(
3753 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3754 .expect("static header"),
3755 );
3756 served(request, response, 200, bytes)
3757}
3758
3759/// A `next` from a request, reduced to somewhere on this node.
3760///
3761/// A path, starting with one slash. `//evil.example` is a *protocol
3762/// relative URL* and would send somebody who signed in here to another
3763/// origin, which is the whole open-redirect family in one line.
3764fn safe_next(raw: Option<String>, fallback: &str) -> String {
3765 raw.filter(|next| next.starts_with('/') && !next.starts_with("//"))
3766 .unwrap_or_else(|| fallback.to_string())
3767}
3768
3769/// Opens the browser session cookie and sends the reader on to `land`.
3770///
3771/// `SameSite=Lax` is what keeps every state-changing form on this node
3772/// out of reach of another site: a cross-site `POST` does not carry this
3773/// cookie. `HttpOnly` because no script here reads it, and `Secure`
3774/// whenever the node is speaking https, so a session cannot be sent in
3775/// clear by a downgrade.
3776fn respond_session(
3777 request: tiny_http::Request,
3778 token: &str,
3779 land: &str,
3780 scheme: &'static str,
3781) -> std::io::Result<(u16, u64)> {
3782 let secure = if scheme == "https" { "; Secure" } else { "" };
3783 let cookie = format!(
3784 "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
3785 session::COOKIE
3786 );
3787 let response = tiny_http::Response::empty(303)
3788 .with_header(
3789 tiny_http::Header::from_bytes(&b"Location"[..], land.as_bytes())
3790 .expect("location header"),
3791 )
3792 .with_header(
3793 tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
3794 .expect("set-cookie header"),
3795 )
3796 .with_header(
3797 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
3798 .expect("static header"),
3799 );
3800 served(request, response, 303, 0)
3801}
3802
3803/// The challenge an assertion says it signed, as the node spells one.
3804///
3805/// `clientDataJSON` carries it base64url, and what it decodes to is the
3806/// hex of a [`ContentHash`]; this turns that back into the hash so the
3807/// store can be asked whether it issued it.
3808fn claimed_challenge(witness: &choir_oplog::Witness) -> Option<choir_hash::ContentHash> {
3809 let json = witness.client_data_json.as_ref()?;
3810 let client: serde_json::Value = serde_json::from_slice(json).ok()?;
3811 let encoded = client.get("challenge")?.as_str()?;
3812 let hex = String::from_utf8(base64url_any(encoded)?).ok()?;
3813 choir_hash::ContentHash::from_hex(&hex)
3814}
3815
3816/// Serves the social-preview card (D57).
3817///
3818/// Immutable and cached for a year: the bytes are compiled into the
3819/// binary, so the only way they change is a new binary, and a chat client
3820/// that has to refetch a card it already holds is spending a stranger's
3821/// request budget on a picture.
3822fn respond_card(request: tiny_http::Request) -> std::io::Result<(u16, u64)> {
3823 let bytes = ui::CARD.len() as u64;
3824 let response = tiny_http::Response::from_data(ui::CARD)
3825 .with_header(
3826 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..])
3827 .expect("static header"),
3828 )
3829 .with_header(
3830 tiny_http::Header::from_bytes(
3831 &b"Cache-Control"[..],
3832 &b"public, max-age=31536000, immutable"[..],
3833 )
3834 .expect("static header"),
3835 );
3836 served(request, response, 200, bytes)
3837}
3838
3839/// Serves a card drawn for one repository (see [`card::png`]).
3840///
3841/// The name is taken from the URL and checked against the grammar a
3842/// repository name has, then drawn. It is deliberately *not* looked up:
3843/// a route that answered `404` for a name this node does not hold would
3844/// be a way to enumerate the ones it does, and the picture is a function
3845/// of the string either way.
3846fn respond_repo_card(request: tiny_http::Request, path: &str) -> std::io::Result<(u16, u64)> {
3847 let missing = |request| respond_plain(request, 404, "no such card\n", b"no-store");
3848 let Some(repo) = path
3849 .strip_prefix(card::PREFIX)
3850 .and_then(|rest| rest.strip_suffix(".png"))
3851 else {
3852 return missing(request);
3853 };
3854 let Some((owner, name)) = repo.split_once('/') else {
3855 return missing(request);
3856 };
3857 if !provision::safe_segment(owner) || !provision::safe_segment(name) {
3858 return missing(request);
3859 }
3860 let bytes = card::png(repo);
3861 let len = bytes.len() as u64;
3862 let response = tiny_http::Response::from_data(bytes)
3863 .with_header(
3864 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..])
3865 .expect("static header"),
3866 )
3867 // A day, not [`respond_card`]'s year: these bytes change with the
3868 // drawing code, so a redeploy that redraws the card must not be
3869 // waiting on a year-old cache to expire.
3870 .with_header(
3871 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=86400"[..])
3872 .expect("static header"),
3873 );
3874 served(request, response, 200, len)
3875}
3876
3877/// Serves [`ui::ROBOTS`].
3878///
3879/// An hour rather than [`respond_card`]'s year: the card's bytes can only
3880/// change with a new binary, but a crawl policy is the kind of thing an
3881/// operator wants to take effect the same afternoon they change it, and
3882/// a year-long cache on the wrong policy is not recallable.
3883fn respond_robots(
3884 request: tiny_http::Request,
3885 acl: Option<&acl::Effective>,
3886 downloads: bool,
3887 scheme: &'static str,
3888) -> std::io::Result<(u16, u64)> {
3889 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
3890 let repos = acl
3891 .map(|table| table.readable_repos(acl::ANON))
3892 .unwrap_or_default();
3893 let policy = ui::robots(&repos, downloads, origin.as_deref());
3894 let bytes = policy.len() as u64;
3895 let response = tiny_http::Response::from_string(policy)
3896 .with_header(
3897 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
3898 .expect("static header"),
3899 )
3900 .with_header(
3901 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=3600"[..])
3902 .expect("static header"),
3903 );
3904 served(request, response, 200, bytes)
3905}
3906
3907/// Serves [`ui::sitemap`].
3908///
3909/// An hour, like the crawl policy beside it and unlike the card: both
3910/// are derived from a file an operator edits, and a day-long cache on a
3911/// document naming a repository they have just unpublished is the wrong
3912/// way for this to be wrong.
3913fn respond_sitemap(
3914 request: tiny_http::Request,
3915 acl: Option<&acl::Effective>,
3916 downloads: bool,
3917 scheme: &'static str,
3918) -> std::io::Result<(u16, u64)> {
3919 let Some(origin) = header(&request, "host").map(|host| format!("{scheme}://{host}")) else {
3920 // Every entry is an absolute URL, so with no `Host` there is no
3921 // document to render rather than a shorter one.
3922 return respond_plain(
3923 request,
3924 400,
3925 "this route needs a Host header\n",
3926 b"no-store",
3927 );
3928 };
3929 let repos = acl
3930 .map(|table| table.readable_repos(acl::ANON))
3931 .unwrap_or_default();
3932 let body = ui::sitemap(&origin, &repos, downloads);
3933 let bytes = body.len() as u64;
3934 let response = tiny_http::Response::from_string(body)
3935 .with_header(
3936 tiny_http::Header::from_bytes(
3937 &b"Content-Type"[..],
3938 &b"application/xml; charset=utf-8"[..],
3939 )
3940 .expect("static header"),
3941 )
3942 .with_header(
3943 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=3600"[..])
3944 .expect("static header"),
3945 );
3946 served(request, response, 200, bytes)
3947}
3948
3949/// Serves the release shelf: the index, the installer, or one file
3950/// (D79).
3951///
3952/// The installer is rendered from the request's own `Host` rather than
3953/// read off the shelf, which is what makes the script fetch from the
3954/// node that served it without this process ever learning an address of
3955/// its own. A `Host` that is not a hostname is refused rather than
3956/// guessed around: it is about to be pasted into a shell script.
3957///
3958/// A name that is not [`downloads::safe_name`] is a `404` and not a
3959/// `400`, because the difference between "refused" and "absent" is
3960/// exactly the disclosure a traversal probe is fishing for.
3961fn respond_download(
3962 request: tiny_http::Request,
3963 path: &str,
3964 shelf: &std::path::Path,
3965 scheme: &'static str,
3966 site: Option<&str>,
3967) -> std::io::Result<(u16, u64)> {
3968 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
3969 let Some(origin) = origin.filter(|origin| downloads::safe_origin(origin)) else {
3970 return respond_plain(
3971 request,
3972 400,
3973 "this route needs a Host header\n",
3974 b"no-store",
3975 );
3976 };
3977 let name = path.strip_prefix(downloads::PREFIX).unwrap_or("");
3978
3979 if name.is_empty() {
3980 let page = downloads::index(shelf, &origin, chosen_theme(&request), site);
3981 return respond_page(request, 200, page, None);
3982 }
3983
3984 if name == downloads::INSTALLER_NAME {
3985 // `no-store`, unlike everything else here: the body carries the
3986 // host it was asked for, so a shared cache holding one copy
3987 // would hand the next reader a script pointing somewhere else.
3988 // `Vary: Host` says the same thing to a cache that stores it
3989 // anyway, on the reasoning `respond_page` gives for `Vary:
3990 // Cookie`.
3991 let body = downloads::installer(&origin);
3992 let bytes = body.len() as u64;
3993 let response = tiny_http::Response::from_string(body)
3994 .with_header(
3995 tiny_http::Header::from_bytes(
3996 &b"Content-Type"[..],
3997 &b"text/plain; charset=utf-8"[..],
3998 )
3999 .expect("static header"),
4000 )
4001 .with_header(
4002 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
4003 .expect("static header"),
4004 )
4005 .with_header(
4006 tiny_http::Header::from_bytes(&b"Vary"[..], &b"Host"[..]).expect("static header"),
4007 );
4008 return served(request, response, 200, bytes);
4009 }
4010
4011 let missing = |request| respond_plain(request, 404, "no such file\n", b"no-store");
4012 if !downloads::safe_name(name) {
4013 return missing(request);
4014 }
4015 // `symlink_metadata`, so a link on the shelf is a link and not the
4016 // thing it points at. The shelf is a public directory, and following
4017 // one out of it is how `/etc` ends up being served by a route that
4018 // only ever meant to hand out tarballs.
4019 let file = shelf.join(name);
4020 let Ok(meta) = std::fs::symlink_metadata(&file) else {
4021 return missing(request);
4022 };
4023 let Ok(handle) = std::fs::File::open(&file) else {
4024 return missing(request);
4025 };
4026 if !meta.is_file() {
4027 return missing(request);
4028 }
4029 let response = tiny_http::Response::from_file(handle)
4030 .with_header(
4031 tiny_http::Header::from_bytes(
4032 &b"Content-Type"[..],
4033 downloads::content_type(name).as_bytes(),
4034 )
4035 .expect("static header"),
4036 )
4037 // Five minutes, not a year: these bytes are a file on a disk an
4038 // operator can replace, unlike [`respond_card`]'s, which can
4039 // only change with a new binary.
4040 .with_header(
4041 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"public, max-age=300"[..])
4042 .expect("static header"),
4043 );
4044 served(request, response, 200, meta.len())
4045}
4046
4047/// One line of text, for a route whose readers are `curl` rather than
4048/// browsers.
4049fn respond_plain(
4050 request: tiny_http::Request,
4051 status: u16,
4052 body: &str,
4053 cache: &[u8],
4054) -> std::io::Result<(u16, u64)> {
4055 let bytes = body.len() as u64;
4056 let response = tiny_http::Response::from_string(body.to_string())
4057 .with_status_code(status)
4058 .with_header(
4059 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
4060 .expect("static header"),
4061 )
4062 .with_header(
4063 tiny_http::Header::from_bytes(&b"Cache-Control"[..], cache).expect("static header"),
4064 );
4065 served(request, response, status, bytes)
4066}
4067
4068/// The most a pre-auth request body may be (D57).
4069///
4070/// Two hex credentials and an ssh public key, with room to spare. It is
4071/// not [`Node::api_body_limit`] because that ceiling is for authenticated
4072/// callers doing real work, and this one is reached by anybody at all: a
4073/// megabyte a stranger can spend without a credential is a megabyte they
4074/// can spend in a loop.
4075const JOIN_BODY_BYTES: u64 = 8 * 1024;
4076
4077/// Answers a pre-auth request that the public limiter refused.
4078///
4079/// Plain text rather than a page. A caller hitting this is a loop, not a
4080/// reader, and rendering the whole stylesheet to tell them so would spend
4081/// exactly the resource the limiter is protecting.
4082fn respond_public_busy(request: tiny_http::Request, retry: u64) -> std::io::Result<(u16, u64)> {
4083 let body = "too many requests\n";
4084 let response = tiny_http::Response::from_string(body)
4085 .with_status_code(429)
4086 .with_header(
4087 tiny_http::Header::from_bytes(&b"Retry-After"[..], retry.to_string().as_bytes())
4088 .expect("a number is a valid header value"),
4089 );
4090 served(request, response, 429, body.len() as u64)
4091}
4092
4093/// Serves the public front door: the landing page and the invite link.
4094///
4095/// Headers differ from [`respond_page`] in one deliberate way:
4096/// `Cache-Control: no-store` rather than `private, no-cache`. A request
4097/// here carries a live credential in its URL and an answer may carry a
4098/// freshly minted one in its body, and neither belongs in a cache that
4099/// something else can read — including the browser's own disk cache on a
4100/// shared machine.
4101fn respond_join(
4102 mut request: tiny_http::Request,
4103 store: Option<&accounts::Accounts>,
4104 path: &str,
4105 offers: join_page::Offers,
4106 sessions: &session::Sessions,
4107 scheme: &'static str,
4108 root: &std::path::Path,
4109) -> std::io::Result<(u16, u64)> {
4110 let join_page::Offers {
4111 ssh,
4112 passkeys,
4113 publishes,
4114 downloads,
4115 } = offers;
4116 let _ = ssh;
4117 let contact = operator_contact(root);
4118 let contact = contact.as_deref();
4119 let docs = docs_url(root);
4120 let theme = chosen_theme(&request);
4121 let chrome = browse::Chrome {
4122 site: None,
4123 theme,
4124 // `join_page` mints its own preview tags, deliberately naming no
4125 // repository and no issuer, so the bar does not add a second set.
4126 origin: None,
4127 // Empty: these pages carry no navigation bar, so there is no
4128 // palette link that would need somewhere to return to — and an
4129 // address that did carry one would be carrying the invite secret
4130 // into an `href`.
4131 here: "",
4132 // Nobody reading these pages has an account yet, which is what
4133 // both of these link to.
4134 account: false,
4135 console: false,
4136 // The book, though, is exactly what a stranger who has just read
4137 // the front door wants next, and it is the one half of this site
4138 // that needs no credential at all (D76).
4139 docs: docs.as_deref(),
4140 // These pages draw no bar, so this decides nothing; `None` is
4141 // the honest value rather than the harmless one.
4142 signed_in: None,
4143 };
4144 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4145 let url = request.url().to_string();
4146 let page = if path == "/join" {
4147 if request.method().as_str() == "POST" {
4148 match quota::read_bounded(
4149 request.as_reader(),
4150 std::num::NonZeroU64::new(JOIN_BODY_BYTES),
4151 ) {
4152 Ok(quota::Body::Complete(bytes)) => {
4153 let body = String::from_utf8_lossy(&bytes).into_owned();
4154 join_page::post(store, &body, origin.as_deref(), chrome)
4155 }
4156 // An over-long body is not told apart from a bad one: the
4157 // page a stranger sees is the same either way, and the
4158 // distinction is only useful to somebody probing.
4159 _ => join_page::not_valid(403, theme),
4160 }
4161 } else {
4162 join_page::get(
4163 store,
4164 join_page::param(&url, "i").as_deref(),
4165 join_page::param(&url, "k").as_deref(),
4166 join_page::Offers {
4167 ssh,
4168 passkeys,
4169 publishes,
4170 downloads,
4171 },
4172 chrome,
4173 origin.as_deref(),
4174 join_page::now_unix_secs(),
4175 )
4176 }
4177 } else {
4178 join_page::landing(
4179 theme,
4180 &join_page::Door {
4181 contact,
4182 // A node with no store cannot hold a queue, so the front
4183 // door does not offer a form whose one button would
4184 // answer 503 (D72).
4185 asking: store.is_some(),
4186 docs: docs.as_deref(),
4187 publishes,
4188 downloads,
4189 },
4190 )
4191 };
4192 let bytes = page.html.len() as u64;
4193 // The page knows whether it emitted a `<script>` tag (D72), and the
4194 // header follows that rather than the route. A responder deciding
4195 // from the route is how D71's sign-in page shipped with a button the
4196 // header would not let run.
4197 let scripted = page.scripted;
4198 // A passwordless redemption ends signed in (D75): there is no
4199 // credential for the reader to present afterwards, so the cookie is
4200 // the only thing that makes the route finish rather than end at a
4201 // page saying "now log in with the nothing you were given".
4202 let opened = page.session.as_deref().map(|user| sessions.open(user));
4203 let mut response = tiny_http::Response::from_string(page.html)
4204 .with_status_code(page.status)
4205 .with_header(
4206 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
4207 .expect("static header"),
4208 )
4209 .with_header(
4210 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
4211 .expect("static header"),
4212 )
4213 .with_header(
4214 // The page says which it needs, because it is the only thing
4215 // that knows whether it emitted a `<script>` tag (D72). A
4216 // responder deciding from the route is how D71's sign-in page
4217 // shipped with a button the header would not let run.
4218 tiny_http::Header::from_bytes(
4219 &b"Content-Security-Policy"[..],
4220 if scripted {
4221 BROWSER_CSP
4222 } else {
4223 UNSCRIPTED_PAGE_CSP
4224 },
4225 )
4226 .expect("static header"),
4227 )
4228 .with_header(
4229 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4230 .expect("static header"),
4231 );
4232 if let Some(token) = opened.as_deref() {
4233 let secure = if scheme == "https" { "; Secure" } else { "" };
4234 let cookie = format!(
4235 "{}={token}; Path=/; Max-Age=43200; SameSite=Lax; HttpOnly{secure}",
4236 session::COOKIE
4237 );
4238 response = response.with_header(
4239 tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
4240 .expect("set-cookie header"),
4241 );
4242 }
4243 // An invite link that reaches a search index is an invite spent by a
4244 // crawler. The header carries where a `<meta>` tag cannot: on the
4245 // redirect-free fetch a crawler actually makes.
4246 if path == "/join" {
4247 response = response.with_header(
4248 tiny_http::Header::from_bytes(&b"X-Robots-Tag"[..], &b"noindex, nofollow"[..])
4249 .expect("static header"),
4250 );
4251 }
4252 served(request, response, page.status, bytes)
4253}
4254
4255/// Serves D72's two pre-auth endpoints: issue a challenge, and take a
4256/// request that solved it.
4257///
4258/// The order inside the `POST /api/access` arm is the security property.
4259/// The challenge is spent **first**, from an in-memory table, before a
4260/// single hash is computed: that makes the work verifiable exactly once
4261/// per challenge issued, and it keeps a caller from turning one solved
4262/// stamp into sixty-four rows. Verifying the stamp before spending the
4263/// challenge would leave the same solution good until it expired.
4264///
4265/// Refusals here are deliberately not specific. A caller that sends a
4266/// stale challenge, an unsolved nonce or a challenge this node never
4267/// issued gets the same sentence, because the differences are only useful
4268/// to somebody probing how the cost is checked.
4269fn respond_access(
4270 mut request: tiny_http::Request,
4271 path: &str,
4272 store: Option<&accounts::Accounts>,
4273 sessions: &session::Sessions,
4274 scheme: &'static str,
4275 body_limit: std::num::NonZeroU64,
4276) -> std::io::Result<(u16, u64)> {
4277 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4278 if path == "/api/access/challenge" {
4279 let challenge = sessions.issue_challenge();
4280 return respond_json(
4281 request,
4282 200,
4283 &serde_json::json!({
4284 "challenge": challenge.to_hex(),
4285 "bits": work::WORK_BITS,
4286 })
4287 .to_string(),
4288 );
4289 }
4290 let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
4291 quota::Body::Complete(body) => body,
4292 quota::Body::OverLimit { limit, size } => {
4293 return respond_api_too_large(request, limit, size)
4294 }
4295 };
4296 let Ok(json) = serde_json::from_slice::<serde_json::Value>(&body) else {
4297 return respond_json(request, 400, r#"{"error":"body must be JSON"}"#);
4298 };
4299 let refused = r#"{"error":"that stamp is not one this node issued and has not been spent; reload the page and try again"}"#;
4300 let (Some(challenge), Some(nonce)) = (
4301 json.get("challenge").and_then(serde_json::Value::as_str),
4302 json.get("nonce").and_then(serde_json::Value::as_str),
4303 ) else {
4304 return respond_json(request, 400, refused);
4305 };
4306 let Some(parsed) = choir_hash::ContentHash::from_hex(challenge) else {
4307 return respond_json(request, 400, refused);
4308 };
4309 if !sessions.spend_challenge(&parsed) {
4310 return respond_json(request, 400, refused);
4311 }
4312 if !work::solved(challenge, nonce) {
4313 return respond_json(request, 400, refused);
4314 }
4315 let (status, answer) = match store {
4316 Some(store) => with_join_url(store.request_access(&json), origin.as_deref(), "request"),
4317 None => (
4318 503,
4319 r#"{"error":"account self-service is not enabled on this node"}"#.to_string(),
4320 ),
4321 };
4322 respond_json(request, status, &answer)
4323}
4324
4325/// Sets or clears the theme cookie and sends the reader back where they
4326/// were.
4327///
4328/// `303`, not `302`: it is the status that says "the result of this is
4329/// at another address, fetch it with `GET`", which is exactly true here
4330/// and leaves no room for a client to repeat the request as something
4331/// else.
4332///
4333/// The cookie is `SameSite=Lax` and `HttpOnly`. Lax because a theme
4334/// chosen from a link on this node is the only way it is ever set, and
4335/// `HttpOnly` because nothing on this surface runs script — a cookie
4336/// script cannot read is one less thing for a future page to leak.
4337/// `Secure` follows the scheme this node is actually serving rather
4338/// than being hardcoded either way. Always-on would be a control that
4339/// silently does nothing on the loopback node this is developed
4340/// against: the browser drops the cookie, the palette never sticks, and
4341/// nothing in the response says why. Never-on would leave a real
4342/// deployment setting a cookie over TLS that a browser then sends in
4343/// clear if anything ever reaches it over plain HTTP. The node knows
4344/// which one it is, so it says.
4345fn respond_theme(
4346 request: tiny_http::Request,
4347 set: &str,
4348 back: &str,
4349 scheme: &str,
4350) -> std::io::Result<(u16, u64)> {
4351 // Clearing is `Max-Age=0`, which is how a cookie is deleted; any
4352 // spelling other than the two real ones clears rather than errors,
4353 // so a hand-typed `/theme?set=nonsense` returns the reader to the
4354 // system default instead of to a refusal page.
4355 let secure = if scheme == "https" { "; Secure" } else { "" };
4356 let cookie = match set {
4357 "dark" | "light" => {
4358 format!("theme={set}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly{secure}")
4359 }
4360 _ => format!("theme=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly{secure}"),
4361 };
4362 let response = tiny_http::Response::empty(303)
4363 .with_header(
4364 tiny_http::Header::from_bytes(&b"Location"[..], back.as_bytes())
4365 .expect("location header"),
4366 )
4367 .with_header(
4368 tiny_http::Header::from_bytes(&b"Set-Cookie"[..], cookie.as_bytes())
4369 .expect("set-cookie header"),
4370 )
4371 .with_header(
4372 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
4373 .expect("static header"),
4374 )
4375 .with_header(
4376 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4377 .expect("static header"),
4378 );
4379 served(request, response, 303, 0)
4380}
4381
4382/// Answers a git request the ACL refused. Plain text, because that is
4383/// what a git client surfaces to whoever ran the command.
4384fn respond_git_denial(
4385 request: tiny_http::Request,
4386 denial: &acl::Denial,
4387) -> std::io::Result<(u16, u64)> {
4388 let body = format!("{}\n", denial.reason);
4389 let bytes = body.len() as u64;
4390 let response = tiny_http::Response::from_string(body)
4391 .with_status_code(denial.status)
4392 .with_header(
4393 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..])
4394 .expect("static header"),
4395 );
4396 served(request, response, denial.status, bytes)
4397}
4398
4399/// Extracts `owner/repo.git` from a smart-HTTP path like
4400/// `/owner/repo.git/git-receive-pack`.
4401pub(crate) fn repo_from_path(url: &str) -> Option<String> {
4402 let path = url.split('?').next().unwrap_or(url);
4403 let end = path
4404 .find(".git/")
4405 .map(|i| i + 4)
4406 .or_else(|| path.ends_with(".git").then_some(path.len()))?;
4407 Some(path[1..end].to_string())
4408}
4409
4410/// Points `HEAD` at a branch that exists, when it does not already.
4411///
4412/// A repository whose `HEAD` names a branch nobody ever created
4413/// advertises no default branch at all, and git's own behaviour then
4414/// splits in two: a plain `git clone` fetches every ref and guesses,
4415/// while anything that has to *infer* the default -- `--depth`,
4416/// `--single-branch`, most CI checkouts -- asks for `HEAD`'s branch, is
4417/// told nothing, and reports an empty repository. The first of those is
4418/// what a test does and the second is what a stranger does, which is how
4419/// this survived a suite that clones on every run.
4420///
4421/// Best-effort and silent, because it is a repair on a read path: a
4422/// repository it cannot fix advertises exactly what it advertised
4423/// before. It never repoints a `HEAD` that already resolves, so a
4424/// deliberately chosen default branch is left alone.
4425fn settle_head(repo: &Path) {
4426 let born = std::process::Command::new("git")
4427 .args(["rev-parse", "--verify", "--quiet", "HEAD"])
4428 .current_dir(repo)
4429 .stdout(std::process::Stdio::null())
4430 .stderr(std::process::Stdio::null())
4431 .status()
4432 .is_ok_and(|status| status.success());
4433 if born {
4434 return;
4435 }
4436 let Ok(out) = std::process::Command::new("git")
4437 .args(["for-each-ref", "--format=%(refname)", "refs/heads"])
4438 .current_dir(repo)
4439 .output()
4440 else {
4441 return;
4442 };
4443 let listing = String::from_utf8_lossy(&out.stdout);
4444 let branches: Vec<&str> = listing.lines().filter(|line| !line.is_empty()).collect();
4445 // `main`, then `master`, then whatever is there: the repaired answer
4446 // must not depend on the order git happens to list refs in.
4447 let target = branches
4448 .iter()
4449 .find(|name| **name == "refs/heads/main")
4450 .or_else(|| branches.iter().find(|name| **name == "refs/heads/master"))
4451 .or_else(|| branches.first())
4452 .copied();
4453 if let Some(target) = target {
4454 let _ = std::process::Command::new("git")
4455 .args(["symbolic-ref", "HEAD", target])
4456 .current_dir(repo)
4457 .status();
4458 }
4459}
4460
4461/// Checks a request's basic-auth credentials against the table; returns
4462/// the authenticated username.
4463fn authorized(table: &AuthTable, request: &tiny_http::Request) -> Option<String> {
4464 let (user, token) = basic_auth(request)?;
4465 let expected = table.get(&user)?;
4466 constant_time_eq(expected.as_bytes(), token.as_bytes()).then_some(user)
4467}
4468
4469/// Compares two secrets without exiting early on length or content, so
4470/// timing does not leak how much of one matched the other.
4471///
4472/// Extracted when session tokens became a second thing worth comparing
4473/// this way. Written as one function rather than two loops because the
4474/// property is easy to state and easy to lose: an early `return false`
4475/// added later for readability would be invisible in review and would
4476/// undo it.
4477fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
4478 let mut diff = a.len() ^ b.len();
4479 for i in 0..a.len().min(b.len()) {
4480 diff |= (a[i] ^ b[i]) as usize;
4481 }
4482 diff == 0
4483}
4484
4485/// Identifies a request: an operator credential from the auth file, or a
4486/// self-service account or invite from the store (D36).
4487///
4488/// The file is consulted first, so a name the operator wrote by hand can
4489/// never be shadowed by an issued one. The store refuses to issue those
4490/// names in the first place; checking in this order means the property
4491/// does not depend on that refusal alone.
4492fn authenticate(
4493 table: &AuthTable,
4494 accounts: Option<&accounts::Accounts>,
4495 request: &tiny_http::Request,
4496) -> Option<accounts::Principal> {
4497 if let Some(user) = authorized(table, request) {
4498 return Some(accounts::Principal::Account(user));
4499 }
4500 let (user, secret) = basic_auth(request)?;
4501 accounts?.authenticate(&user, &secret)
4502}
4503
4504/// The `user` and `secret` halves of a basic-auth header, undecided.
4505fn basic_auth(request: &tiny_http::Request) -> Option<(String, String)> {
4506 let auth_header = header(request, "Authorization")?;
4507 let b64 = auth_header.strip_prefix("Basic ")?.trim();
4508 let creds = String::from_utf8(base64_decode(b64)?).ok()?;
4509 let (user, secret) = creds.split_once(':')?;
4510 Some((user.to_string(), secret.to_string()))
4511}
4512
4513/// Serves `POST /api/prepare`: the bytes of one op, for a browser that
4514/// is about to sign them with a passkey (D39).
4515///
4516/// The author is always the authenticated caller and never a name in the
4517/// body, the same rule passkey enrolment follows. A body that could
4518/// choose its author would let this endpoint mint a payload claiming
4519/// somebody else — refused at admission by the view's own author check,
4520/// but only after the node had helpfully built it.
4521///
4522/// Only comments come through here. A verdict is fully determined by the
4523/// review and the button, so it is rendered into the page; a comment
4524/// carries text nobody has typed yet, which is the whole reason this
4525/// round trip exists.
4526fn handle_prepare(
4527 user: &str,
4528 acl: Option<&acl::Effective>,
4529 body_limit: std::num::NonZeroU64,
4530 mut request: tiny_http::Request,
4531) -> std::io::Result<(u16, u64)> {
4532 let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
4533 quota::Body::Complete(body) => body,
4534 quota::Body::OverLimit { limit, size } => {
4535 return respond_api_too_large(request, limit, size)
4536 }
4537 };
4538 let method = request.method().as_str().to_string();
4539 let path = request.url().split('?').next().unwrap_or("").to_string();
4540 let (status, body) = match acl {
4541 Some(table) => match acl::api_denial(table, user, &method, &path, &req_body, |_| None) {
4542 Some(denial) => (
4543 denial.status,
4544 serde_json::json!({ "error": denial.reason }).to_string(),
4545 ),
4546 None => prepare_body(user, &req_body),
4547 },
4548 None => prepare_body(user, &req_body),
4549 };
4550 let bytes = body.len() as u64;
4551 let response = tiny_http::Response::from_string(body)
4552 .with_status_code(status)
4553 .with_header(
4554 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
4555 .expect("static header"),
4556 );
4557 served(request, response, status, bytes)
4558}
4559
4560/// The body half of [`handle_prepare`], split out so the authorization
4561/// half above has one shape regardless of whether a table exists.
4562fn prepare_body(user: &str, req_body: &[u8]) -> (u16, String) {
4563 let Ok(json) = serde_json::from_slice::<serde_json::Value>(req_body) else {
4564 return (400, r#"{"error":"body must be JSON"}"#.to_string());
4565 };
4566 let field = |name: &str| {
4567 json.get(name)
4568 .and_then(serde_json::Value::as_str)
4569 .map(str::trim)
4570 .filter(|s| !s.is_empty())
4571 };
4572 if field("kind") != Some("comment") {
4573 return (
4574 400,
4575 r#"{"error":"`kind` must be \"comment\"; a verdict is prepared in the page"}"#
4576 .to_string(),
4577 );
4578 }
4579 let (Some(id), Some(text)) = (field("id"), field("body")) else {
4580 return (
4581 400,
4582 r#"{"error":"`id` (the review) and `body` (the comment) are required"}"#.to_string(),
4583 );
4584 };
4585 if text.chars().count() > MAX_COMMENT_CHARS {
4586 return (
4587 400,
4588 serde_json::json!({
4589 "error": format!("a comment is at most {MAX_COMMENT_CHARS} characters"),
4590 })
4591 .to_string(),
4592 );
4593 }
4594 // Minted here, not taken from the body: the comment id is the
4595 // author's retry identity, so a browser that submits one prepared
4596 // payload twice must hit the refusal that identity exists to give.
4597 let comment_id = choir_identity::ActorKey::generate().actor_id().to_hex();
4598 let prepared = prepare::comment(id, user, &comment_id, text);
4599 (
4600 200,
4601 serde_json::json!({
4602 "payload_hex": prepared.payload_hex,
4603 "challenge": prepared.challenge,
4604 "comment": comment_id,
4605 "channel": user,
4606 })
4607 .to_string(),
4608 )
4609}
4610
4611/// Longest comment the prepare endpoint will build. A review thread is
4612/// discussion, and the op log keeps every byte of it forever.
4613const MAX_COMMENT_CHARS: usize = 4096;
4614
4615/// Serves the operator's console (D72), both the page and the three
4616/// actions on it.
4617///
4618/// One function for `GET` and `POST` because they are one surface: the
4619/// forms post back to the address that rendered them, which is what keeps
4620/// the page and its actions from drifting into disagreeing about which
4621/// grants exist.
4622///
4623/// Gated at `@node write`, the same authority
4624/// [`crate::acl::api_denial`] requires to mint an invite through the API.
4625/// It has to be: every button here is one of those calls.
4626fn respond_people(
4627 mut request: tiny_http::Request,
4628 store: Option<&accounts::Accounts>,
4629 acl: Option<&acl::Effective>,
4630 user: &str,
4631 root: &std::path::Path,
4632 scheme: &'static str,
4633 body_limit: std::num::NonZeroU64,
4634) -> std::io::Result<(u16, u64)> {
4635 let docs = docs_url(root);
4636 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4637 let chrome = browse::Chrome {
4638 site: None,
4639 theme: chosen_theme(&request),
4640 here: "/people",
4641 origin: origin.as_deref(),
4642 account: true,
4643 // Reaching this page at all means holding `@node write`, which is
4644 // what the console link is gated on — the refusal below is the
4645 // path for everybody else.
4646 console: true,
4647 docs: docs.as_deref(),
4648 signed_in: Some(true),
4649 };
4650 let Some(store) = store else {
4651 let html = ui::refusal(
4652 "Account self-service is off",
4653 503,
4654 &ui::Refusal {
4655 code: "accounts_disabled",
4656 error: "This node was started without an accounts file.",
4657 expected: Some("a node that issues credentials"),
4658 actual: Some("the operator's console"),
4659 next: "Start the daemon with --accounts-file to issue and answer invites here.",
4660 },
4661 &[],
4662 reader_chrome(&request),
4663 );
4664 return respond_page(request, 503, html, None);
4665 };
4666 let denial = acl.and_then(|table| table.check(user, &acl::Scope::Node, acl::Level::Write));
4667 if let Some(denial) = denial {
4668 let html = ui::refusal(
4669 "Not yours to see",
4670 denial.status,
4671 &ui::Refusal {
4672 code: "forbidden",
4673 error: "This page shows and changes who may reach this node.",
4674 expected: Some("@node write"),
4675 actual: Some("the grants you hold"),
4676 next: "Ask the operator, who is whoever holds the node's own credential.",
4677 },
4678 &[],
4679 reader_chrome(&request),
4680 );
4681 return respond_page(request, denial.status, html, None);
4682 }
4683 // What an operator may grant is what they may read, which for a
4684 // `@node write` holder is everything on the node.
4685 let repos = browse::repositories(root, &|_| true);
4686
4687 if request.method().as_str() != "POST" {
4688 let said = match request.url().split_once("?said=") {
4689 Some((_, code)) => said_in_words(code.split('&').next().unwrap_or("")),
4690 None => "",
4691 };
4692 let page = people_page::render(
4693 store,
4694 &repos,
4695 operator_contact(root).as_deref(),
4696 said,
4697 chrome,
4698 );
4699 return respond_console(request, page);
4700 }
4701 if !same_origin(&request, scheme) {
4702 let html = ui::refusal(
4703 "Cross-origin write refused",
4704 403,
4705 &ui::Refusal {
4706 code: "cross_origin",
4707 error: "That form was submitted from another site.",
4708 expected: Some("a form on this node"),
4709 actual: Some("a form somewhere else"),
4710 next: "Open this node's own page and try again.",
4711 },
4712 &[],
4713 reader_chrome(&request),
4714 );
4715 return respond_page(request, 403, html, None);
4716 }
4717 let body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
4718 quota::Body::Complete(body) => String::from_utf8_lossy(&body).into_owned(),
4719 quota::Body::OverLimit { limit, size } => {
4720 return respond_api_too_large(request, limit, size)
4721 }
4722 };
4723 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4724 let field = |key: &str| join_page::form_value(&body, key);
4725 // `.git` is how the ACL spells a repository, and leaving it off is
4726 // the mistake that grants nothing at all. Added here rather than
4727 // refused: the select offers repository names, and there is one right
4728 // answer.
4729 let grant = || {
4730 let repo = field("repo")?;
4731 let level = field("level")?;
4732 Some(format!("{repo}.git {level}"))
4733 };
4734 match field("action").as_deref() {
4735 Some("grant") => {
4736 let (Some(id), Some(grant)) = (field("request_id"), grant()) else {
4737 return respond_people_result(request, "bad");
4738 };
4739 let (status, _) = store.grant_request(
4740 user,
4741 &serde_json::json!({ "request_id": id, "grants": [grant] }),
4742 );
4743 respond_people_result(request, if status == 200 { "granted" } else { "bad" })
4744 }
4745 Some("contact") => {
4746 // Absent rather than empty is how the form spells "clear
4747 // it", since `form_value` refuses a blank value.
4748 let value = field("contact").unwrap_or_default();
4749 match write_operator_contact(root, &value) {
4750 Ok(()) => respond_people_result(request, "contact"),
4751 Err(_) => respond_people_result(request, "bad"),
4752 }
4753 }
4754 Some("decline") => {
4755 let Some(id) = field("request_id") else {
4756 return respond_people_result(request, "bad");
4757 };
4758 let (status, _) = store.decline_request(&serde_json::json!({ "request_id": id }));
4759 respond_people_result(request, if status == 200 { "declined" } else { "bad" })
4760 }
4761 Some("invite") => {
4762 let Some(grant) = grant() else {
4763 return respond_people_result(request, "bad");
4764 };
4765 // A readable name is optional (D75): the seat stays open
4766 // either way, and the person redeeming picks the username.
4767 let name = field("display_name");
4768 let mut body = serde_json::json!({ "grants": [grant] });
4769 if let Some(name) = name.as_deref() {
4770 body["display_name"] = serde_json::json!(name);
4771 }
4772 let (status, answer) =
4773 with_join_url(store.invite(user, &body), origin.as_deref(), "invite");
4774 let parsed: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
4775 match (status, parsed["join_url"].as_str()) {
4776 (200, Some(link)) => {
4777 let page = people_page::minted(
4778 link,
4779 name.as_deref().unwrap_or("whoever opens it"),
4780 chrome,
4781 );
4782 respond_console(request, page)
4783 }
4784 _ => respond_people_result(request, "bad"),
4785 }
4786 }
4787 _ => respond_people_result(request, "bad"),
4788 }
4789}
4790
4791/// The outcome of a console action, as a sentence.
4792///
4793/// Chosen from a fixed set here rather than echoed out of the query
4794/// string, so the page cannot be made to say anything by a link somebody
4795/// sends an operator.
4796fn said_in_words(code: &str) -> &'static str {
4797 match code {
4798 "granted" => "Let in. The link they already hold now works.",
4799 "declined" => "Declined. Their link says only that it is not valid.",
4800 "contact" => "Saved. The front page offers it to anybody who arrives with nothing.",
4801 "bad" => "That did not work. Nothing changed.",
4802 _ => "",
4803 }
4804}
4805
4806/// Post/redirect/get for a console action, so a refresh does not repeat it.
4807fn respond_people_result(request: tiny_http::Request, said: &str) -> std::io::Result<(u16, u64)> {
4808 let location = format!("/people?said={said}");
4809 let response = tiny_http::Response::empty(303)
4810 .with_header(
4811 tiny_http::Header::from_bytes(&b"Location"[..], location.as_bytes())
4812 .expect("location header"),
4813 )
4814 .with_header(
4815 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..])
4816 .expect("static header"),
4817 );
4818 served(request, response, 303, 0)
4819}
4820
4821/// Sends a console page with the headers every browser surface carries.
4822///
4823/// [`BROWSER_CSP`], because this page draws the bar. D81's rule is that
4824/// the search box and the key that opens it ship together, and the
4825/// console renders the same bar every browse page does. Its own
4826/// controls are still plain forms that post -- nothing here depends on
4827/// script, and `people_page` asserts that -- but the document loads the
4828/// palette, so the header has to license it.
4829fn respond_console(
4830 request: tiny_http::Request,
4831 page: people_page::Page,
4832) -> std::io::Result<(u16, u64)> {
4833 let bytes = page.html.len() as u64;
4834 let response = tiny_http::Response::from_string(page.html)
4835 .with_status_code(page.status)
4836 .with_header(
4837 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
4838 .expect("static header"),
4839 )
4840 .with_header(
4841 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-store"[..])
4842 .expect("static header"),
4843 )
4844 .with_header(
4845 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
4846 .expect("static header"),
4847 )
4848 .with_header(
4849 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
4850 .expect("static header"),
4851 );
4852 served(request, response, page.status, bytes)
4853}
4854
4855/// The operator's contact, for the one page a stranger can reach.
4856///
4857/// One line of `<root>/.choir/contact`, absent by default, and
4858/// deliberately not a compiled-in constant: a personal identifier baked
4859/// into a published binary cannot be taken back out of the copies of it,
4860/// which is the same reason host addresses and owner names are
4861/// placeholders in every tracked file here.
4862///
4863/// Read per request rather than once at startup. That was the other way
4864/// round until the console could edit it (D72), and a value an operator
4865/// can change from a page but only see take effect after a restart is a
4866/// control that appears not to work. The read is one small file on a
4867/// route that is already rendering several kilobytes, behind the same
4868/// public limiter as the rest of the pre-auth surface.
4869fn operator_contact(root: &std::path::Path) -> Option<String> {
4870 std::fs::read_to_string(root.join(".choir/contact"))
4871 .ok()
4872 .and_then(|text| text.lines().next().map(str::trim).map(str::to_string))
4873 .filter(|line| !line.is_empty())
4874}
4875
4876/// Where [`operator_contact`] reads from.
4877fn contact_path(root: &std::path::Path) -> std::path::PathBuf {
4878 root.join(".choir/contact")
4879}
4880
4881/// Where this node's book is published, if its operator has said (D76).
4882///
4883/// One line of `<root>/.choir/docs-url`, absent by default, and read per
4884/// request for the same two reasons [`operator_contact`] is: a host
4885/// address compiled into a published binary cannot be taken back out of
4886/// the copies of it, and a value an operator can set but only see take
4887/// effect after a restart is a control that appears not to work.
4888///
4889/// Only an absolute `https://` or `http://` address is accepted. The
4890/// value reaches an `href` on a page that answers anybody, so a relative
4891/// string here would silently become a path on this node, and a
4892/// `javascript:` one would be exactly the injection the rest of this
4893/// surface is written to refuse.
4894fn docs_url(root: &std::path::Path) -> Option<String> {
4895 std::fs::read_to_string(root.join(".choir/docs-url"))
4896 .ok()
4897 .and_then(|text| text.lines().next().map(str::trim).map(str::to_string))
4898 .filter(|line| line.starts_with("https://") || line.starts_with("http://"))
4899 .filter(|line| !line.chars().any(char::is_control))
4900}
4901
4902/// Sets or clears the operator's contact from the console (D72).
4903///
4904/// Written with the private-file primitive the rest of this node's own
4905/// state uses, and validated first: the string lands on the page that
4906/// answers anybody, so a control character in it would be a header or a
4907/// second line in a file whose grammar is one line.
4908///
4909/// An empty submission removes the file rather than writing an empty
4910/// one, because "no contact" and "a contact that is nothing" render the
4911/// same and only one of them is a state somebody meant.
4912fn write_operator_contact(root: &std::path::Path, value: &str) -> Result<(), String> {
4913 let value = value.trim();
4914 let path = contact_path(root);
4915 if value.is_empty() {
4916 return match std::fs::remove_file(&path) {
4917 Ok(()) => Ok(()),
4918 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
4919 Err(e) => Err(format!("{}: {e}", path.display())),
4920 };
4921 }
4922 if value.chars().count() > 200 {
4923 return Err("a contact must be at most 200 characters".to_string());
4924 }
4925 if value.chars().any(char::is_control) {
4926 return Err(
4927 "a contact must be one line and must not contain control characters".to_string(),
4928 );
4929 }
4930 if let Some(parent) = path.parent() {
4931 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
4932 }
4933 choir_fs::write_atomic_private(&path, format!("{value}\n").as_bytes())
4934 .map_err(|e| format!("{}: {e}", path.display()))
4935}
4936
4937/// Mints the token an account uses for git and the CLI (D75).
4938///
4939/// `POST` only, same-origin only, and rendered directly rather than
4940/// redirected to: the node keeps only a hash, so a redirect would drop
4941/// the one copy of the secret that exists.
4942fn respond_account_token(
4943 request: tiny_http::Request,
4944 store: Option<&accounts::Accounts>,
4945 user: &str,
4946 acl: Option<&acl::Effective>,
4947 root: &std::path::Path,
4948 scheme: &'static str,
4949) -> std::io::Result<(u16, u64)> {
4950 let docs = docs_url(root);
4951 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
4952 let chrome = browse::Chrome {
4953 site: None,
4954 theme: chosen_theme(&request),
4955 here: "/account",
4956 origin: origin.as_deref(),
4957 account: true,
4958 console: acl.is_some_and(|table| {
4959 table
4960 .check(user, &acl::Scope::Node, acl::Level::Write)
4961 .is_none()
4962 }),
4963 docs: docs.as_deref(),
4964 signed_in: Some(true),
4965 };
4966 let refuse = |request, title: &str, status: u16, reason: &'static str, next: &'static str| {
4967 let html = ui::refusal(
4968 title,
4969 status,
4970 &ui::Refusal {
4971 code: "token",
4972 error: reason,
4973 expected: None,
4974 actual: None,
4975 next,
4976 },
4977 &[],
4978 reader_chrome(&request),
4979 );
4980 respond_page(request, status, html, None)
4981 };
4982 if request.method().as_str() != "POST" {
4983 return refuse(
4984 request,
4985 "Not a page",
4986 405,
4987 "A token is made by pressing the button on your account page.",
4988 "Open /account and use the form there.",
4989 );
4990 }
4991 if !same_origin(&request, scheme) {
4992 return refuse(
4993 request,
4994 "Cross-origin write refused",
4995 403,
4996 "That form was submitted from another site.",
4997 "Open this node's own account page and try again.",
4998 );
4999 }
5000 let Some(store) = store else {
5001 return refuse(
5002 request,
5003 "Account self-service is off",
5004 503,
5005 "This node was started without an accounts file.",
5006 "Ask the operator to start the daemon with --accounts-file.",
5007 );
5008 };
5009 let (status, answer) = store.mint_token(user);
5010 let parsed: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
5011 let Some(token) = parsed["token"].as_str() else {
5012 return refuse(
5013 request,
5014 "No token for this credential",
5015 status,
5016 "Only an issued account can hold a token, and yours is not one.",
5017 "An operator credential from the auth file already is a password; use it.",
5018 );
5019 };
5020 let _ = acl;
5021 let node = header(&request, "host").map(|host| format!("{scheme}://{host}"));
5022 let page = account_page::minted(
5023 token,
5024 user,
5025 node.as_deref(),
5026 parsed["replaced"].as_bool().unwrap_or(false),
5027 chrome,
5028 );
5029 let bytes = page.html.len() as u64;
5030 let response = tiny_http::Response::from_string(page.html)
5031 .with_status_code(page.status)
5032 .with_header(
5033 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
5034 .expect("static header"),
5035 )
5036 .with_header(
5037 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-store"[..])
5038 .expect("static header"),
5039 )
5040 .with_header(
5041 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
5042 .expect("static header"),
5043 )
5044 .with_header(
5045 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
5046 .expect("static header"),
5047 );
5048 served(request, response, page.status, bytes)
5049}
5050
5051/// Whether a state-changing request came from this node's own pages.
5052///
5053/// **Absent means yes.** A browser sends `Origin` on every `POST`; a
5054/// program does not, and `curl`, `choirctl` and every test in this
5055/// workspace are programs. So a missing header is a non-browser client
5056/// and passes, while a header naming somewhere else is a page on another
5057/// site steering a browser that still holds a credential for this one.
5058///
5059/// It matters because of what a browser sends unasked. The session
5060/// cookie is `SameSite=Lax` and so never rides a cross-site `POST`, but
5061/// cached Basic credentials do -- an operator who authenticated with the
5062/// auth file once has a browser that will re-present it to any page that
5063/// asks. Without this check, a link could mint an invite in their name.
5064fn same_origin(request: &tiny_http::Request, scheme: &'static str) -> bool {
5065 origin_mismatch(request, scheme).is_none()
5066}
5067
5068/// The two sides of a failed [`same_origin`], as the reader should see
5069/// them: what the node required, and what it was sent.
5070///
5071/// `None` when the request is same-origin, so a caller reads it as the
5072/// refusal itself rather than as a detail beside one.
5073///
5074/// Both halves are the node's own address and the browser's own address.
5075/// Neither is a secret, and withholding them was not buying anything: the
5076/// page said "a form somewhere else" for a request that came from this
5077/// node's own sign-in page over the wrong scheme, which sends the reader
5078/// looking for an attacker instead of at their address bar. A refusal
5079/// that cannot be acted on is a refusal that gets reported as a bug.
5080fn origin_mismatch(request: &tiny_http::Request, scheme: &'static str) -> Option<(String, String)> {
5081 let origin = header(request, "origin")?;
5082 match header(request, "host") {
5083 Some(host) => {
5084 let expected = format!("{scheme}://{host}");
5085 (origin != expected).then_some((expected, origin))
5086 }
5087 // No `Host` and an `Origin` that claims one: nothing to compare
5088 // against, so refuse rather than guess.
5089 None => Some(("this node's own address".to_string(), origin)),
5090 }
5091}
5092
5093/// Serves one `/api/accounts...` request (D36).
5094///
5095/// The ACL decides who may issue, revoke and read the roster, through the
5096/// same [`acl::api_denial`] table every other endpoint goes through. Two
5097/// things are checked here instead, because neither is a grant: that the
5098/// node has a store at all, and that redemption is being performed by the
5099/// invite it names rather than by somebody who merely holds a credential.
5100/// Adds the one-click join link to a freshly minted invite (D57).
5101///
5102/// The operator's own reason for this: the store answers with an
5103/// `id:secret` pair shaped for `curl -u`, which is right for a script and
5104/// is not something anybody pastes into a chat window. The link is the
5105/// artefact that actually gets sent to a person, so the endpoint that
5106/// mints the invite is the place to build it.
5107///
5108/// A node that was reached without a `Host` header gets no link rather
5109/// than a guessed one, on the same reasoning as `browse::node_url`: an
5110/// operator who pastes a wrong address has a mystery, and one who finds
5111/// no link goes and looks.
5112///
5113/// Anything that is not a successful mint passes straight through: an
5114/// error body has no invite in it to link to.
5115///
5116/// `field` names the member holding the `id:secret` pair, because D72's
5117/// access request answers with the same two halves under a different name
5118/// and lands on the same page. One builder rather than two: the link
5119/// grammar is `/join?i=&k=` in exactly one place, and a second copy is
5120/// how the two would come to disagree.
5121fn with_join_url(answer: (u16, String), origin: Option<&str>, field: &str) -> (u16, String) {
5122 let (status, body) = answer;
5123 if status != 200 {
5124 return (status, body);
5125 }
5126 let (Some(origin), Ok(mut parsed)) = (origin, serde_json::from_str::<serde_json::Value>(&body))
5127 else {
5128 return (status, body);
5129 };
5130 let Some((id, secret)) = parsed[field].as_str().and_then(|p| p.split_once(':')) else {
5131 return (status, body);
5132 };
5133 let url = format!("{origin}/join?i={id}&k={secret}");
5134 parsed["join_url"] = serde_json::Value::String(url);
5135 (status, parsed.to_string())
5136}
5137
5138/// The self-service surface a request is answered against: the store, and
5139/// whether passkeys are switched on (D71).
5140///
5141/// One argument rather than two because they are one decision. Passing
5142/// them separately is what let the store's presence stand in for the
5143/// passkey answer in the first place.
5144struct SelfService<'a> {
5145 store: Option<&'a accounts::Accounts>,
5146 passkeys: bool,
5147}
5148
5149fn handle_accounts(
5150 self_service: SelfService<'_>,
5151 user: &str,
5152 invite: Option<&str>,
5153 acl: Option<&acl::Effective>,
5154 body_limit: std::num::NonZeroU64,
5155 scheme: &'static str,
5156 mut request: tiny_http::Request,
5157) -> std::io::Result<(u16, u64)> {
5158 let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
5159 quota::Body::Complete(body) => body,
5160 quota::Body::OverLimit { limit, size } => {
5161 return respond_api_too_large(request, limit, size)
5162 }
5163 };
5164 let method = request.method().as_str().to_string();
5165 let path = request.url().split('?').next().unwrap_or("").to_string();
5166 // Issuing and revoking are the two calls on this node a hostile page
5167 // would most like to make with somebody else's cached credential.
5168 if method == "POST" && !same_origin(&request, scheme) {
5169 return respond_json(request, 403, r#"{"error":"cross-origin write refused"}"#);
5170 }
5171 // The address this operator actually reached the node on. The store
5172 // cannot know it — it has never seen a request — so the link is
5173 // assembled here, where the `Host` header is.
5174 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
5175 let SelfService { store, passkeys } = self_service;
5176 let (status, body) = match (store, acl) {
5177 (None, _) => (
5178 503,
5179 r#"{"error":"account self-service is not enabled on this node"}"#.to_string(),
5180 ),
5181 // Unreachable through the daemon, which refuses `--accounts-file`
5182 // without `--acl-file`. Fail closed anyway rather than assume the
5183 // only caller stays the only caller.
5184 (_, None) => (
5185 403,
5186 r#"{"error":"account self-service needs an ACL"}"#.to_string(),
5187 ),
5188 (Some(store), Some(table)) => {
5189 let denial = acl::api_denial(table, user, &method, &path, &req_body, |_| None);
5190 if let Some(denial) = denial {
5191 (
5192 denial.status,
5193 serde_json::json!({ "error": denial.reason }).to_string(),
5194 )
5195 } else {
5196 let json = if req_body.iter().all(u8::is_ascii_whitespace) {
5197 Some(serde_json::Value::Null)
5198 } else {
5199 serde_json::from_slice::<serde_json::Value>(&req_body).ok()
5200 };
5201 match (json, method.as_str(), path.as_str()) {
5202 (None, _, _) => (400, r#"{"error":"body must be JSON"}"#.to_string()),
5203 (Some(json), "POST", "/api/accounts/invite") => {
5204 with_join_url(store.invite(user, &json), origin.as_deref(), "invite")
5205 }
5206 (Some(json), "POST", "/api/accounts/redeem") => match invite {
5207 Some(id) => store.redeem(id, &json),
5208 None => (
5209 403,
5210 r#"{"error":"redeem an invite by presenting it as the credential"}"#
5211 .to_string(),
5212 ),
5213 },
5214 (Some(json), "POST", "/api/accounts/revoke") => store.revoke(&json),
5215 (Some(json), "POST", "/api/accounts/request/grant") => {
5216 store.grant_request(user, &json)
5217 }
5218 (Some(json), "POST", "/api/accounts/request/decline") => {
5219 store.decline_request(&json)
5220 }
5221 (Some(_), "POST", "/api/accounts/passkey" | "/api/accounts/passkey/remove")
5222 if !passkeys =>
5223 {
5224 (
5225 503,
5226 r#"{"error":"passkeys are not enabled on this node"}"#.to_string(),
5227 )
5228 }
5229 (Some(json), "POST", "/api/accounts/passkey") => {
5230 store.enroll_passkey(user, &json)
5231 }
5232 (Some(json), "POST", "/api/accounts/passkey/remove") => {
5233 store.remove_passkey(user, &json)
5234 }
5235 (Some(_), "GET", "/api/accounts") => (200, store.list_json().to_string()),
5236 _ => (404, r#"{"error":"no such endpoint"}"#.to_string()),
5237 }
5238 }
5239 }
5240 };
5241 let bytes = body.len() as u64;
5242 let response = tiny_http::Response::from_string(body)
5243 .with_status_code(status)
5244 .with_header(
5245 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5246 .expect("static header"),
5247 );
5248 served(request, response, status, bytes)
5249}
5250
5251/// Encodes bytes as standard base64 with `=` padding.
5252pub(crate) fn base64_encode(bytes: &[u8]) -> String {
5253 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5254 let mut out = String::new();
5255 for chunk in bytes.chunks(3) {
5256 let b = [
5257 chunk[0],
5258 *chunk.get(1).unwrap_or(&0),
5259 *chunk.get(2).unwrap_or(&0),
5260 ];
5261 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
5262 out.push(ALPHABET[(n >> 18) as usize & 63] as char);
5263 out.push(ALPHABET[(n >> 12) as usize & 63] as char);
5264 out.push(if chunk.len() > 1 {
5265 ALPHABET[(n >> 6) as usize & 63] as char
5266 } else {
5267 '='
5268 });
5269 out.push(if chunk.len() > 2 {
5270 ALPHABET[n as usize & 63] as char
5271 } else {
5272 '='
5273 });
5274 }
5275 out
5276}
5277
5278/// Renders a raw ed25519 public key in OpenSSH `ssh-ed25519 <b64>` form
5279/// (the wire blob is two length-prefixed strings: key type, key bytes).
5280pub fn ssh_ed25519_pubkey(raw: &[u8; 32]) -> String {
5281 let mut blob = Vec::new();
5282 for part in [b"ssh-ed25519".as_slice(), raw.as_slice()] {
5283 blob.extend_from_slice(&(part.len() as u32).to_be_bytes());
5284 blob.extend_from_slice(part);
5285 }
5286 format!("ssh-ed25519 {}", base64_encode(&blob))
5287}
5288
5289/// One line of the trusted-keys file: a public key the node accepts, and
5290/// optionally the channel name its holder is allowed to speak as.
5291#[derive(Debug, Clone, PartialEq, Eq)]
5292pub struct TrustedKey {
5293 /// Operator-assigned channel name this key may act as, when the line
5294 /// carries one. `None` = key trusted, name unconstrained (the
5295 /// pre-existing behaviour, and what a bare hex line still means).
5296 pub name: Option<String>,
5297 /// Actor id (hex): the git push principal, and the `key_id` a
5298 /// signature carries. **Always derived from the key**, never from the
5299 /// name — so adding a name column cannot shift push attribution.
5300 pub actor_id: String,
5301 /// The raw ed25519 public key.
5302 pub key: [u8; 32],
5303}
5304
5305/// Parses a trusted-keys file. One key per line, `#` comments and blank
5306/// lines skipped, in either form:
5307///
5308/// ```text
5309/// <64-char hex> # trusted, speaks as any channel
5310/// <name> <64-char hex> # trusted, bound to that channel name
5311/// ```
5312///
5313/// The name column is additive: files written before it existed parse
5314/// unchanged, and a key with no name keeps exactly its old permissions.
5315/// Binding is opt-in per key, so adding one line cannot lock anybody
5316/// else out.
5317///
5318/// # Errors
5319///
5320/// Filesystem failures, and [`std::io::ErrorKind::InvalidData`] for a
5321/// line that is not a valid ed25519 public key, or that binds one name
5322/// to two keys. An invalid line fails the whole parse: a partial signer
5323/// list would silently drop the ability to verify somebody's pushes.
5324pub fn parse_keys_file(path: &Path) -> std::io::Result<Vec<TrustedKey>> {
5325 let invalid = |msg: String| std::io::Error::new(std::io::ErrorKind::InvalidData, msg);
5326 let mut registry = choir_identity::Registry::new();
5327 let mut out: Vec<TrustedKey> = Vec::new();
5328 for line in std::fs::read_to_string(path)?.lines() {
5329 let line = line.trim();
5330 if line.is_empty() || line.starts_with('#') {
5331 continue;
5332 }
5333 let (name, hex) = match line.rsplit_once(char::is_whitespace) {
5334 Some((name, hex)) => (Some(name.trim().to_string()), hex),
5335 None => (None, line),
5336 };
5337 let bytes = platform::hex_decode(hex)
5338 .filter(|b| b.len() == 32)
5339 .ok_or_else(|| {
5340 invalid("keys file lines are `<hex>` or `<name> <hex>`, 64 hex chars".to_string())
5341 })?;
5342 // One name, one key. Two keys sharing a name would make the
5343 // binding meaningless in exactly the direction it exists to
5344 // prevent: either holder could speak as that channel.
5345 if let Some(name) = &name {
5346 if out.iter().any(|k| k.name.as_ref() == Some(name)) {
5347 return Err(invalid(format!(
5348 "keys file binds {name:?} to more than one key"
5349 )));
5350 }
5351 }
5352 let mut key = [0u8; 32];
5353 key.copy_from_slice(&bytes);
5354 let actor_id = registry
5355 .register(&key)
5356 .map_err(|e| invalid(format!("{e:?}")))?;
5357 out.push(TrustedKey {
5358 name,
5359 actor_id: actor_id.to_hex(),
5360 key,
5361 });
5362 }
5363 Ok(out)
5364}
5365
5366/// Writes `<root>/.choir/allowed_signers` — the file git's ssh signature
5367/// verification checks push certificates against. One line per actor:
5368/// principal (the actor id) followed by the OpenSSH public key.
5369///
5370/// # Errors
5371///
5372/// Propagates filesystem failures.
5373pub fn write_allowed_signers(root: &Path, keys: &[TrustedKey]) -> std::io::Result<PathBuf> {
5374 let dir = root.join(".choir");
5375 std::fs::create_dir_all(&dir)?;
5376 let path = dir.join("allowed_signers");
5377 let mut contents = String::new();
5378 for k in keys {
5379 // Principal stays the actor id even when the line carries a name:
5380 // push attribution (`key/<principal>`) is a property of the key,
5381 // and rewriting it would silently reattribute pushes.
5382 contents.push_str(&format!("{} {}\n", k.actor_id, ssh_ed25519_pubkey(&k.key)));
5383 }
5384 std::fs::write(&path, contents)?;
5385 Ok(path)
5386}
5387
5388/// Decodes standard base64 (with `=` padding); `None` on any bad input.
5389pub(crate) fn base64_decode(input: &str) -> Option<Vec<u8>> {
5390 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
5391 let mut rev = [255u8; 256];
5392 for (i, &c) in ALPHABET.iter().enumerate() {
5393 rev[c as usize] = i as u8;
5394 }
5395 let input = input.trim_end_matches('=');
5396 let mut out = Vec::with_capacity(input.len() * 3 / 4);
5397 let mut buf = 0u32;
5398 let mut bits = 0u32;
5399 for c in input.bytes() {
5400 let v = rev[c as usize];
5401 if v == 255 {
5402 return None;
5403 }
5404 buf = (buf << 6) | u32::from(v);
5405 bits += 6;
5406 if bits >= 8 {
5407 bits -= 8;
5408 out.push((buf >> bits) as u8);
5409 }
5410 }
5411 Some(out)
5412}
5413
5414/// The agent-facing surface as plain text, generated from
5415/// `crates/choir-cli/src/surface.rs` and checked for staleness by
5416/// `choir-cli/tests/it/surface.rs`. Included rather than depended on: the
5417/// node has no business linking the CLI, and a generated file with a
5418/// staleness test is the cheaper coupling.
5419const LLMS_TXT: &str = include_str!("llms.txt");
5420
5421/// The machine-readable API description (D17), generated from
5422/// `choir-cli`'s surface table and checked for staleness by
5423/// `choir-cli/tests/it/surface.rs`.
5424///
5425/// Included rather than depended on, for the reason `llms.txt` already
5426/// is: the node has no business linking the CLI, and a generated file
5427/// with a staleness test is the cheaper coupling.
5428const SCHEMA_JSON: &str = include_str!("schema.json");
5429
5430/// [`SCHEMA_JSON`] with a `capabilities` object describing what this
5431/// particular node will accept (D17).
5432///
5433/// The row calls for "versioned API + deprecation policy + capability
5434/// negotiation", and the three parts land in different places on
5435/// purpose. The version and the deprecations are properties of the API
5436/// and are generated. The capabilities are properties of *this
5437/// deployment* — whether it issues accounts, grades requests against an
5438/// ACL, or runs a sequencer at all — and change with the flags it was
5439/// started with. A client that reads only the committed file would build
5440/// against a node that does not exist.
5441///
5442/// Deliberately coarse: what a client can *branch on*, not the operator's
5443/// configuration. Whether review is required or a quota is set changes
5444/// which requests succeed, not which requests are well-formed, and the
5445/// node already answers those in its own words with a code and a repair
5446/// (`ERRORS.md`). Listing them here would invite a client to
5447/// pre-emptively refuse what the node would have explained.
5448fn schema_with_capabilities(accounts: bool, acl: bool, platform: bool) -> String {
5449 let mut doc: serde_json::Value =
5450 serde_json::from_str(SCHEMA_JSON).expect("the generated schema is JSON");
5451 doc["capabilities"] = serde_json::json!({
5452 // Credentials can be issued through the API rather than by hand.
5453 "accounts": accounts,
5454 // Requests are graded per repository, so a 404 may mean "not
5455 // granted" rather than "not here" — the distinction `llms.txt`
5456 // spells out and a client has to know before it retries.
5457 "acl": acl,
5458 // There is a sequencer behind this node, so signed operations
5459 // are admitted at all. Without one it serves git and nothing
5460 // else, and every `/api/submit` is refused for a reason no
5461 // amount of client-side correctness fixes.
5462 "platform": platform,
5463 });
5464 format!(
5465 "{}\n",
5466 serde_json::to_string_pretty(&doc).expect("the schema is always serializable")
5467 )
5468}
5469
5470/// The sync contract, served at `/sync.md`. Hand-authored, unlike
5471/// `llms.txt`, and included from the repository root so the served copy
5472/// and the committed one cannot disagree.
5473const SYNC_MD: &str = include_str!("../../../SYNC.md");
5474
5475/// Routes one `/api/...` request to the platform (503 when disabled).
5476/// Serves the browser page from the cache, or `304` when the client
5477/// already holds the current one.
5478///
5479/// The conditional check happens before the cache lookup and before
5480/// any rendering, so a reader polling an idle node costs one integer
5481/// comparison and an empty response. That is the whole reason the page
5482/// can be refreshed aggressively without the node noticing.
5483fn handle_ui(
5484 platform: Option<&Platform>,
5485 cache: &ui::UiCache,
5486 user: &str,
5487 acl: Option<&acl::Effective>,
5488 root: &std::path::Path,
5489 passkeys: bool,
5490 request: tiny_http::Request,
5491) -> std::io::Result<(u16, u64)> {
5492 let platform = match platform {
5493 Some(p) => p,
5494 None => {
5495 // A page, not a line of plain text: this is the node's front
5496 // door, so it is the first thing a person sees, and "there is
5497 // nothing to show" reads as a broken node rather than as a
5498 // node deliberately started without a sequencer.
5499 let html = ui::refusal(
5500 "This node has no view to show",
5501 503,
5502 &ui::Refusal {
5503 code: "platform_disabled",
5504 error: "This node serves git repositories, but its platform API is \
5505 switched off, so there is no op log, no sequencer and no \
5506 materialized view behind this page.",
5507 expected: Some("a node started with the platform API enabled"),
5508 actual: Some("a git-only node"),
5509 next: "Browse the repositories instead — the link above works, and \
5510 cloning and pushing work exactly as they always did. This page \
5511 fills in once the operator restarts the node with the platform \
5512 API on.",
5513 },
5514 &[("/r/", "repositories")],
5515 reader_chrome(&request),
5516 );
5517 return respond_page(request, 503, html, None);
5518 }
5519 };
5520
5521 // Without an ACL every reader sees one page, so the reader key is
5522 // empty and both the cache and the `ETag` behave exactly as they did
5523 // before D29 phase B. With one, the key carries the grants, so an
5524 // edit to the ACL file invalidates a browser's copy of the page as
5525 // surely as a new op does.
5526 let reader = acl.map(|table| table.cache_key(user)).unwrap_or_default();
5527 // The book's address is part of what this page renders, and it is a
5528 // file an operator edits while the node runs (D76) -- so it belongs
5529 // in the cache identity beside the grants, or the bar keeps the link
5530 // it had when the page was first rendered. Folded into the reader
5531 // key rather than added as a parameter to `etag` and `page`: both
5532 // already take this string precisely so that "what this reader is
5533 // shown" can grow without a third one.
5534 let docs = docs_url(root);
5535 let reader = match docs.as_deref() {
5536 Some(url) => format!("{reader}\u{1f}{url}"),
5537 None => reader,
5538 };
5539 // D80, for the same reason: a seed's header says how far behind its
5540 // home it is and whether it has stopped, and neither moves the view.
5541 let reader = match platform.replica_marker() {
5542 Some(marker) => format!("{reader}\u{1f}{marker}"),
5543 None => reader,
5544 };
5545 let seq = platform.view_seq();
5546 // Read once and used for both the tag and the render, so the page a
5547 // reader is handed resolved names against exactly the store state
5548 // its `ETag` claims (D46).
5549 let (generation, roster) = platform.roster();
5550 let chrome = browse::Chrome {
5551 site: None,
5552 theme: chosen_theme(&request),
5553 // Behind the auth gate and not an address anybody pastes into a
5554 // chat window, so it takes no preview and needs no scheme here.
5555 origin: None,
5556 here: "/status",
5557 account: passkeys,
5558 console: acl.is_some_and(|table| {
5559 table
5560 .check(user, &acl::Scope::Node, acl::Level::Write)
5561 .is_none()
5562 }),
5563 docs: docs.as_deref(),
5564 signed_in: Some(identified(user)),
5565 };
5566 let tag = ui::etag(seq, generation, &reader, chrome.theme);
5567 if header(&request, "If-None-Match").as_deref() == Some(tag.as_str()) {
5568 return served(request, not_modified(&tag, BROWSER_CSP), 304, 0);
5569 }
5570
5571 let page = cache.page(seq, generation, &reader, &roster, chrome, || {
5572 let body = platform.handle_api("GET", "/api/view", &[]).1;
5573 match acl {
5574 Some(table) => acl::filter_response(table, user, "/api/view", &body),
5575 None => body,
5576 }
5577 });
5578 let response = tiny_http::Response::from_string(page.as_str())
5579 .with_header(
5580 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
5581 .expect("static header"),
5582 )
5583 .with_header(
5584 tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
5585 )
5586 // The page is private per node and changes with every op; a
5587 // shared cache must never hold it, and a browser must ask.
5588 .with_header(
5589 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
5590 .expect("static header"),
5591 )
5592 // Defence in depth behind the escaper: even if a value slipped
5593 // through unescaped, the page may not run scripts, load
5594 // anything remote, or be framed by another origin.
5595 .with_header(
5596 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
5597 .expect("static header"),
5598 )
5599 .with_header(
5600 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
5601 .expect("static header"),
5602 );
5603 served(request, response, 200, page.len() as u64)
5604}
5605
5606/// Serves one repository-browsing page (D30).
5607///
5608/// Authorization is the same grant a clone needs, checked here rather
5609/// than in the renderer: a reader without `read` must be told the
5610/// repository does not exist, and a page cannot say that convincingly
5611/// after it has already started describing one.
5612struct BrowseContext<'a> {
5613 /// Where the bare repositories live.
5614 root: &'a Path,
5615 /// The authenticated reader, whose grants decide what renders.
5616 user: &'a str,
5617 /// The grant table, or `None` on a node that gates nothing.
5618 acl: Option<&'a acl::Effective>,
5619 /// The view, for the pages served from it rather than from disk.
5620 platform: Option<&'a Platform>,
5621 /// Whether the browser may offer mutation controls.
5622 browser_writes: bool,
5623 /// The one repository this node presents, if it presents one.
5624 site: Option<&'a str>,
5625 /// `http` or `https`, from how this node was started rather than
5626 /// from anything the request said: a client must not be able to talk
5627 /// the node into advertising `https` for a plaintext port.
5628 scheme: &'static str,
5629 /// Whether this node runs invite-based credential self-service
5630 /// (D36). A page that tells a newcomer to redeem an invite on a node
5631 /// that issues none is sending them to a command that cannot work.
5632 self_service: bool,
5633 /// Whether this node offers passkeys, which is what `/account`
5634 /// renders for. See [`browse::Chrome::account`].
5635 passkeys: bool,
5636}
5637
5638/// The `/api/view` body this caller may see.
5639///
5640/// One reading, used by `/api/profile` and by the profile page, because
5641/// the ACL narrowing lives here and a second copy of it is a second
5642/// thing to keep right -- the first time the two disagreed, one of the
5643/// two surfaces would be disclosing more than the other.
5644fn visible_view(platform: &Platform, acl: Option<&acl::Effective>, user: &str) -> String {
5645 let raw = platform.handle_api("GET", "/api/view", &[]).1;
5646 match acl {
5647 Some(table) => acl::filter_response(table, user, "/api/view", &raw),
5648 None => raw,
5649 }
5650}
5651
5652fn handle_browse(
5653 context: &BrowseContext,
5654 page: &browse::Page,
5655 request: tiny_http::Request,
5656) -> std::io::Result<(u16, u64)> {
5657 let &BrowseContext {
5658 root,
5659 user,
5660 acl,
5661 platform,
5662 browser_writes,
5663 site,
5664 scheme,
5665 self_service,
5666 passkeys,
5667 } = context;
5668 let readable = |repo: &str| match acl {
5669 Some(table) => table.allows_repo(user, repo, acl::Level::Read),
5670 None => true,
5671 };
5672 if let Some(repo) = page.repo() {
5673 if !readable(repo) {
5674 // The body lives in `browse` because a repository that does
5675 // not exist reaches the same refusal from inside the
5676 // renderer. Two constructions that agree today are a
5677 // coincidence with a test on it; one construction is the
5678 // property.
5679 let denied = browse::no_such_repository(reader_chrome(&request));
5680 return respond_page(request, denied.status, denied.html, None);
5681 }
5682 }
5683
5684 // A picture is answered here, after the grant and before anything
5685 // is rendered: it is the one address under `/r/` that is not a page.
5686 if let browse::Page::Raw { repo, rev, path } = page {
5687 return respond_raw(request, root, repo, rev, path);
5688 }
5689
5690 // The origin the reader actually reached this node on, so a page
5691 // that prints a command can print one they can paste. Scheme comes
5692 // from the connection rather than the request: a client cannot talk
5693 // this node into advertising `https` for a plaintext port.
5694 let origin = header(&request, "host").map(|host| format!("{scheme}://{host}"));
5695 let docs = docs_url(root);
5696 let rendered = browse::render(
5697 root,
5698 page,
5699 &readable,
5700 platform,
5701 browse::Viewer {
5702 user,
5703 browser_writes,
5704 site,
5705 origin: origin.as_deref(),
5706 theme: chosen_theme(&request),
5707 here: request.url().split(['?', '#']).next().unwrap_or("/"),
5708 self_service,
5709 account: passkeys,
5710 console: acl.is_some_and(|table| {
5711 table
5712 .check(user, &acl::Scope::Node, acl::Level::Write)
5713 .is_none()
5714 }),
5715 docs: docs.as_deref(),
5716 signed_in: Some(identified(user)),
5717 // The ACL answers "who owns this repository" for the one
5718 // sentence the review page states about what would land a
5719 // change. A node without one hands back an empty list,
5720 // which is the right input: nobody owns anything, so the
5721 // approval-weight rule is the one in force.
5722 owners: &|repo: &str| acl.map_or_else(Vec::new, |table| table.owners(repo)),
5723 },
5724 );
5725 // Revalidation happens after the ACL check and before the body is
5726 // written, so a `304` costs the reader nothing and still cannot be
5727 // obtained for a repository they may not read.
5728 if let Some(tag) = rendered.etag.as_deref() {
5729 if header(&request, "If-None-Match").as_deref() == Some(tag) {
5730 // The same policy the `200` would have carried: a `304`
5731 // that named a weaker one would leave the client on it.
5732 return served(request, not_modified(tag, BROWSER_CSP), 304, 0);
5733 }
5734 }
5735
5736 let (status, bytes) = (rendered.status, rendered.html.len() as u64);
5737 let mut response = tiny_http::Response::from_string(rendered.html)
5738 .with_status_code(rendered.status)
5739 .with_header(
5740 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
5741 .expect("static header"),
5742 )
5743 .with_header(
5744 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
5745 .expect("static header"),
5746 )
5747 // The same defence in depth the D28 page carries: file contents
5748 // are attacker-supplied by definition here, so even an escaping
5749 // miss must not be able to run or fetch anything.
5750 //
5751 // Since D81 every page here loads one same-origin source,
5752 // the command palette, and `script-src 'self'` is what lets it
5753 // run. The pages that still run nothing -- the invite pages,
5754 // which draw no bar -- take `UNSCRIPTED_PAGE_CSP` instead, and
5755 // that is the line the split is drawn on now.
5756 .with_header(
5757 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], BROWSER_CSP)
5758 .expect("static header"),
5759 )
5760 .with_header(
5761 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
5762 .expect("static header"),
5763 );
5764 if let Some(tag) = rendered.etag {
5765 response.add_header(
5766 tiny_http::Header::from_bytes(&b"ETag"[..], tag.as_bytes()).expect("etag header"),
5767 );
5768 }
5769 served(request, response, status, bytes)
5770}
5771
5772/// Serves one picture a repository carries, for the `<img>` its README
5773/// renders (D30).
5774///
5775/// The grant was checked by [`handle_browse`], which is the only caller.
5776/// The type comes from the path's extension and from nowhere else, and
5777/// [`served`] adds `nosniff`, so a browser takes these bytes as the
5778/// picture they were asked for as or as nothing.
5779///
5780/// Every refusal is the same line, whichever of the reasons in
5781/// [`browse::raw`] it was: the reader is an `<img>`, which shows the alt
5782/// text either way.
5783fn respond_raw(
5784 request: tiny_http::Request,
5785 root: &Path,
5786 repo: &str,
5787 rev: &str,
5788 path: &str,
5789) -> std::io::Result<(u16, u64)> {
5790 let held = header(&request, "If-None-Match");
5791 let Some(raw) = browse::raw(root, repo, rev, path, held.as_deref()) else {
5792 return respond_plain(request, 404, "no such image\n", b"no-store");
5793 };
5794 let Some(bytes) = raw.bytes else {
5795 return served(request, not_modified(&raw.etag, RAW_CSP), 304, 0);
5796 };
5797 let len = bytes.len() as u64;
5798 let response = tiny_http::Response::from_data(bytes)
5799 .with_header(
5800 tiny_http::Header::from_bytes(&b"Content-Type"[..], raw.content_type.as_bytes())
5801 .expect("static header"),
5802 )
5803 .with_header(
5804 tiny_http::Header::from_bytes(&b"Cache-Control"[..], &b"private, no-cache"[..])
5805 .expect("static header"),
5806 )
5807 .with_header(
5808 tiny_http::Header::from_bytes(&b"Content-Security-Policy"[..], RAW_CSP)
5809 .expect("static header"),
5810 )
5811 .with_header(
5812 tiny_http::Header::from_bytes(&b"Referrer-Policy"[..], REFERRER_POLICY)
5813 .expect("static header"),
5814 )
5815 .with_header(
5816 tiny_http::Header::from_bytes(&b"ETag"[..], raw.etag.as_bytes()).expect("etag header"),
5817 );
5818 served(request, response, 200, len)
5819}
5820
5821#[derive(Debug)]
5822struct Readiness {
5823 log_verified: bool,
5824 sequencer_live: bool,
5825 storage_writable: bool,
5826 free_disk_bytes: Option<u64>,
5827 disk_space_ok: bool,
5828 ref_disagreements: usize,
5829}
5830
5831impl Readiness {
5832 fn ready(&self) -> bool {
5833 self.log_verified
5834 && self.sequencer_live
5835 && self.storage_writable
5836 && self.disk_space_ok
5837 && self.ref_disagreements == 0
5838 }
5839}
5840
5841fn storage_probe(root: &Path) -> bool {
5842 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5843 let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5844 let path = root.join(format!(
5845 ".choir-ready-probe-{}-{sequence}",
5846 std::process::id()
5847 ));
5848 let result = (|| {
5849 let mut file = std::fs::OpenOptions::new()
5850 .write(true)
5851 .create_new(true)
5852 .open(&path)?;
5853 std::io::Write::write_all(&mut file, b"ready\n")?;
5854 file.sync_all()
5855 })();
5856 std::fs::remove_file(path).ok();
5857 result.is_ok()
5858}
5859
5860fn free_disk_bytes(root: &Path) -> Option<u64> {
5861 let output = std::process::Command::new("df")
5862 .args(["-Pk"])
5863 .arg(root)
5864 .output()
5865 .ok()?;
5866 if !output.status.success() {
5867 return None;
5868 }
5869 let text = String::from_utf8(output.stdout).ok()?;
5870 let available_kib = text
5871 .lines()
5872 .nth(1)?
5873 .split_whitespace()
5874 .nth(3)?
5875 .parse::<u64>()
5876 .ok()?;
5877 available_kib.checked_mul(1024)
5878}
5879
5880fn readiness(root: &Path, platform: Option<&Platform>, min_free_bytes: u64) -> Readiness {
5881 let log_verified = choir_oplog::repair::verify(&root.join(".choir/ops.jsonl"))
5882 .is_ok_and(|report| report.fault.is_none());
5883 let sequencer_live = platform.is_some_and(|p| !p.durability_failed());
5884 let free_disk_bytes = free_disk_bytes(root);
5885 let ref_disagreements = platform.map_or(usize::MAX, |p| p.survey_git_refs(root).len());
5886 Readiness {
5887 log_verified,
5888 sequencer_live,
5889 storage_writable: storage_probe(root),
5890 free_disk_bytes,
5891 disk_space_ok: free_disk_bytes.is_some_and(|bytes| bytes >= min_free_bytes),
5892 ref_disagreements,
5893 }
5894}
5895
5896fn handle_observability(
5897 root: &Path,
5898 platform: Option<&Platform>,
5899 min_free_bytes: u64,
5900 counters: &limits::Counters,
5901 started_unix: u64,
5902 request: tiny_http::Request,
5903) -> std::io::Result<(u16, u64)> {
5904 if request.url() == "/healthz" {
5905 let healthy = platform.is_none_or(|p| !p.durability_failed());
5906 let body = serde_json::json!({
5907 "format_version": 1,
5908 "healthy": healthy,
5909 })
5910 .to_string();
5911 let status = if healthy { 200 } else { 503 };
5912 let bytes = body.len() as u64;
5913 let response = tiny_http::Response::from_string(body)
5914 .with_status_code(status)
5915 .with_header(
5916 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5917 .expect("static header"),
5918 );
5919 return served(request, response, status, bytes);
5920 }
5921
5922 let state = readiness(root, platform, min_free_bytes);
5923 if request.url() == "/metrics" {
5924 // This scrape is itself a request, and it has not finished yet,
5925 // so it is not in these numbers. That is the ordinary shape and
5926 // not a defect: every scrape is one request behind, uniformly.
5927 let traffic = counters.snapshot();
5928 let body = format!(
5929 "# TYPE choir_ready gauge\nchoir_ready {}\n\
5930 # TYPE choir_log_verified gauge\nchoir_log_verified {}\n\
5931 # TYPE choir_sequencer_live gauge\nchoir_sequencer_live {}\n\
5932 # TYPE choir_storage_writable gauge\nchoir_storage_writable {}\n\
5933 # TYPE choir_free_disk_bytes gauge\nchoir_free_disk_bytes {}\n\
5934 # TYPE choir_ref_disagreements gauge\nchoir_ref_disagreements {}\n\
5935 # TYPE choir_process_start_time_seconds gauge\n\
5936 choir_process_start_time_seconds {}\n\
5937 # TYPE choir_requests_total counter\nchoir_requests_total {}\n\
5938 # TYPE choir_requests_unauthorized_total counter\n\
5939 choir_requests_unauthorized_total {}\n\
5940 # TYPE choir_requests_throttled_total counter\n\
5941 choir_requests_throttled_total {}\n\
5942 # TYPE choir_requests_failed_total counter\n\
5943 choir_requests_failed_total {}\n\
5944 # TYPE choir_request_duration_microseconds_total counter\n\
5945 choir_request_duration_microseconds_total {}\n",
5946 u8::from(state.ready()),
5947 u8::from(state.log_verified),
5948 u8::from(state.sequencer_live),
5949 u8::from(state.storage_writable),
5950 state.free_disk_bytes.unwrap_or(0),
5951 state.ref_disagreements,
5952 started_unix,
5953 traffic.requests,
5954 traffic.unauthorized,
5955 traffic.throttled,
5956 traffic.failed,
5957 traffic.duration_us,
5958 );
5959 let bytes = body.len() as u64;
5960 let response = tiny_http::Response::from_string(body).with_header(
5961 tiny_http::Header::from_bytes(
5962 &b"Content-Type"[..],
5963 &b"text/plain; version=0.0.4; charset=utf-8"[..],
5964 )
5965 .expect("static header"),
5966 );
5967 return served(request, response, 200, bytes);
5968 }
5969
5970 let status = if state.ready() { 200 } else { 503 };
5971 let body = serde_json::json!({
5972 "format_version": 1,
5973 "ready": state.ready(),
5974 "checks": {
5975 "log_verified": state.log_verified,
5976 "sequencer_live": state.sequencer_live,
5977 "storage_writable": state.storage_writable,
5978 "free_disk_bytes": state.free_disk_bytes,
5979 "minimum_free_disk_bytes": min_free_bytes,
5980 "disk_space_ok": state.disk_space_ok,
5981 "ref_agreement": state.ref_disagreements == 0,
5982 "ref_disagreements": state.ref_disagreements,
5983 }
5984 })
5985 .to_string();
5986 let bytes = body.len() as u64;
5987 let response = tiny_http::Response::from_string(body)
5988 .with_status_code(status)
5989 .with_header(
5990 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
5991 .expect("static header"),
5992 );
5993 served(request, response, status, bytes)
5994}
5995
5996/// Whether this workspace request is over the caller's D37 ceiling, and
5997/// the refusal to send if it is.
5998///
5999/// A request naming a workspace that already exists cannot raise anyone's
6000/// count, so it is not checked: an idempotent retry (D35 binds one to a
6001/// change id and an idempotency key precisely so it can be retried) must
6002/// not be refused for creating nothing. That is also why this reads the
6003/// body — it is the only place the workspace being asked for is named —
6004/// and why it reads nothing else out of it, leaving every other judgement
6005/// about the request to `provision`.
6006fn workspace_quota_refusal(
6007 platform: &Platform,
6008 user: &str,
6009 ceiling: Option<std::num::NonZeroU32>,
6010 body: &[u8],
6011) -> Option<(u16, String)> {
6012 let ceiling = ceiling?.get() as usize;
6013 let request: serde_json::Value = serde_json::from_slice(body).ok()?;
6014 let field = |key: &str| {
6015 request
6016 .get(key)
6017 .and_then(|v| v.as_str())
6018 .unwrap_or_default()
6019 };
6020 let (repo, name) = (field("repo"), field("name"));
6021 if repo.is_empty() || name.is_empty() {
6022 // Malformed: let `provision` say so, in its own words.
6023 return None;
6024 }
6025 if platform.workspace_head(&format!("{repo}/{name}")).is_some() {
6026 return None;
6027 }
6028 let channel = quota::channel_for(user);
6029 let held = platform.workspaces_held_by(&channel);
6030 if held < ceiling {
6031 return None;
6032 }
6033 let rejection = reject::Rejection::new(
6034 reject::Code::QuotaExceeded,
6035 format!("you already hold {held} workspaces, which is this node's per-user limit"),
6036 "archive a workspace you are finished with (POST /api/workspace/archive) to free the \
6037 allowance, or ask the operator to raise the limit",
6038 )
6039 .with_states(
6040 Some(format!("at most {ceiling} workspaces")),
6041 Some(format!("{held} workspaces")),
6042 );
6043 Some((403, rejection.body()))
6044}
6045
6046/// Serves one platform-API request.
6047///
6048/// `workspaces` is the caller's per-user workspace ceiling (D37),
6049/// already `None` for anyone the metering exempts. It is checked here
6050/// rather than inside [`provision::create_workspace`] so the quota
6051/// observes the creation path instead of editing it.
6052struct ApiRequestContext<'a> {
6053 platform: Option<&'a Platform>,
6054 root: &'a Path,
6055 base_url: &'a str,
6056 user: &'a str,
6057 acl: Option<&'a acl::Effective>,
6058 /// The same table as `acl`, but supplied for the hook callbacks too,
6059 /// which deliberately receive `acl: None` (D60).
6060 ///
6061 /// Those callbacks are privileged: they spend authorization the git
6062 /// route already checked, so running the ordinary API denials over
6063 /// them would re-ask a question that has been answered. One question
6064 /// has *not* been answered there, because it could not be: a
6065 /// `propose` grant is admitted at the smart-HTTP boundary before any
6066 /// refname exists. This field carries the table for that one check
6067 /// and nothing else.
6068 push_acl: Option<&'a acl::Effective>,
6069 workspaces: Option<std::num::NonZeroU32>,
6070 body_limit: std::num::NonZeroU64,
6071 queue: Option<&'a queue_api::QueueConfig>,
6072 queue_in_flight: &'a queue_api::InFlight,
6073}
6074
6075fn handle_api(
6076 context: ApiRequestContext<'_>,
6077 mut request: tiny_http::Request,
6078) -> std::io::Result<(u16, u64)> {
6079 let ApiRequestContext {
6080 platform,
6081 root,
6082 base_url,
6083 user,
6084 acl,
6085 push_acl,
6086 workspaces,
6087 body_limit,
6088 queue,
6089 queue_in_flight,
6090 } = context;
6091 let (status, body) = match platform {
6092 Some(p) => {
6093 let req_body = match quota::read_bounded(request.as_reader(), Some(body_limit))? {
6094 quota::Body::Complete(body) => body,
6095 quota::Body::OverLimit { limit, size } => {
6096 return respond_api_too_large(request, limit, size)
6097 }
6098 };
6099 let method = request.method().as_str().to_string();
6100 let path = request.url().to_string();
6101 // The body is already in hand, which is the only place the
6102 // repository a submission touches can be recovered from.
6103 let denial = acl
6104 .and_then(|table| {
6105 acl::api_denial(table, user, &method, &path, &req_body, |id| {
6106 p.review_repo(id)
6107 })
6108 })
6109 .or_else(|| {
6110 // D60. Deliberately not inside `api_denial`: that one
6111 // authorizes the caller of this endpoint, and the
6112 // caller here is the hook. This authorizes the person
6113 // whose push triggered it, named in the body.
6114 ((method.as_str(), path.as_str()) == ("POST", "/api/git-update"))
6115 .then_some(push_acl)
6116 .flatten()
6117 .and_then(|table| platform::proposal_denial(table, &req_body))
6118 });
6119 if let Some(denial) = denial {
6120 (
6121 denial.status,
6122 serde_json::json!({ "error": denial.reason }).to_string(),
6123 )
6124 } else if (method.as_str(), path.as_str()) == ("POST", "/api/queue/run") {
6125 // Routed here rather than inside the platform for the
6126 // same reason as `/api/workspace`: it needs the repo
6127 // root, which the platform does not hold.
6128 match queue {
6129 Some(config) => {
6130 queue_api::run(root, p, config, queue_in_flight, &req_body)
6131 }
6132 None => (
6133 501,
6134 serde_json::json!({
6135 "error": "this node was started without --ci-command, so it runs no queue"
6136 })
6137 .to_string(),
6138 ),
6139 }
6140 } else if (method.as_str(), path.as_str()) == ("POST", "/api/workspace") {
6141 match workspace_quota_refusal(p, user, workspaces, &req_body) {
6142 Some(refusal) => refusal,
6143 None => provision::create_workspace(root, p, base_url, user, &req_body),
6144 }
6145 } else if (method.as_str(), path.as_str()) == ("POST", "/api/workspace/archive") {
6146 provision::archive_workspace(root, p, user, &req_body)
6147 } else if (method.as_str(), path.as_str()) == ("GET", "/api/repos") {
6148 // Routed here rather than inside the platform for the
6149 // same reason as `/api/repo`: it needs the repo root,
6150 // which the platform does not hold.
6151 repos_request(root, acl, user)
6152 } else if (method.as_str(), path.as_str()) == ("POST", "/api/repo") {
6153 // Routed here rather than inside the platform for the
6154 // same reason as `/api/workspace`: it needs the repo
6155 // root, which the platform does not hold.
6156 //
6157 // Creating a repository is a write against the node
6158 // itself rather than against any repository — there is
6159 // no repository yet to be scoped to — so it asks for
6160 // `@node` write, the same authority that governs the
6161 // log and the attestation (D29).
6162 let denial =
6163 acl.and_then(|table| table.check(user, &acl::Scope::Node, acl::Level::Write));
6164 if let Some(denial) = denial {
6165 (
6166 denial.status,
6167 serde_json::json!({ "error": denial.reason }).to_string(),
6168 )
6169 } else {
6170 create_repo_request(root, &req_body)
6171 }
6172 } else if (method.as_str(), path.as_str()) == ("GET", "/api/ref-agreement") {
6173 // Routed here rather than inside the platform for the
6174 // same reason as `/api/workspace`: it needs the repo
6175 // root, which the platform does not hold.
6176 let findings = p.survey_git_refs(root);
6177 let body = serde_json::json!({
6178 "format_version": 1,
6179 "agree": findings.is_empty(),
6180 "findings": findings.iter().map(platform::RefFinding::to_json)
6181 .collect::<Vec<_>>(),
6182 });
6183 (200, body.to_string())
6184 } else {
6185 let (status, body) = p.handle_api(&method, &path, &req_body);
6186 // Phase B: the aggregate reads are narrowed rather than
6187 // refused, because a reader granted one repository still
6188 // has a legitimate view — of that repository. Applied to
6189 // the response rather than inside the handler so the
6190 // platform keeps answering one question, and the ACL
6191 // stays the only thing that knows about grants.
6192 let body = match acl {
6193 Some(table) if status == 200 => acl::filter_response(table, user, &path, &body),
6194 _ => body,
6195 };
6196 // Bounded last, after any narrowing, because
6197 // `<section>_omitted` is a row count and a count of rows
6198 // this caller may not read discloses that they exist.
6199 // See `bound`'s module docs; the ordering is the design.
6200 match status {
6201 200 => (status, bound::apply(&path, &body)),
6202 _ => (status, body),
6203 }
6204 }
6205 }
6206 None => (503, r#"{"error":"platform API not enabled"}"#.to_string()),
6207 };
6208 let bytes = body.len() as u64;
6209 let response = tiny_http::Response::from_string(body)
6210 .with_status_code(status)
6211 .with_header(
6212 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..])
6213 .expect("static header"),
6214 );
6215 served(request, response, status, bytes)
6216}
6217
6218/// Bridges one HTTP request to `git http-backend` CGI. `extra_env` is
6219/// added to the CGI child (and thus inherited by git hooks).
6220///
6221/// `push_bytes` is the caller's per-user ceiling on the request body
6222/// (D37). It is checked here, before the CGI child is spawned, which is
6223/// the point of the whole design: `git http-backend` is what runs the
6224/// `pre-receive` hook, and the hook is what submits ops. A body refused
6225/// on this side of the spawn means no hook ran, so no op was submitted
6226/// and the hook's retraction path — the one that has to undo the refs an
6227/// aborted push already got into the durable log — is never entered.
6228fn handle(
6229 root: PathBuf,
6230 mut request: tiny_http::Request,
6231 extra_env: &[(String, String)],
6232 push_bytes: Option<std::num::NonZeroU64>,
6233) -> std::io::Result<(u16, u64)> {
6234 let url = request.url().to_string();
6235 let (path, query) = match url.split_once('?') {
6236 Some((p, q)) => (p.to_string(), q.to_string()),
6237 None => (url.clone(), String::new()),
6238 };
6239
6240 let body = match quota::read_bounded(request.as_reader(), push_bytes)? {
6241 quota::Body::Complete(body) => body,
6242 quota::Body::OverLimit { limit, size } => {
6243 return respond_push_too_large(request, limit, size)
6244 }
6245 };
6246
6247 let method = request.method().as_str().to_string();
6248 let content_type = request
6249 .headers()
6250 .iter()
6251 .find(|h| h.field.equiv("Content-Type"))
6252 .map(|h| h.value.as_str().to_string())
6253 .unwrap_or_default();
6254
6255 let mut child = std::process::Command::new("git")
6256 .arg("http-backend")
6257 .envs(extra_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
6258 .env("GIT_PROJECT_ROOT", &root)
6259 .env("GIT_HTTP_EXPORT_ALL", "1")
6260 .env("PATH_INFO", &path)
6261 .env("QUERY_STRING", &query)
6262 .env("REQUEST_METHOD", &method)
6263 .env("CONTENT_TYPE", &content_type)
6264 .env("CONTENT_LENGTH", body.len().to_string())
6265 .env("REMOTE_ADDR", "127.0.0.1")
6266 .env("GATEWAY_INTERFACE", "CGI/1.1")
6267 .stdin(std::process::Stdio::piped())
6268 .stdout(std::process::Stdio::piped())
6269 .stderr(std::process::Stdio::null())
6270 .spawn()?;
6271
6272 use std::io::Write;
6273 child.stdin.take().expect("stdin piped").write_all(&body)?;
6274 let mut out = Vec::new();
6275 child
6276 .stdout
6277 .take()
6278 .expect("stdout piped")
6279 .read_to_end(&mut out)?;
6280 child.wait()?;
6281
6282 // Split the CGI response into headers and body.
6283 let split = out
6284 .windows(4)
6285 .position(|w| w == b"\r\n\r\n")
6286 .map(|i| (i, i + 4))
6287 .or_else(|| {
6288 out.windows(2)
6289 .position(|w| w == b"\n\n")
6290 .map(|i| (i, i + 2))
6291 });
6292 let (head, rest) = match split {
6293 Some((h, b)) => (&out[..h], &out[b..]),
6294 None => (&[][..], &out[..]),
6295 };
6296
6297 let mut status = 200;
6298 let mut headers = Vec::new();
6299 for line in String::from_utf8_lossy(head).lines() {
6300 if let Some((k, v)) = line.split_once(':') {
6301 let (k, v) = (k.trim(), v.trim());
6302 if k.eq_ignore_ascii_case("Status") {
6303 status = v
6304 .split_whitespace()
6305 .next()
6306 .and_then(|s| s.parse().ok())
6307 .unwrap_or(200);
6308 } else if let Ok(h) = tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()) {
6309 headers.push(h);
6310 }
6311 }
6312 }
6313
6314 let bytes = rest.len() as u64;
6315 let mut response = tiny_http::Response::from_data(rest.to_vec()).with_status_code(status);
6316 for h in headers {
6317 response.add_header(h);
6318 }
6319 served(request, response, status, bytes)
6320}