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