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}