choir_cli/host.rs
1//! `choir host` — a fresh machine to a running node, in one command.
2//!
3//! Everything this does was already possible: `choir init`, then a
4//! certificate, then `choir node install`, then a wait, then `choir repo
5//! create`, then `choir invite`. Each is small. The problem was never
6//! any one of them — it was that the *order* differs by what the machine
7//! is, and the machine's owner is the one person who cannot be expected
8//! to know which order applies to them before they have run anything.
9//!
10//! So there are three shapes and one command:
11//!
12//! | you have | you run | you get |
13//! |:--|:--|:--|
14//! | a laptop | `choir host` | loopback, no certificate, seconds |
15//! | a name pointing here | `choir host --domain <name>` | `https://<name>:8417` |
16//! | a VPS and no name | `choir host --public` | `https://<ip>.sslip.io:8417` |
17//!
18//! # Why `choir host` and not `choir node host`
19//!
20//! `choir node …` is the family for a node that exists — serve it, stop
21//! it, read its log. This is the command you run when there is no node,
22//! which is the same reason `choir init` is not `choir node init`. It
23//! sits beside `init` in "getting started", and `init` is what it calls.
24//!
25//! # Invariant 9 is the whole difficulty
26//!
27//! The daemon refuses a non-loopback bind without TLS. That is not a
28//! setting; it is the privacy rule written as code. Which means mode 2
29//! and mode 3 are not "the same thing with a different bind address" —
30//! they are a certificate first and a node second, and this module's
31//! real job is to make the certificate step legible rather than to hide
32//! it. It never runs `sudo` on your behalf. It prints the one line and
33//! stops.
34//!
35//! # Examples
36//!
37//! ```
38//! use choir_cli::host::Exposure;
39//!
40//! // A magic-DNS name so a box with no domain can still be issued a
41//! // certificate with zero DNS work. Dashes, not dots: one label under
42//! // the registrable domain, which is the form the Public Suffix List
43//! // entry covers without ambiguity.
44//! assert_eq!(
45//! Exposure::Public("203.0.113.7".into()).name().as_deref(),
46//! Some("203-0-113-7.sslip.io")
47//! );
48//! assert_eq!(Exposure::Local.name(), None);
49//! ```
50
51use std::path::PathBuf;
52
53/// The magic-DNS provider used when there is no domain.
54///
55/// `sslip.io` rather than `nip.io`: since 2025 both are operated by the
56/// same maintainer and share one Let's Encrypt rate-limit pool, so there
57/// is no availability to be gained by preferring one, and `sslip.io` is
58/// the one whose own documentation demonstrates the issuance path. That
59/// shared pool is also why `--public-name` exists — the limit has been
60/// exhausted before, and a rate-limited third party must never be the
61/// only way to stand a node up.
62pub const MAGIC_DNS: &str = "sslip.io";
63
64/// How this node will be reached.
65#[derive(Debug, Clone, Eq, PartialEq)]
66pub enum Exposure {
67 /// Loopback. No certificate, because none is needed and none is
68 /// possible: nobody outside can reach it, which is the point.
69 Local,
70 /// A name the user already owns, pointing at this box.
71 Domain(String),
72 /// A public IPv4 address with no name, wearing a magic-DNS one.
73 Public(String),
74 /// A name the user named outright, for when the magic-DNS provider
75 /// is rate-limited or unwanted.
76 Named(String),
77}
78
79impl Exposure {
80 /// The name a certificate would be issued for, if any.
81 #[must_use]
82 pub fn name(&self) -> Option<String> {
83 match self {
84 Exposure::Local => None,
85 Exposure::Domain(name) | Exposure::Named(name) => Some(name.clone()),
86 // Dashes rather than dots. Both forms resolve, and the
87 // dashed one is a single label under the registrable domain
88 // — the shape the Public Suffix List entry covers cleanly,
89 // and the shape that cannot be mistaken for a subdomain
90 // delegation by anything in the path.
91 Exposure::Public(ip) => Some(format!("{}.{MAGIC_DNS}", ip.replace('.', "-"))),
92 }
93 }
94
95 /// The URL people outside will use.
96 #[must_use]
97 pub fn url(&self, port: u16) -> String {
98 match self.name() {
99 Some(name) => format!("https://{name}:{port}"),
100 None => format!("http://127.0.0.1:{port}"),
101 }
102 }
103
104 /// Whether a certificate has to exist before the node may bind.
105 #[must_use]
106 pub fn needs_certificate(&self) -> bool {
107 self.name().is_some()
108 }
109}
110
111/// What `choir host` was asked to do.
112#[derive(Debug, Clone, Eq, PartialEq)]
113pub struct Options {
114 /// Loopback, a domain, or a magic-DNS name.
115 pub exposure: Exposure,
116 /// The port to serve on.
117 pub port: u16,
118 /// The state directory, `~/.choir` unless given.
119 pub state: PathBuf,
120 /// A first repository to create, if one was named.
121 pub repo: Option<String>,
122 /// A first person to invite, if one was named.
123 pub invite: Option<String>,
124 /// Do not stop to ask about linger; warn and carry on.
125 pub yes: bool,
126 /// Stop after the certificate would have been requested, having
127 /// requested nothing. `certbot --dry-run` underneath.
128 pub dry_run: bool,
129 /// Do not hand the node to a service manager; become it.
130 ///
131 /// For a container, where the runtime is the supervisor and PID 1
132 /// should be the daemon. Everything up to and including the address
133 /// still happens; then this `exec`s `choir node serve`, so the
134 /// process the runtime watches is `choir-node` itself.
135 pub foreground: bool,
136 /// Daemon flags, passed through after `--`.
137 pub extra: Vec<String>,
138}
139
140/// Parses `choir host`'s arguments.
141///
142/// # Errors
143///
144/// Returns a usage sentence naming the flag that was wrong. Combinations
145/// that contradict each other — two exposures, a public address on a
146/// machine that has none — are refused here rather than half-applied,
147/// because the first thing this command does is write files.
148pub fn parse(rest: &[&str], default_state: PathBuf) -> Result<Options, String> {
149 let (mine, extra) = match rest.iter().position(|a| *a == "--") {
150 Some(at) => (&rest[..at], &rest[at + 1..]),
151 None => (rest, &rest[rest.len()..]),
152 };
153 let mut domain: Option<String> = None;
154 let mut named: Option<String> = None;
155 let mut ip: Option<String> = None;
156 let mut public = false;
157 let mut port: Option<u16> = None;
158 let mut state: Option<PathBuf> = None;
159 let mut repo: Option<String> = None;
160 let mut invite: Option<String> = None;
161 let mut yes = false;
162 let mut dry_run = false;
163 let mut foreground = false;
164
165 let mut i = 0;
166 while i < mine.len() {
167 let name = mine[i];
168 let value = || -> Result<String, String> {
169 mine.get(i + 1)
170 .map(|v| (*v).to_string())
171 .ok_or_else(|| format!("{name} needs a value"))
172 };
173 match name {
174 "--public" => {
175 public = true;
176 i += 1;
177 continue;
178 }
179 "--yes" => {
180 yes = true;
181 i += 1;
182 continue;
183 }
184 "--dry-run" => {
185 dry_run = true;
186 i += 1;
187 continue;
188 }
189 "--foreground" => {
190 foreground = true;
191 i += 1;
192 continue;
193 }
194 "--domain" => domain = Some(value()?),
195 "--public-name" => named = Some(value()?),
196 "--ip" => ip = Some(value()?),
197 "--repo" => repo = Some(value()?),
198 "--invite" => invite = Some(value()?),
199 "--state" => state = Some(PathBuf::from(value()?)),
200 "--port" => {
201 let raw = value()?;
202 port = Some(
203 raw.parse()
204 .map_err(|_| format!("--port needs a port number, not {raw:?}"))?,
205 );
206 }
207 other => {
208 return Err(format!(
209 "unknown option {other:?}\n\n \
210 daemon flags go after `--`: choir host -- {other} ..."
211 ));
212 }
213 }
214 i += 2;
215 }
216
217 let chosen = [domain.is_some(), named.is_some(), public || ip.is_some()]
218 .iter()
219 .filter(|c| **c)
220 .count();
221 if chosen > 1 {
222 return Err(
223 "--domain, --public and --public-name are three answers to one question:\n \
224 what name does a certificate go on. Give one."
225 .to_string(),
226 );
227 }
228
229 let exposure = if let Some(name) = domain {
230 Exposure::Domain(name)
231 } else if let Some(name) = named {
232 Exposure::Named(name)
233 } else if public || ip.is_some() {
234 let given = ip.is_some();
235 let address = match ip {
236 Some(given) => given,
237 None => detect_address()?,
238 };
239 if !is_public_v4(&address) {
240 // Two different mistakes, and the same sentence for both
241 // would be wrong for one of them. A detected private address
242 // means this box is behind NAT and genuinely does not know
243 // the address the world uses; a *given* one means the
244 // address handed over is not routable, and repeating the
245 // NAT explanation would send the reader looking for a
246 // problem they have already been told the answer to.
247 return Err(match given {
248 true => format!(
249 "{address} is not routable on the public internet, so no certificate \
250 can be issued for it.\n\n \
251 private, loopback, link-local, carrier-grade NAT (100.64/10, which is \
252 also\n every Tailscale address) and the documentation ranges are all \
253 refused."
254 ),
255 false => format!(
256 "{address} is this box's own address and it is not a public one, so no \
257 certificate\n can be issued for it.\n\n \
258 behind NAT, the address the world uses is not one this box can see. \
259 Find it:\n\n \
260 curl -fsS https://api.ipify.org\n\n \
261 then: choir host --public --ip <that address>"
262 ),
263 });
264 }
265 Exposure::Public(address)
266 } else {
267 Exposure::Local
268 };
269
270 // `--foreground` becomes the daemon, so there is no "afterwards" in
271 // this process for either of these to happen in. Refused rather than
272 // silently dropped: a flag that is accepted and does nothing is
273 // worse than one that is not accepted.
274 if foreground && (repo.is_some() || invite.is_some()) {
275 return Err(
276 "--foreground execs the daemon, so nothing runs after it — --repo and\n \
277 --invite would never happen. Run them against the node once it is up:\n\n \
278 choir repo create <api> <owner/name.git>\n \
279 choir invite <api> <name> <owner/name.git>"
280 .to_string(),
281 );
282 }
283 if invite.is_some() && repo.is_none() {
284 return Err(
285 "--invite needs --repo: an invite carries a grant, and a grant names a\n \
286 repository. Without one the link would let somebody in to nothing.\n\n \
287 choir host --repo me/thing.git --invite <their name>"
288 .to_string(),
289 );
290 }
291 if dry_run && !exposure.needs_certificate() {
292 return Err(
293 "--dry-run is about the certificate, and a local node has none.\n \
294 Use it with --domain or --public."
295 .to_string(),
296 );
297 }
298
299 Ok(Options {
300 exposure,
301 port: port.unwrap_or(8417),
302 state: absolute(state.unwrap_or(default_state)),
303 repo,
304 invite,
305 yes,
306 dry_run,
307 foreground,
308 extra: extra.iter().map(|a| (*a).to_string()).collect(),
309 })
310}
311
312/// Resolves a state directory against the working directory.
313///
314/// A relative `--state` is a perfectly reasonable thing to type and a
315/// broken thing to record: the supervision file this ends up in is read
316/// by launchd or systemd, neither of which is standing where you were.
317/// The node then starts against a root that does not exist, the unit
318/// restarts it every two seconds, and the only sign is an empty log at a
319/// path that is itself relative.
320///
321/// `std::path::absolute` rather than `canonicalize`: the directory does
322/// not exist yet on a first run, and `canonicalize` fails on a path that
323/// is not there.
324fn absolute(path: PathBuf) -> PathBuf {
325 std::path::absolute(&path).unwrap_or(path)
326}
327
328/// This machine's own IPv4 address on the route to the outside.
329///
330/// A connectionless UDP socket, not `ifconfig` parsing and not a call to
331/// somebody's what-is-my-ip service. `connect` on a UDP socket sends
332/// nothing; it only makes the kernel pick the source address it *would*
333/// use, which is exactly the question. The address it names is
334/// `192.0.2.1` — TEST-NET-1, reserved for documentation and routed
335/// nowhere — so even a stray packet would reach no one.
336///
337/// Behind NAT this answers with the private address, which is the honest
338/// answer: this box genuinely does not know its public one, and guessing
339/// would produce a certificate request for a name that resolves
340/// somewhere else.
341///
342/// # Errors
343///
344/// Returns a description when no route to the outside can be found.
345pub fn detect_address() -> Result<String, String> {
346 let socket = std::net::UdpSocket::bind("0.0.0.0:0")
347 .map_err(|e| format!("could not open a socket to ask which address this box uses: {e}"))?;
348 socket
349 .connect("192.0.2.1:80")
350 .map_err(|e| format!("no route to the outside from this box: {e}"))?;
351 let local = socket
352 .local_addr()
353 .map_err(|e| format!("could not read this box's own address: {e}"))?;
354 Ok(local.ip().to_string())
355}
356
357/// Whether an address is one the public internet can route to.
358///
359/// Only IPv4: `sslip.io` encodes v6 too, but a v6-only box that reaches
360/// this path deserves to be told so rather than handed a name built by a
361/// rule this has not been exercised against.
362#[must_use]
363pub fn is_public_v4(address: &str) -> bool {
364 let Ok(std::net::IpAddr::V4(ip)) = address.parse::<std::net::IpAddr>() else {
365 return false;
366 };
367 let [a, b, ..] = ip.octets();
368 !(ip.is_private()
369 || ip.is_loopback()
370 || ip.is_link_local()
371 || ip.is_broadcast()
372 || ip.is_documentation()
373 || ip.is_unspecified()
374 || ip.is_multicast()
375 // Carrier-grade NAT, 100.64.0.0/10. Common on cheap VPS and on
376 // every Tailscale interface, and a certificate for it would be
377 // issued for a name the world resolves to somebody else's box.
378 || (a == 100 && (64..128).contains(&b))
379 || a >= 240)
380}
381
382/// The firewall this machine runs, and the one line that opens a port.
383///
384/// Detected and printed, never run. Opening a port is a change to how
385/// much of this machine the internet can reach, and that decision is not
386/// one a convenience command gets to make silently — even when it is
387/// obviously the right one, which it usually is.
388#[must_use]
389pub fn firewall_hint(port: u16, needs_http01: bool) -> Option<String> {
390 if crate::doctor::on_path("ufw").is_some() {
391 let mut line = format!("sudo ufw allow {port}/tcp");
392 if needs_http01 {
393 line.push_str(" && sudo ufw allow 80/tcp");
394 }
395 return Some(line);
396 }
397 if crate::doctor::on_path("firewall-cmd").is_some() {
398 let mut line = format!("sudo firewall-cmd --permanent --add-port={port}/tcp");
399 if needs_http01 {
400 line.push_str(" && sudo firewall-cmd --permanent --add-port=80/tcp");
401 }
402 line.push_str(" && sudo firewall-cmd --reload");
403 return Some(line);
404 }
405 None
406}
407
408/// Whether this user's services survive a logout.
409///
410/// `None` on anything that is not `systemd --user` — a launchd agent has
411/// no equivalent question, and answering one that was not asked is how a
412/// report gets ignored.
413#[must_use]
414pub fn linger(user: &str) -> Option<bool> {
415 if std::env::consts::OS != "linux" {
416 return None;
417 }
418 let out = std::process::Command::new("loginctl")
419 .args(["show-user", user])
420 .output()
421 .ok()?;
422 if !out.status.success() {
423 return None;
424 }
425 Some(
426 String::from_utf8_lossy(&out.stdout)
427 .lines()
428 .any(|line| line.trim() == "Linger=yes"),
429 )
430}
431
432/// This process's login name.
433#[must_use]
434pub fn username() -> String {
435 std::process::Command::new("id")
436 .arg("-un")
437 .output()
438 .ok()
439 .filter(|out| out.status.success())
440 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
441 .unwrap_or_default()
442}
443
444/// How to let somebody else reach a loopback node.
445///
446/// A loopback node is not reachable from another machine, and that is
447/// invariant 9 doing its job rather than a limitation to route around.
448/// Both answers here keep it that way: an ssh tunnel and `tailscale
449/// serve` each terminate somewhere else and forward to `127.0.0.1`, so
450/// the node never binds an address the internet can see.
451#[must_use]
452pub fn share_hint(port: u16) -> String {
453 if crate::doctor::on_path("tailscale").is_some() {
454 return format!(
455 "tailscale serve --bg http://localhost:{port}\n \
456 (HTTPS inside your tailnet; the node itself stays on loopback)"
457 );
458 }
459 format!(
460 "ssh -N -L {port}:127.0.0.1:{port} <you>@<this-machine>\n \
461 then the node is at http://127.0.0.1:{port} on the other end"
462 )
463}
464
465/// Waits for the node to answer `/healthz`.
466///
467/// Polled rather than assumed, because everything after this point —
468/// creating a repository, minting an invite — is a request to a daemon a
469/// service manager has only just been asked to start, and the failure
470/// when it is not up yet is a connection refused that reads like a
471/// broken install.
472///
473/// # Errors
474///
475/// Returns what the last attempt saw, after `seconds`.
476pub fn wait_healthy(client: &crate::mcp::HttpClient, seconds: u64) -> Result<(), String> {
477 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds);
478 loop {
479 let last = match client.get("/healthz") {
480 // 401 counts: something is serving and it is asking who we
481 // are, which answers the only question being asked here.
482 Ok((200 | 401, _)) => return Ok(()),
483 Ok((503, body)) => format!("503, its own durability check failed: {body}"),
484 Ok((code, _)) => format!("answered {code}"),
485 Err(error) => error,
486 };
487 if std::time::Instant::now() >= deadline {
488 return Err(last);
489 }
490 std::thread::sleep(std::time::Duration::from_millis(250));
491 }
492}