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}