Skip to main content

choir_cli/
tls.rs

1//! `choir node tls` — the one step that needs root, and nothing else does.
2//!
3//! Obtaining a certificate is privileged: `certbot` writes
4//! `/etc/letsencrypt`, and the renewal hook that keeps it working lives
5//! under the same tree. Running a node is not privileged, and must not
6//! become so. `scripts/flip/setup_tls.sh` states that split for the
7//! operator's own dogfood host; this is the same split for somebody who
8//! installed a binary and has no checkout to run a script out of.
9//!
10//! # Why this is a separate command rather than something `host` sudoes
11//!
12//! `choir host --domain <name>` prints one `sudo` line and stops. It
13//! does not shell out to `sudo` itself, for two reasons that are the
14//! same reason twice: a tool that escalates on your behalf has to be
15//! trusted about *what* it escalated, and the only honest way to show
16//! that is to make the privileged thing a command with a name, so it
17//! appears in `--help`, in the shell history, and in `sudo`'s log as
18//! itself rather than as an argument to something else.
19//!
20//! # What renewal does
21//!
22//! The daemon reads its certificate once, when it binds. There is no
23//! reload; a rotated pair reaches the running node only through a
24//! restart. So the deploy hook `certbot` runs after every successful
25//! renewal is what closes the loop: it re-projects the pair into the
26//! node user's state directory and restarts the user unit. Nothing here
27//! runs on a timer of ours — `certbot`'s own timer is the schedule.
28//!
29//! # Examples
30//!
31//! ```
32//! use choir_cli::tls::Plan;
33//!
34//! let plan = Plan::new("node.example", 8417, "choir", std::path::Path::new("/home/choir"));
35//! // The marker the node reads is two lines under the node's own state
36//! // directory, never a path into /etc/letsencrypt: the live directory
37//! // is root-owned by design, and an unprivileged node that reads it
38//! // works exactly until the first renewal rotates the files.
39//! assert!(plan.marker.ends_with("tls.enabled"));
40//! assert!(plan.cert.starts_with("/home/choir/.choir/tls"));
41//! ```
42
43use std::path::{Path, PathBuf};
44
45/// Where `certbot`'s deploy hooks live. Fixed by certbot, not by us.
46pub const HOOK_DIR: &str = "/etc/letsencrypt/renewal-hooks/deploy";
47
48/// The name of the hook this installs, and the one `node uninstall`
49/// names when it says what it left behind.
50pub const HOOK_NAME: &str = "choir-tls";
51
52/// Everything the certificate step will touch, built before anything is
53/// done so a refusal can name all of it.
54#[derive(Debug, Clone, Eq, PartialEq)]
55pub struct Plan {
56    /// The name the certificate is for.
57    pub domain: String,
58    /// The port the node serves on. Part of the public URL, not of the
59    /// certificate.
60    pub port: u16,
61    /// The unprivileged account the node runs as.
62    pub user: String,
63    /// That account's `~/.choir`.
64    pub state: PathBuf,
65    /// Where the readable copy of the pair is projected.
66    pub tls_dir: PathBuf,
67    /// The projected certificate.
68    pub cert: PathBuf,
69    /// The projected private key.
70    pub key: PathBuf,
71    /// The two-line file `choir node serve` reads.
72    pub marker: PathBuf,
73    /// The one-line file holding the URL people outside use.
74    pub public_url: PathBuf,
75    /// The deploy hook certbot runs after every renewal.
76    pub hook: PathBuf,
77}
78
79impl Plan {
80    /// The layout for one domain under one node account's home.
81    #[must_use]
82    pub fn new(domain: &str, port: u16, user: &str, home: &Path) -> Plan {
83        let state = home.join(".choir");
84        let tls_dir = state.join("tls");
85        Plan {
86            domain: domain.to_string(),
87            port,
88            user: user.to_string(),
89            cert: tls_dir.join("fullchain.pem"),
90            key: tls_dir.join("privkey.pem"),
91            marker: state.join("tls.enabled"),
92            public_url: state.join("public-url"),
93            state,
94            tls_dir,
95            hook: PathBuf::from(HOOK_DIR).join(HOOK_NAME),
96        }
97    }
98
99    /// The URL this node will be reachable at once the pair is in place.
100    #[must_use]
101    pub fn url(&self) -> String {
102        format!("https://{}:{}", self.domain, self.port)
103    }
104
105    /// The two lines the marker holds.
106    #[must_use]
107    pub fn marker_body(&self) -> String {
108        format!("{}\n{}\n", self.cert.display(), self.key.display())
109    }
110}
111
112/// How the ACME challenge is answered.
113#[derive(Debug, Clone, Copy, Eq, PartialEq)]
114pub enum Challenge {
115    /// `--standalone` on port 80. Needs the port reachable from the
116    /// internet now *and* at every renewal, because renewals rebind it.
117    Http01,
118    /// `--dns-cloudflare`, selected by the presence of a credentials
119    /// file rather than by a flag — the same file-as-marker discipline
120    /// as everything else here. Needs no inbound port and never
121    /// publishes the origin address, which is why it is preferred behind
122    /// a proxying CDN.
123    Dns01Cloudflare,
124}
125
126impl Challenge {
127    /// Which one this state directory selects.
128    #[must_use]
129    pub fn for_state(state: &Path) -> Challenge {
130        match state.join("cloudflare.ini").exists() {
131            true => Challenge::Dns01Cloudflare,
132            false => Challenge::Http01,
133        }
134    }
135}
136
137/// How far to go.
138#[derive(Debug, Clone, Copy, Eq, PartialEq)]
139pub enum Issuance {
140    /// Ask the real directory for a real certificate.
141    Live,
142    /// `--dry-run`: exercise the whole path against the staging
143    /// directory and write nothing. The way to find out that port 80 is
144    /// firewalled without spending a rate-limit slot on finding out.
145    DryRun,
146    /// `--test-cert`: a real file, from the staging directory, that no
147    /// client will trust. For proving the projection and the restart.
148    Staging,
149}
150
151/// The `certbot` invocation, built but not run.
152///
153/// Returned as data so a test can assert on the exact argv without
154/// certbot, root, a domain, or a rate-limit slot. The argv *is* the
155/// contract: every flag omitted here is a prompt certbot would have
156/// asked a script that cannot answer.
157#[must_use]
158pub fn certbot_argv(plan: &Plan, challenge: Challenge, issuance: Issuance) -> Vec<String> {
159    let mut argv: Vec<String> = vec!["certbot".into(), "certonly".into()];
160    match challenge {
161        Challenge::Http01 => argv.push("--standalone".into()),
162        Challenge::Dns01Cloudflare => {
163            argv.push("--dns-cloudflare".into());
164            argv.push("--dns-cloudflare-credentials".into());
165            argv.push(plan.state.join("cloudflare.ini").display().to_string());
166        }
167    }
168    argv.push("-d".into());
169    argv.push(plan.domain.clone());
170    argv.push("--non-interactive".into());
171    argv.push("--agree-tos".into());
172    // No email, on purpose, and this is the standing privacy rule rather
173    // than an oversight: no personal identifier goes into
174    // infrastructure. The cost is no expiry-warning mail, and the deploy
175    // hook's automation is what actually answers that; `certbot renew
176    // --dry-run` is the manual check.
177    argv.push("--register-unsafely-without-email".into());
178    match issuance {
179        Issuance::Live => {}
180        Issuance::DryRun => argv.push("--dry-run".into()),
181        Issuance::Staging => argv.push("--test-cert".into()),
182    }
183    argv
184}
185
186/// The deploy hook certbot runs after every successful renewal.
187///
188/// It reads the marker at *renewal* time rather than being written for
189/// whichever boundary was live on the day it was installed. Moving
190/// termination from the node to a proxy in front of it is an operator
191/// decision that must not require remembering to rewrite a hook: a
192/// renewal that does not reach the live listener is a certificate that
193/// expires while every file on disk says it was renewed.
194#[must_use]
195pub fn hook_script(plan: &Plan, uid: &str) -> String {
196    format!(
197        "#!/bin/sh\n\
198         # Installed by `choir node tls`. Runs after every successful\n\
199         # certbot renewal. Refreshes whatever is terminating TLS for\n\
200         # this node -- the node itself, or a proxy in front of it.\n\
201         #\n\
202         # The daemon reads its certificate once, at bind. There is no\n\
203         # reload, so the restart below is not a nicety: without it the\n\
204         # renewed pair sits on disk and the running process keeps\n\
205         # presenting the expired one.\n\
206         set -eu\n\
207         if systemctl is-active --quiet nginx 2>/dev/null; then\n\
208         \x20 systemctl reload nginx\n\
209         fi\n\
210         [ -f {marker} ] || exit 0\n\
211         install -o {user} -g {user} -m 600 \\\n\
212         \x20 /etc/letsencrypt/live/{domain}/fullchain.pem {cert}\n\
213         install -o {user} -g {user} -m 600 \\\n\
214         \x20 /etc/letsencrypt/live/{domain}/privkey.pem {key}\n\
215         sudo -u {user} XDG_RUNTIME_DIR=/run/user/{uid} \\\n\
216         \x20 systemctl --user restart choir-node.service\n",
217        marker = plan.marker.display(),
218        user = plan.user,
219        domain = plan.domain,
220        cert = plan.cert.display(),
221        key = plan.key.display(),
222    )
223}
224
225/// One step of the certificate run, for printing as it happens.
226pub struct Step {
227    /// What was attempted, in the imperative.
228    pub what: String,
229    /// How it came out.
230    pub outcome: Result<String, String>,
231}
232
233/// Everything that must be true before the first privileged byte.
234///
235/// Checked in one pass and reported together: a tool that fails on the
236/// first missing thing, is fixed, then fails on the second is a tool
237/// that gets run four times, and each of those runs asked for root.
238///
239/// # Errors
240///
241/// Returns every unmet precondition, one per line.
242pub fn preflight(plan: &Plan, challenge: Challenge, uid: &str) -> Result<(), String> {
243    let mut problems: Vec<String> = Vec::new();
244    if uid != "0" {
245        problems.push(format!(
246            "this needs root — certbot writes /etc/letsencrypt:\n      \
247             sudo choir node tls {} --user {}",
248            plan.domain, plan.user
249        ));
250    }
251    if crate::doctor::on_path("certbot").is_none() {
252        problems.push(
253            "certbot is not installed:\n      \
254             sudo apt-get install -y certbot   (Debian/Ubuntu)\n      \
255             sudo dnf install -y certbot       (Fedora/RHEL)"
256                .to_string(),
257        );
258    }
259    if !plan.state.exists() {
260        problems.push(format!(
261            "{} does not exist, so there is no node here to enable TLS for:\n      \
262             run `choir host` as {} first",
263            plan.state.display(),
264            plan.user
265        ));
266    }
267    if challenge == Challenge::Dns01Cloudflare {
268        let ini = plan.state.join("cloudflare.ini");
269        if !mode_is_private(&ini) {
270            problems.push(format!(
271                "{} must be mode 0600; it holds an API token:\n      chmod 600 {}",
272                ini.display(),
273                ini.display()
274            ));
275        }
276    }
277    match problems.is_empty() {
278        true => Ok(()),
279        false => Err(problems.join("\n\n  ")),
280    }
281}
282
283/// Whether a file exists and nothing outside its owner can read it.
284#[cfg(unix)]
285fn mode_is_private(path: &Path) -> bool {
286    use std::os::unix::fs::PermissionsExt;
287    std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o077 == 0)
288}
289
290#[cfg(not(unix))]
291fn mode_is_private(path: &Path) -> bool {
292    path.exists()
293}
294
295/// Performs the privileged half, in the order that survives a failure at
296/// any point.
297///
298/// Marker before the hook runs, hook installed before the first
299/// projection: the hook reads the marker to decide whether the node
300/// needs the pair at all, so running it first would skip the projection
301/// and leave a marker naming two files that are not there. Running the
302/// hook *is* the first projection — proving the renewal path today,
303/// rather than at a renewal two months from now when nobody is watching.
304///
305/// # Errors
306///
307/// Returns the steps completed so far alongside the failure, so the
308/// caller can print what was done as well as what stopped.
309pub fn apply(
310    plan: &Plan,
311    challenge: Challenge,
312    issuance: Issuance,
313    uid: &str,
314) -> Result<Vec<Step>, (Vec<Step>, String)> {
315    let mut steps: Vec<Step> = Vec::new();
316    let push = |steps: &mut Vec<Step>, what: &str, done: String, r: Result<(), String>| {
317        let failed = r.as_ref().err().cloned();
318        steps.push(Step {
319            what: what.to_string(),
320            outcome: match &failed {
321                None => Ok(done),
322                Some(error) => Err(error.clone()),
323            },
324        });
325        failed
326    };
327
328    let argv = certbot_argv(plan, challenge, issuance);
329    let issued = match issuance {
330        Issuance::Live => "issued".to_string(),
331        Issuance::DryRun => "dry run passed; nothing was written".to_string(),
332        Issuance::Staging => "issued from staging; no client will trust it".to_string(),
333    };
334    if let Some(error) = push(
335        &mut steps,
336        &format!("certificate for {}", plan.domain),
337        issued,
338        run(&argv),
339    ) {
340        return Err((steps, error));
341    }
342    // A dry run proves reachability and stops. Writing a marker for a
343    // certificate that was deliberately not issued would hand the node a
344    // public bind and two paths that do not exist.
345    if issuance == Issuance::DryRun {
346        return Ok(steps);
347    }
348
349    let projected = plan.tls_dir.display().to_string();
350    for (what, done, result) in [
351        (
352            "readable copy",
353            projected.clone(),
354            std::fs::create_dir_all(&plan.tls_dir)
355                .map_err(|e| format!("create {}: {e}", plan.tls_dir.display())),
356        ),
357        (
358            "renewal hook",
359            plan.hook.display().to_string(),
360            write_hook(plan, uid),
361        ),
362        (
363            "marker",
364            plan.marker.display().to_string(),
365            write_as_node(plan, &plan.marker, &plan.marker_body()),
366        ),
367        (
368            "public url",
369            plan.url(),
370            write_as_node(plan, &plan.public_url, &format!("{}\n", plan.url())),
371        ),
372        (
373            "first projection",
374            projected,
375            run(&[plan.hook.display().to_string()]),
376        ),
377    ] {
378        if let Some(error) = push(&mut steps, what, done, result) {
379            return Err((steps, error));
380        }
381    }
382    Ok(steps)
383}
384
385/// Writes the deploy hook, executable, owned by root by virtue of who is
386/// running.
387fn write_hook(plan: &Plan, uid: &str) -> Result<(), String> {
388    let dir = Path::new(HOOK_DIR);
389    std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
390    choir_fs::write_atomic(&plan.hook, hook_script(plan, uid))
391        .map_err(|e| format!("write {}: {e}", plan.hook.display()))?;
392    #[cfg(unix)]
393    {
394        use std::os::unix::fs::PermissionsExt;
395        std::fs::set_permissions(&plan.hook, std::fs::Permissions::from_mode(0o755))
396            .map_err(|e| format!("chmod {}: {e}", plan.hook.display()))?;
397    }
398    Ok(())
399}
400
401/// Writes a file into the node's state directory and hands it to the
402/// node's account.
403///
404/// This process is root, so a file it creates belongs to root — and the
405/// node deliberately does not. A marker the node cannot read is a node
406/// that quietly starts on loopback and never says why.
407fn write_as_node(plan: &Plan, path: &Path, body: &str) -> Result<(), String> {
408    choir_fs::write_atomic_private(path, body)
409        .map_err(|e| format!("write {}: {e}", path.display()))?;
410    run(&[
411        "chown".to_string(),
412        format!("{}:{}", plan.user, plan.user),
413        path.display().to_string(),
414    ])
415}
416
417/// Runs one command, returning its last line of stderr on failure.
418///
419/// The last line rather than all of it: `certbot` narrates, and the
420/// sentence a reader needs — the challenge that failed, the port that
421/// was not reachable — is the one it ends on.
422fn run(argv: &[String]) -> Result<(), String> {
423    let Some((program, args)) = argv.split_first() else {
424        return Ok(());
425    };
426    let out = std::process::Command::new(program)
427        .args(args)
428        .output()
429        .map_err(|e| format!("could not run `{program}`: {e}"))?;
430    if out.status.success() {
431        return Ok(());
432    }
433    let text = String::from_utf8_lossy(&out.stderr);
434    let detail = text.trim().lines().last().unwrap_or("failed").to_string();
435    Err(format!("`{}` failed: {detail}", argv.join(" ")))
436}
437
438/// This process's user id, as a string, for the root check and the hook.
439///
440/// `id -u` rather than a libc call: the workspace adds dependencies
441/// reluctantly and this is asked once per command, not per request.
442#[must_use]
443pub fn uid() -> String {
444    std::process::Command::new("id")
445        .arg("-u")
446        .output()
447        .ok()
448        .filter(|out| out.status.success())
449        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
450        .unwrap_or_default()
451}
452
453/// The expiry date `openssl` reads out of a certificate.
454///
455/// `openssl x509`, not a parser: the workspace already requires
456/// `openssl` for everything key-shaped and adding an X.509 crate to read
457/// one field would be a supply-chain edge bought for a date.
458///
459/// # Errors
460///
461/// Returns a description when the file cannot be read or is not a
462/// certificate.
463pub fn expiry(cert: &Path) -> Result<String, String> {
464    let out = std::process::Command::new("openssl")
465        .args(["x509", "-enddate", "-noout", "-in"])
466        .arg(cert)
467        .output()
468        .map_err(|e| format!("could not run `openssl`: {e}"))?;
469    if !out.status.success() {
470        return Err(format!("{} is not a certificate", cert.display()));
471    }
472    let text = String::from_utf8_lossy(&out.stdout);
473    text.trim()
474        .strip_prefix("notAfter=")
475        .map(str::to_string)
476        .ok_or_else(|| format!("`openssl x509` said {:?}", text.trim()))
477}