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}