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}