Skip to main content

choir_cli/
serve.rs

1//! `choir node serve` — run the daemon without spelling out its flags.
2//!
3//! Starting a node used to mean `cargo run -p choir-node --` followed by
4//! a repository root, a port and four `--*-file` paths, retyped or
5//! copied out of a shell history. Every one of those is derivable from
6//! the layout [`crate::init`] already writes, so this derives them: the
7//! command that starts a node takes the same argument as the command
8//! that created one, which is none.
9//!
10//! # It does not stay in the middle
11//!
12//! On unix this `exec`s the daemon rather than spawning it, so the
13//! process the supervisor watches, the process that receives a signal
14//! and the process in `ps` are all `choir-node` itself. A wrapper that
15//! lingered would add a pid that means nothing, swallow the exit code
16//! the daemon uses to ask for supervision (75), and put a second thing
17//! between `launchd` and the thing it is meant to restart.
18//!
19//! # Examples
20//!
21//! ```
22//! use choir_cli::serve::Layout;
23//!
24//! let layout = Layout::new(std::path::Path::new("/tmp/choir-serve-example"), 8417);
25//! // Derived, not configured: the same paths `choir init` wrote.
26//! assert!(layout.repos.ends_with("repos"));
27//! assert!(layout.auth.ends_with("auth"));
28//! ```
29
30use std::path::{Path, PathBuf};
31
32/// Where a node's state lives, by the convention `choir init` writes.
33///
34/// Fields rather than a lookup, so a caller cannot ask for a path this
35/// does not define. The layout is deliberately not configurable
36/// file-by-file: a node whose four paths can each point somewhere else
37/// is a node whose backup, restore and status commands each need to be
38/// told all four, and every one of them is a place to get it wrong.
39pub struct Layout {
40    /// The state directory itself, `~/.choir` unless told otherwise.
41    pub state: PathBuf,
42    /// Bare repositories; the daemon's positional root.
43    pub repos: PathBuf,
44    /// `user:token`, the credential the API checks.
45    pub auth: PathBuf,
46    /// Public keys the node accepts ops from. Without it the platform
47    /// API stays off and every `/api/*` read answers 503.
48    pub keys: PathBuf,
49    /// Where the daemon's own log is appended when supervised.
50    pub log: PathBuf,
51    /// Two lines — certificate path, then key path — when this node
52    /// terminates TLS itself. Its *existence* is the switch, the same
53    /// marker discipline the review and scope gates already use: an
54    /// empty file and an absent one mean opposite things, and a separate
55    /// enable-flag beside the file it guards is a pair that can disagree.
56    pub tls_marker: PathBuf,
57    /// The D36 accounts file. Present means invites can be minted;
58    /// absent means `/api/accounts/invite` answers 503.
59    pub accounts: PathBuf,
60    /// The D29 per-repository grants. Its existence is the switch, and
61    /// the daemon refuses an accounts file without one — an issued grant
62    /// with no table to grade it against is a grant to everything.
63    pub acl: PathBuf,
64    /// The one URL people outside this machine use, when there is one.
65    pub public_url: PathBuf,
66    /// Three `key = value` lines when this node is a seed of another
67    /// node's log (D80): `home`, `credential`, `serve`. Its existence is
68    /// the switch, the same discipline as `tls.enabled`: a seed and a
69    /// home are the same layout started with different flags, and the
70    /// marker is what `choir seed` writes so that `node serve`, `node
71    /// install` and every command after them need no seed-specific
72    /// argument.
73    pub seed_marker: PathBuf,
74    /// Where `choir seed` keeps the `user:token` line the home issued,
75    /// mode 0600, so the unit never points outside the state directory.
76    pub seed_credential: PathBuf,
77    /// Present when landings are pushed to each repository's remotes
78    /// (D21). Written by `choir repo follower add`; its existence is
79    /// `--followers`.
80    pub followers_marker: PathBuf,
81    /// The port to bind.
82    pub port: u16,
83}
84
85/// What the seed marker says.
86#[derive(Debug, Clone, Eq, PartialEq)]
87pub struct Seed {
88    /// The home whose log this node copies.
89    pub home: String,
90    /// The credential file the home issued to this seed's principal.
91    pub credential: PathBuf,
92    /// Whether it binds a port. An archival seed replicates, stores and
93    /// serves nobody.
94    pub serve: bool,
95}
96
97impl Seed {
98    /// The marker's text.
99    #[must_use]
100    pub fn render(&self) -> String {
101        format!(
102            "# This node is a seed of another node's log (D80). Written by\n\
103             # `choir seed`; `choir node serve` reads it. Delete it to make\n\
104             # this state directory a home again (its log would then be\n\
105             # one nobody else writes to).\n\
106             home = {}\n\
107             credential = {}\n\
108             serve = {}\n",
109            self.home,
110            self.credential.display(),
111            if self.serve { "yes" } else { "no" }
112        )
113    }
114
115    /// Reads a marker's text back.
116    ///
117    /// Anything short of a home, a credential and a `serve` answer is
118    /// `None`: a half-written marker must not become a node that starts
119    /// as a home over a log that was a copy.
120    #[must_use]
121    pub fn parse(text: &str) -> Option<Seed> {
122        let mut home = None;
123        let mut credential = None;
124        let mut serve = None;
125        for line in text.lines() {
126            let line = line.trim();
127            if line.is_empty() || line.starts_with('#') {
128                continue;
129            }
130            let (key, value) = line.split_once('=')?;
131            match key.trim() {
132                "home" => home = Some(value.trim().trim_end_matches('/').to_string()),
133                "credential" => credential = Some(PathBuf::from(value.trim())),
134                "serve" => {
135                    serve = match value.trim() {
136                        "yes" => Some(true),
137                        "no" => Some(false),
138                        _ => return None,
139                    }
140                }
141                _ => return None,
142            }
143        }
144        Some(Seed {
145            home: home?,
146            credential: credential?,
147            serve: serve?,
148        })
149    }
150}
151
152impl Layout {
153    /// The standard layout under `state`.
154    #[must_use]
155    pub fn new(state: &Path, port: u16) -> Layout {
156        Layout {
157            state: state.to_path_buf(),
158            repos: state.join("repos"),
159            auth: state.join("auth"),
160            keys: state.join("keys"),
161            log: state.join("node.log"),
162            tls_marker: state.join("tls.enabled"),
163            accounts: state.join("accounts.jsonl"),
164            acl: state.join("acl"),
165            public_url: state.join("public-url"),
166            seed_marker: state.join("seed"),
167            seed_credential: state.join("seed-credential"),
168            followers_marker: state.join("followers"),
169            port,
170        }
171    }
172
173    /// The home this node seeds, when it is a seed.
174    #[must_use]
175    pub fn seed(&self) -> Option<Seed> {
176        Seed::parse(&std::fs::read_to_string(&self.seed_marker).ok()?)
177    }
178
179    /// The certificate and key this node terminates TLS with, if it does.
180    ///
181    /// Read at *start* time rather than baked into the supervision file,
182    /// which is what makes renewal a restart rather than a re-render: a
183    /// certbot deploy hook replaces the two files and restarts the unit,
184    /// and the unit still says `choir node serve`.
185    ///
186    /// Anything but two non-empty lines is `None`. A half-written marker
187    /// must not become a public bind with no certificate — that is
188    /// invariant 9 by another route.
189    #[must_use]
190    pub fn tls(&self) -> Option<(PathBuf, PathBuf)> {
191        let text = std::fs::read_to_string(&self.tls_marker).ok()?;
192        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
193        let cert = PathBuf::from(lines.next()?);
194        let key = PathBuf::from(lines.next()?);
195        Some((cert, key))
196    }
197
198    /// The public URL, when one was written.
199    #[must_use]
200    pub fn public(&self) -> Option<String> {
201        let text = std::fs::read_to_string(&self.public_url).ok()?;
202        let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
203        Some(line.to_string())
204    }
205
206    /// The address the daemon should bind.
207    ///
208    /// `0.0.0.0` exactly when there is a certificate to present, never
209    /// otherwise. The daemon refuses the unsafe combination on its own
210    /// (invariant 9); this side never asks for it, so the refusal is a
211    /// backstop rather than the mechanism.
212    #[must_use]
213    pub fn bind(&self) -> &'static str {
214        match self.tls() {
215            Some(_) => "0.0.0.0",
216            None => "127.0.0.1",
217        }
218    }
219
220    /// What must already exist for a node to start, and does not.
221    ///
222    /// Checked before the daemon is launched so the answer names
223    /// `choir init` rather than arriving as whatever the daemon says
224    /// about a path it could not read. The repository root is not in
225    /// this list: the daemon creates it, and an empty node is a valid
226    /// one.
227    ///
228    /// A seed needs neither: it learns every key from its home's
229    /// `/api/signers`, and an archival seed has no readers to name. What
230    /// it cannot start without is the credential its marker points at.
231    #[must_use]
232    pub fn missing(&self) -> Vec<&Path> {
233        if self.seed_marker.exists() {
234            return match self.seed() {
235                Some(seed) if seed.credential.exists() => Vec::new(),
236                _ => vec![self.seed_credential.as_path()],
237            };
238        }
239        [self.auth.as_path(), self.keys.as_path()]
240            .into_iter()
241            .filter(|p| !p.exists())
242            .collect()
243    }
244}
245
246/// The daemon invocation, built but not yet run.
247///
248/// Separated from running it so a test can assert on the exact argv
249/// without a daemon, a port or a temp directory that outlives it. The
250/// argv *is* the contract here — every flag this omits is a default the
251/// daemon picks, and a test that only checked the process started would
252/// not notice one going missing.
253#[derive(Debug, Eq, PartialEq)]
254pub struct Invocation {
255    /// The `choir-node` binary that will be executed.
256    pub program: PathBuf,
257    /// Its arguments, in order, not including the program name.
258    pub args: Vec<String>,
259}
260
261impl Invocation {
262    /// The command as a person would type it, for printing before it runs.
263    #[must_use]
264    pub fn display(&self) -> String {
265        let mut line = self.program.display().to_string();
266        for arg in &self.args {
267            line.push(' ');
268            line.push_str(arg);
269        }
270        line
271    }
272}
273
274/// Finds the `choir-node` binary.
275///
276/// Beside this executable first, `PATH` second. The sibling wins
277/// because the two binaries are built and installed as a pair: an older
278/// `choir-node` earlier on `PATH` would serve a different build than
279/// the `choir` that launched it, and every symptom afterwards would
280/// look like the running node being wrong rather than being old.
281///
282/// # Errors
283///
284/// Returns a description, naming both places it looked, when neither
285/// holds one.
286pub fn find_daemon() -> Result<PathBuf, String> {
287    if let Ok(exe) = std::env::current_exe() {
288        if let Some(dir) = exe.parent() {
289            let sibling = dir.join("choir-node");
290            if sibling.is_file() {
291                return Ok(sibling);
292            }
293        }
294    }
295    crate::doctor::on_path("choir-node").ok_or_else(|| {
296        "no `choir-node` binary beside `choir` or on PATH\n\n  \
297         build it:   cargo build --release -p choir-node\n  \
298         install it: cp target/release/choir-node ~/.local/bin/"
299            .to_string()
300    })
301}
302
303/// Builds the daemon invocation for a layout.
304///
305/// `extra` is passed through verbatim after the derived flags, so any
306/// daemon flag this does not know about is still reachable without
307/// this function having to grow a copy of the daemon's argument parser
308/// — which would be a second parser to keep in step, and the kind that
309/// fails by silently dropping a flag rather than by refusing it.
310///
311/// # Errors
312///
313/// Refuses when the credential or the trusted-key file is missing,
314/// naming the command that writes them. Starting without them would
315/// produce a node that either accepts everyone or answers 503 to every
316/// read, and both look like a broken install rather than an unfinished
317/// one.
318pub fn plan(
319    program: PathBuf,
320    layout: &Layout,
321    create: &[String],
322    extra: &[String],
323) -> Result<Invocation, String> {
324    let missing = layout.missing();
325    if !missing.is_empty() {
326        let names: Vec<String> = missing.iter().map(|p| p.display().to_string()).collect();
327        return Err(format!(
328            "this state directory has no node in it yet — missing:\n  {}\n\n  \
329             create one: choir init",
330            names.join("\n  ")
331        ));
332    }
333    if let Some(seed) = layout.seed() {
334        return plan_seed(program, layout, &seed, create, extra);
335    }
336    let mut args = vec![
337        layout.repos.display().to_string(),
338        layout.port.to_string(),
339        "--auth-file".to_string(),
340        layout.auth.display().to_string(),
341        "--keys-file".to_string(),
342        layout.keys.display().to_string(),
343    ];
344    // Both derived from a file's existence rather than from a flag, so
345    // that a certificate arriving later, or an accounts file being
346    // created later, changes what the node serves without the
347    // supervision file being re-rendered. That is the failure mode of
348    // the shell installers this replaces: a unit that lints clean and
349    // restarts cleanly while launching yesterday's arguments.
350    if let Some((cert, key)) = layout.tls() {
351        for path in [&cert, &key] {
352            if std::fs::File::open(path).is_err() {
353                return Err(format!(
354                    "{} names {}, which this user cannot read\n\n  \
355                     re-issue and re-project the pair: sudo choir node tls <domain> \
356                     --user $(id -un)",
357                    layout.tls_marker.display(),
358                    path.display()
359                ));
360            }
361        }
362        args.push("--bind".to_string());
363        args.push(layout.bind().to_string());
364        args.push("--tls-cert".to_string());
365        args.push(cert.display().to_string());
366        args.push("--tls-key".to_string());
367        args.push(key.display().to_string());
368    }
369    if layout.acl.exists() {
370        args.push("--acl-file".to_string());
371        args.push(layout.acl.display().to_string());
372    }
373    if layout.accounts.exists() {
374        // Refused here rather than left to the daemon, which exits on it
375        // at startup and is therefore discovered by a supervisor
376        // restarting every two seconds into the same failure.
377        if !layout.acl.exists() {
378            return Err(format!(
379                "{} turns on invite-only credentials, and there is no {} to grade the\n  \
380                 grants against — an issued grant with no table is a grant to every\n  \
381                 repository. Write one, or remove the accounts file.",
382                layout.accounts.display(),
383                layout.acl.display()
384            ));
385        }
386        args.push("--accounts-file".to_string());
387        args.push(layout.accounts.display().to_string());
388    }
389    for repo in create {
390        args.push("--create".to_string());
391        args.push(repo.clone());
392    }
393    if layout.followers_marker.exists() {
394        args.push("--followers".to_string());
395    }
396    args.extend_from_slice(extra);
397    Ok(Invocation { program, args })
398}
399
400/// The daemon invocation for a seed (D80).
401///
402/// The same layout, three flags different: `--seed` and
403/// `--seed-credential` instead of `--keys-file`, and no port at all for
404/// an archival seed. The daemon refuses `--keys-file` and `--create` on
405/// a seed; both are refused here first, so the sentence names the reason
406/// rather than a supervisor restarting into it.
407fn plan_seed(
408    program: PathBuf,
409    layout: &Layout,
410    seed: &Seed,
411    create: &[String],
412    extra: &[String],
413) -> Result<Invocation, String> {
414    if !create.is_empty() {
415        return Err(format!(
416            "a seed takes no --create: its repositories come from {}",
417            seed.home
418        ));
419    }
420    if extra.iter().any(|a| a == "--keys-file") {
421        return Err(
422            "a seed takes no --keys-file: it learns every key from its home's /api/signers"
423                .to_string(),
424        );
425    }
426    let mut args = vec![layout.repos.display().to_string()];
427    if seed.serve {
428        args.push(layout.port.to_string());
429    }
430    args.push("--seed".to_string());
431    args.push(seed.home.clone());
432    args.push("--seed-credential".to_string());
433    args.push(seed.credential.display().to_string());
434    if !seed.serve {
435        // An archival seed takes only its own flags; the daemon refuses
436        // the rest, and there is nothing to bind them to.
437        args.extend_from_slice(extra);
438        return Ok(Invocation { program, args });
439    }
440    if layout.auth.exists() {
441        args.push("--auth-file".to_string());
442        args.push(layout.auth.display().to_string());
443    }
444    if let Some((cert, key)) = layout.tls() {
445        for path in [&cert, &key] {
446            if std::fs::File::open(path).is_err() {
447                return Err(format!(
448                    "{} names {}, which this user cannot read\n\n  \
449                     re-issue and re-project the pair: sudo choir node tls <domain> \
450                     --user $(id -un)",
451                    layout.tls_marker.display(),
452                    path.display()
453                ));
454            }
455        }
456        args.push("--bind".to_string());
457        args.push(layout.bind().to_string());
458        args.push("--tls-cert".to_string());
459        args.push(cert.display().to_string());
460        args.push("--tls-key".to_string());
461        args.push(key.display().to_string());
462    }
463    if layout.acl.exists() {
464        args.push("--acl-file".to_string());
465        args.push(layout.acl.display().to_string());
466    }
467    args.extend_from_slice(extra);
468    Ok(Invocation { program, args })
469}
470
471/// Whether something is already listening on this port.
472///
473/// Asked before the daemon is launched only to make the refusal
474/// legible: a bind that fails inside the daemon reports an errno
475/// against an address, and the useful sentence — that this is probably
476/// the node you already started — is one this side can write and that
477/// side cannot. It is a race in principle, and harmless in practice:
478/// losing the race produces exactly the message not checking would have.
479#[must_use]
480pub fn port_taken(port: u16) -> bool {
481    std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
482}
483
484/// Replaces this process with the daemon.
485///
486/// # Errors
487///
488/// Only returns if the `exec` itself failed; a successful call never
489/// returns at all.
490#[cfg(unix)]
491pub fn exec(invocation: &Invocation) -> String {
492    use std::os::unix::process::CommandExt;
493    let error = std::process::Command::new(&invocation.program)
494        .args(&invocation.args)
495        .exec();
496    format!("could not run {}: {error}", invocation.program.display())
497}
498
499/// Runs the daemon as a child and waits for it.
500///
501/// The non-unix fallback: without `exec` the wrapper has to stay, so it
502/// at least forwards the exit code rather than inventing one.
503///
504/// # Errors
505///
506/// Returns a description when the daemon could not be started.
507#[cfg(not(unix))]
508pub fn exec(invocation: &Invocation) -> String {
509    match std::process::Command::new(&invocation.program)
510        .args(&invocation.args)
511        .status()
512    {
513        Ok(status) => std::process::exit(status.code().unwrap_or(1)),
514        Err(error) => format!("could not run {}: {error}", invocation.program.display()),
515    }
516}
517
518/// The port named by a node URL, if it names one.
519///
520/// `http://127.0.0.1:8417` is the shape `choir init` writes, and the
521/// port in it is the one the daemon should bind — reading it back means
522/// `serve` and every client command agree without the port being
523/// written down twice.
524#[must_use]
525pub fn port_of(url: &str) -> Option<u16> {
526    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
527    let host = rest.split('/').next()?;
528    host.rsplit_once(':')?.1.parse().ok()
529}