Skip to main content

choir_cli/
seed.rs

1//! `choir seed` — a fresh machine to a running seed of somebody else's
2//! log, in one command (D80).
3//!
4//! Everything this does was already possible: mint a node key where the
5//! daemon will look for it, hand the home's operator three lines to
6//! paste, take the credential they issue, start `choir-node` with
7//! `--seed`, supervise it. The order is what nobody should have to know,
8//! and the daemon flags are what nobody should have to retype, which is
9//! the same reasoning as [`crate::host`].
10//!
11//! # Two runs, on purpose
12//!
13//! A seed is a *named* reader of its home. Its key has to be registered
14//! and bound there, and a credential issued, before it can read a page,
15//! and all of that happens on a machine this command is not running on.
16//! So the first run stops after minting the identity and prints what the
17//! home registers, exit 3, the same handover shape as `choir host`'s
18//! certificate step; the second run, with `--credential`, finishes.
19//!
20//! # What it writes
21//!
22//! The marker [`crate::serve::Seed`] beside the state directory's other
23//! markers, and the credential copied to 0600 inside it. After that
24//! `choir node serve`, `node install`, `node status`, `node logs` and
25//! `doctor` all work on a seed exactly as on a home, because the marker
26//! is what they read.
27//!
28//! # Examples
29//!
30//! ```
31//! use choir_cli::seed::registration;
32//!
33//! // The three things the home's operator pastes: a keys line, a
34//! // binding, and the two grants a seed needs (the log grant does not
35//! // reach git, so `read` on the repositories is the second one).
36//! let lines = registration("seed-a", "ab".repeat(32).as_str(), "https://home.example");
37//! assert!(lines.iter().any(|l| l.contains("@node auditor")));
38//! assert!(lines.iter().any(|l| l.contains("choir bind https://home.example")));
39//! ```
40
41use std::path::{Path, PathBuf};
42
43/// What `choir seed` was asked to do.
44#[derive(Debug, Clone, Eq, PartialEq)]
45pub struct Options {
46    /// The home whose log this machine will copy, without a trailing slash.
47    pub home: String,
48    /// The `user:token` file the home issued, once it has.
49    pub credential: Option<PathBuf>,
50    /// The state directory, `~/.choir` unless given.
51    pub state: PathBuf,
52    /// The port to serve on. Ignored by an archival seed.
53    pub port: u16,
54    /// Replicate and store, bind nothing.
55    pub archival: bool,
56    /// The principal's name at the home, as the keys line and the ACL
57    /// will spell it.
58    pub name: String,
59    /// Become the daemon instead of installing a unit.
60    pub foreground: bool,
61    /// Daemon flags, passed through after `--`.
62    pub extra: Vec<String>,
63}
64
65/// Parses `choir seed`'s arguments.
66///
67/// # Errors
68///
69/// Returns a usage sentence naming what was wrong: no home, a home that
70/// is not an `http(s)` URL, a port on an archival seed, or an option this
71/// does not know (daemon flags go after `--`).
72pub fn parse(rest: &[&str], default_state: PathBuf) -> Result<Options, String> {
73    let (mine, extra) = match rest.iter().position(|a| *a == "--") {
74        Some(at) => (&rest[..at], &rest[at + 1..]),
75        None => (rest, &rest[rest.len()..]),
76    };
77    let mut home: Option<String> = None;
78    let mut credential: Option<PathBuf> = None;
79    let mut state: Option<PathBuf> = None;
80    let mut port: Option<u16> = None;
81    let mut archival = false;
82    let mut name: Option<String> = None;
83    let mut foreground = false;
84
85    let mut i = 0;
86    while i < mine.len() {
87        let arg = mine[i];
88        let value = || -> Result<String, String> {
89            mine.get(i + 1)
90                .map(|v| (*v).to_string())
91                .ok_or_else(|| format!("{arg} needs a value"))
92        };
93        match arg {
94            "--archival" => {
95                archival = true;
96                i += 1;
97                continue;
98            }
99            "--foreground" => {
100                foreground = true;
101                i += 1;
102                continue;
103            }
104            "--credential" => credential = Some(PathBuf::from(value()?)),
105            "--state" => state = Some(PathBuf::from(value()?)),
106            "--name" => name = Some(value()?),
107            "--port" => {
108                let raw = value()?;
109                port = Some(
110                    raw.parse()
111                        .map_err(|_| format!("--port needs a port number, not {raw:?}"))?,
112                );
113            }
114            other if other.starts_with('-') => {
115                return Err(format!(
116                    "unknown option {other:?}\n\n  \
117                     daemon flags go after `--`: choir seed <home-url> -- {other} ..."
118                ));
119            }
120            positional => {
121                if home.is_some() {
122                    return Err(format!(
123                        "one home, not two: {positional:?} came after {}",
124                        home.as_deref().unwrap_or_default()
125                    ));
126                }
127                home = Some(positional.to_string());
128                i += 1;
129                continue;
130            }
131        }
132        i += 2;
133    }
134
135    let Some(home) = home else {
136        return Err("which node's log is this a seed of?\n\n    \
137                    choir seed https://<home> [--credential <file>]"
138            .to_string());
139    };
140    if !(home.starts_with("http://") || home.starts_with("https://")) {
141        return Err(format!(
142            "the home is a node URL, http:// or https://, not {home:?}"
143        ));
144    }
145    if archival && port.is_some() {
146        return Err(
147            "--archival binds nothing, so --port would never be used. Give one or the other."
148                .to_string(),
149        );
150    }
151    let name = name.unwrap_or_else(|| "seed".to_string());
152    if name.is_empty()
153        || !name
154            .chars()
155            .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c))
156    {
157        return Err(format!(
158            "--name is the principal's name at the home: ASCII letters, digits, `-`, `_` \
159             and `.`, not {name:?}"
160        ));
161    }
162
163    Ok(Options {
164        home: home.trim_end_matches('/').to_string(),
165        credential,
166        state: absolute(state.unwrap_or(default_state)),
167        port: port.unwrap_or(8417),
168        archival,
169        name,
170        foreground,
171        extra: extra.iter().map(|a| (*a).to_string()).collect(),
172    })
173}
174
175/// Resolves a state directory against the working directory.
176///
177/// The supervision file this ends up in is read by launchd or systemd,
178/// neither of which is standing where you were; see [`crate::host`].
179fn absolute(path: PathBuf) -> PathBuf {
180    std::path::absolute(&path).unwrap_or(path)
181}
182
183/// Where the daemon keeps a node's key under `repos`.
184///
185/// The seed's own key is a node key like any other: the daemon reads
186/// `<root>/.choir/node.key`, 32 secret bytes, and mints one if absent.
187/// Minted *here* rather than left to the daemon because the home has to
188/// know the public half before the daemon can read a page, which is
189/// before it ever starts.
190#[must_use]
191pub fn key_path(repos: &Path) -> PathBuf {
192    repos.join(".choir").join("node.key")
193}
194
195/// Reads the node key at `path`, or mints one there.
196///
197/// # Errors
198///
199/// Returns a description when the file exists and is not 32 bytes, or
200/// cannot be written.
201pub fn identity(path: &Path) -> Result<choir_identity::ActorKey, String> {
202    if path.exists() {
203        let bytes = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
204        let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
205            format!(
206                "{} is not a node key: it should be exactly 32 bytes",
207                path.display()
208            )
209        })?;
210        return Ok(choir_identity::ActorKey::from_secret_bytes(&bytes));
211    }
212    if let Some(parent) = path.parent() {
213        std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
214    }
215    let key = choir_identity::ActorKey::generate();
216    choir_fs::write_atomic_private(path, key.secret_bytes())
217        .map_err(|e| format!("write {}: {e}", path.display()))?;
218    Ok(key)
219}
220
221/// The public half of a key, as the keys file spells it.
222#[must_use]
223pub fn public_hex(key: &choir_identity::ActorKey) -> String {
224    key.public_key_bytes()
225        .iter()
226        .map(|b| format!("{b:02x}"))
227        .collect()
228}
229
230/// What the home's operator pastes to admit this seed, one line each.
231///
232/// Nothing seed-specific on the home: a keys line, a binding, a
233/// credential and two grants, all files it already has. Two grants
234/// rather than one because the log grant (`@node auditor`, the
235/// node-wide read `/api/log` needs) does not reach git, and a seed that
236/// cannot fetch holds a history pointing at objects it does not have.
237/// `*` is the whole node; name repositories instead to seed only some.
238#[must_use]
239pub fn registration(name: &str, hex: &str, home: &str) -> Vec<String> {
240    vec![
241        format!("keys file:   {name} {hex}"),
242        format!("binding:     choir bind {home} <home-root>/.choir/node.key {name} {hex}"),
243        format!("auth file:   {name}:<a token minted there, openssl rand -hex 32>"),
244        format!("acl file:    {name} @node auditor"),
245        format!("             {name} * read"),
246    ]
247}
248
249/// Copies the issued credential into the state directory at 0600.
250///
251/// The unit that starts the seed must not point at a file in a download
252/// folder or a pasted-into `/tmp`; it points inside the state directory,
253/// where a backup, a `doctor` and a person looking for it will look. The
254/// file is validated as one `user:token` line first, so a wrong file is
255/// refused before anything is written.
256///
257/// # Errors
258///
259/// Returns a description when the file is not a credential or cannot be
260/// copied.
261pub fn place_credential(from: &Path, into: &Path) -> Result<String, String> {
262    let credential = choir_node::replica::Credential::read(from)?;
263    if from != into {
264        let text =
265            std::fs::read_to_string(from).map_err(|e| format!("read {}: {e}", from.display()))?;
266        choir_fs::write_atomic_private(into, text)
267            .map_err(|e| format!("write {}: {e}", into.display()))?;
268    }
269    Ok(credential.user().to_string())
270}