Skip to main content

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}