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    /// The port to bind.
67    pub port: u16,
68}
69
70impl Layout {
71    /// The standard layout under `state`.
72    #[must_use]
73    pub fn new(state: &Path, port: u16) -> Layout {
74        Layout {
75            state: state.to_path_buf(),
76            repos: state.join("repos"),
77            auth: state.join("auth"),
78            keys: state.join("keys"),
79            log: state.join("node.log"),
80            tls_marker: state.join("tls.enabled"),
81            accounts: state.join("accounts.jsonl"),
82            acl: state.join("acl"),
83            public_url: state.join("public-url"),
84            port,
85        }
86    }
87
88    /// The certificate and key this node terminates TLS with, if it does.
89    ///
90    /// Read at *start* time rather than baked into the supervision file,
91    /// which is what makes renewal a restart rather than a re-render: a
92    /// certbot deploy hook replaces the two files and restarts the unit,
93    /// and the unit still says `choir node serve`.
94    ///
95    /// Anything but two non-empty lines is `None`. A half-written marker
96    /// must not become a public bind with no certificate — that is
97    /// invariant 9 by another route.
98    #[must_use]
99    pub fn tls(&self) -> Option<(PathBuf, PathBuf)> {
100        let text = std::fs::read_to_string(&self.tls_marker).ok()?;
101        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
102        let cert = PathBuf::from(lines.next()?);
103        let key = PathBuf::from(lines.next()?);
104        Some((cert, key))
105    }
106
107    /// The public URL, when one was written.
108    #[must_use]
109    pub fn public(&self) -> Option<String> {
110        let text = std::fs::read_to_string(&self.public_url).ok()?;
111        let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
112        Some(line.to_string())
113    }
114
115    /// The address the daemon should bind.
116    ///
117    /// `0.0.0.0` exactly when there is a certificate to present, never
118    /// otherwise. The daemon refuses the unsafe combination on its own
119    /// (invariant 9); this side never asks for it, so the refusal is a
120    /// backstop rather than the mechanism.
121    #[must_use]
122    pub fn bind(&self) -> &'static str {
123        match self.tls() {
124            Some(_) => "0.0.0.0",
125            None => "127.0.0.1",
126        }
127    }
128
129    /// What must already exist for a node to start, and does not.
130    ///
131    /// Checked before the daemon is launched so the answer names
132    /// `choir init` rather than arriving as whatever the daemon says
133    /// about a path it could not read. The repository root is not in
134    /// this list: the daemon creates it, and an empty node is a valid
135    /// one.
136    #[must_use]
137    pub fn missing(&self) -> Vec<&Path> {
138        [self.auth.as_path(), self.keys.as_path()]
139            .into_iter()
140            .filter(|p| !p.exists())
141            .collect()
142    }
143}
144
145/// The daemon invocation, built but not yet run.
146///
147/// Separated from running it so a test can assert on the exact argv
148/// without a daemon, a port or a temp directory that outlives it. The
149/// argv *is* the contract here — every flag this omits is a default the
150/// daemon picks, and a test that only checked the process started would
151/// not notice one going missing.
152#[derive(Debug, Eq, PartialEq)]
153pub struct Invocation {
154    /// The `choir-node` binary that will be executed.
155    pub program: PathBuf,
156    /// Its arguments, in order, not including the program name.
157    pub args: Vec<String>,
158}
159
160impl Invocation {
161    /// The command as a person would type it, for printing before it runs.
162    #[must_use]
163    pub fn display(&self) -> String {
164        let mut line = self.program.display().to_string();
165        for arg in &self.args {
166            line.push(' ');
167            line.push_str(arg);
168        }
169        line
170    }
171}
172
173/// Finds the `choir-node` binary.
174///
175/// Beside this executable first, `PATH` second. The sibling wins
176/// because the two binaries are built and installed as a pair: an older
177/// `choir-node` earlier on `PATH` would serve a different build than
178/// the `choir` that launched it, and every symptom afterwards would
179/// look like the running node being wrong rather than being old.
180///
181/// # Errors
182///
183/// Returns a description, naming both places it looked, when neither
184/// holds one.
185pub fn find_daemon() -> Result<PathBuf, String> {
186    if let Ok(exe) = std::env::current_exe() {
187        if let Some(dir) = exe.parent() {
188            let sibling = dir.join("choir-node");
189            if sibling.is_file() {
190                return Ok(sibling);
191            }
192        }
193    }
194    crate::doctor::on_path("choir-node").ok_or_else(|| {
195        "no `choir-node` binary beside `choir` or on PATH\n\n  \
196         build it:   cargo build --release -p choir-node\n  \
197         install it: cp target/release/choir-node ~/.local/bin/"
198            .to_string()
199    })
200}
201
202/// Builds the daemon invocation for a layout.
203///
204/// `extra` is passed through verbatim after the derived flags, so any
205/// daemon flag this does not know about is still reachable without
206/// this function having to grow a copy of the daemon's argument parser
207/// — which would be a second parser to keep in step, and the kind that
208/// fails by silently dropping a flag rather than by refusing it.
209///
210/// # Errors
211///
212/// Refuses when the credential or the trusted-key file is missing,
213/// naming the command that writes them. Starting without them would
214/// produce a node that either accepts everyone or answers 503 to every
215/// read, and both look like a broken install rather than an unfinished
216/// one.
217pub fn plan(
218    program: PathBuf,
219    layout: &Layout,
220    create: &[String],
221    extra: &[String],
222) -> Result<Invocation, String> {
223    let missing = layout.missing();
224    if !missing.is_empty() {
225        let names: Vec<String> = missing.iter().map(|p| p.display().to_string()).collect();
226        return Err(format!(
227            "this state directory has no node in it yet — missing:\n  {}\n\n  \
228             create one: choir init",
229            names.join("\n  ")
230        ));
231    }
232    let mut args = vec![
233        layout.repos.display().to_string(),
234        layout.port.to_string(),
235        "--auth-file".to_string(),
236        layout.auth.display().to_string(),
237        "--keys-file".to_string(),
238        layout.keys.display().to_string(),
239    ];
240    // Both derived from a file's existence rather than from a flag, so
241    // that a certificate arriving later, or an accounts file being
242    // created later, changes what the node serves without the
243    // supervision file being re-rendered. That is the failure mode of
244    // the shell installers this replaces: a unit that lints clean and
245    // restarts cleanly while launching yesterday's arguments.
246    if let Some((cert, key)) = layout.tls() {
247        for path in [&cert, &key] {
248            if std::fs::File::open(path).is_err() {
249                return Err(format!(
250                    "{} names {}, which this user cannot read\n\n  \
251                     re-issue and re-project the pair: sudo choir node tls <domain> \
252                     --user $(id -un)",
253                    layout.tls_marker.display(),
254                    path.display()
255                ));
256            }
257        }
258        args.push("--bind".to_string());
259        args.push(layout.bind().to_string());
260        args.push("--tls-cert".to_string());
261        args.push(cert.display().to_string());
262        args.push("--tls-key".to_string());
263        args.push(key.display().to_string());
264    }
265    if layout.acl.exists() {
266        args.push("--acl-file".to_string());
267        args.push(layout.acl.display().to_string());
268    }
269    if layout.accounts.exists() {
270        // Refused here rather than left to the daemon, which exits on it
271        // at startup and is therefore discovered by a supervisor
272        // restarting every two seconds into the same failure.
273        if !layout.acl.exists() {
274            return Err(format!(
275                "{} turns on invite-only credentials, and there is no {} to grade the\n  \
276                 grants against — an issued grant with no table is a grant to every\n  \
277                 repository. Write one, or remove the accounts file.",
278                layout.accounts.display(),
279                layout.acl.display()
280            ));
281        }
282        args.push("--accounts-file".to_string());
283        args.push(layout.accounts.display().to_string());
284    }
285    for repo in create {
286        args.push("--create".to_string());
287        args.push(repo.clone());
288    }
289    args.extend_from_slice(extra);
290    Ok(Invocation { program, args })
291}
292
293/// Whether something is already listening on this port.
294///
295/// Asked before the daemon is launched only to make the refusal
296/// legible: a bind that fails inside the daemon reports an errno
297/// against an address, and the useful sentence — that this is probably
298/// the node you already started — is one this side can write and that
299/// side cannot. It is a race in principle, and harmless in practice:
300/// losing the race produces exactly the message not checking would have.
301#[must_use]
302pub fn port_taken(port: u16) -> bool {
303    std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
304}
305
306/// Replaces this process with the daemon.
307///
308/// # Errors
309///
310/// Only returns if the `exec` itself failed; a successful call never
311/// returns at all.
312#[cfg(unix)]
313pub fn exec(invocation: &Invocation) -> String {
314    use std::os::unix::process::CommandExt;
315    let error = std::process::Command::new(&invocation.program)
316        .args(&invocation.args)
317        .exec();
318    format!("could not run {}: {error}", invocation.program.display())
319}
320
321/// Runs the daemon as a child and waits for it.
322///
323/// The non-unix fallback: without `exec` the wrapper has to stay, so it
324/// at least forwards the exit code rather than inventing one.
325///
326/// # Errors
327///
328/// Returns a description when the daemon could not be started.
329#[cfg(not(unix))]
330pub fn exec(invocation: &Invocation) -> String {
331    match std::process::Command::new(&invocation.program)
332        .args(&invocation.args)
333        .status()
334    {
335        Ok(status) => std::process::exit(status.code().unwrap_or(1)),
336        Err(error) => format!("could not run {}: {error}", invocation.program.display()),
337    }
338}
339
340/// The port named by a node URL, if it names one.
341///
342/// `http://127.0.0.1:8417` is the shape `choir init` writes, and the
343/// port in it is the one the daemon should bind — reading it back means
344/// `serve` and every client command agree without the port being
345/// written down twice.
346#[must_use]
347pub fn port_of(url: &str) -> Option<u16> {
348    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
349    let host = rest.split('/').next()?;
350    host.rsplit_once(':')?.1.parse().ok()
351}