Skip to main content

choir_cli/
doctor.rs

1//! `choir doctor` — one command that answers "why did that fail?".
2//!
3//! Every other command in this binary assumes its surroundings: that
4//! `git` is on the path, that `curl` can reach a node, that the auth
5//! file is readable and not world-readable, that the node it is about
6//! to talk to is actually up. When one of those is false the failure
7//! surfaces wherever it happens to be noticed — a subprocess exit
8//! status, a `curl` error, a 401 — and the reader has to work backwards
9//! from a symptom to a cause.
10//!
11//! This module checks all of them up front and reports each one with the
12//! command that fixes it. It is the only place in the crate that treats
13//! a missing dependency as a *finding* rather than an error: nothing
14//! here aborts on the first failure, because "git is missing" and "the
15//! node is unreachable" are independently useful and a reader who has
16//! both wants both in one pass.
17//!
18//! # Examples
19//!
20//! ```
21//! use choir_cli::doctor::{Check, Status};
22//!
23//! let checks = vec![
24//!     Check::pass("git", "/usr/bin/git"),
25//!     Check::fail("mergiraf", "not on PATH").with_fix("brew install mergiraf"),
26//! ];
27//! assert_eq!(Status::worst(&checks), Status::Fail);
28//! assert_eq!(choir_cli::doctor::exit_code(&checks), 1);
29//! ```
30
31use crate::style::Style;
32use std::path::PathBuf;
33
34/// How a single check came out.
35///
36/// Three states rather than two because the difference matters to the
37/// exit code: an absent `mergiraf` narrows the merge ladder to line
38/// merge and first-class conflicts, which is a *worse* node and a
39/// working one. Failing the command for it would teach operators to
40/// ignore the command.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum Status {
43    /// Present and usable.
44    Pass,
45    /// Usable, but something is degraded or unset.
46    Warn,
47    /// Something that is required is absent or broken.
48    Fail,
49}
50
51impl Status {
52    /// The most severe status in a set, or [`Status::Pass`] for none.
53    ///
54    /// This is what decides the exit code, so it is ordering over the
55    /// enum rather than a hand-written match: adding a status variant
56    /// between the existing ones must not silently keep the old answer.
57    pub fn worst(checks: &[Check]) -> Status {
58        checks
59            .iter()
60            .map(|c| c.status)
61            .max()
62            .unwrap_or(Status::Pass)
63    }
64
65    /// The fixed-width label used in the report, without colour.
66    pub fn label(self) -> &'static str {
67        match self {
68            Status::Pass => "ok  ",
69            Status::Warn => "warn",
70            Status::Fail => "FAIL",
71        }
72    }
73
74    fn paint(self, style: Style) -> String {
75        match self {
76            Status::Pass => style.green(self.label()),
77            Status::Warn => style.cyan(self.label()),
78            Status::Fail => style.red(self.label()),
79        }
80    }
81}
82
83/// One thing that was checked, and what to do when it is wrong.
84#[derive(Debug, Clone)]
85pub struct Check {
86    /// What was checked, e.g. `git` or `node reachable`.
87    pub name: String,
88    /// How it came out.
89    pub status: Status,
90    /// What was found: a path, a version, an error, an http status.
91    pub detail: String,
92    /// The command that fixes it, when there is one to name.
93    ///
94    /// `None` for a passing check and for a failure whose fix depends on
95    /// something this process cannot know. A wrong fix is worse than no
96    /// fix: it gets run.
97    pub fix: Option<String>,
98}
99
100impl Check {
101    /// A passing check.
102    pub fn pass(name: &str, detail: impl Into<String>) -> Check {
103        Check {
104            name: name.into(),
105            status: Status::Pass,
106            detail: detail.into(),
107            fix: None,
108        }
109    }
110
111    /// A degraded-but-working check.
112    pub fn warn(name: &str, detail: impl Into<String>) -> Check {
113        Check {
114            name: name.into(),
115            status: Status::Warn,
116            detail: detail.into(),
117            fix: None,
118        }
119    }
120
121    /// A check that failed.
122    pub fn fail(name: &str, detail: impl Into<String>) -> Check {
123        Check {
124            name: name.into(),
125            status: Status::Fail,
126            detail: detail.into(),
127            fix: None,
128        }
129    }
130
131    /// Attaches the command that fixes this check.
132    pub fn with_fix(mut self, fix: impl Into<String>) -> Check {
133        self.fix = Some(fix.into());
134        self
135    }
136}
137
138/// The exit code for a set of checks: 0 when nothing failed, 1 when
139/// something did.
140///
141/// A warning does not fail the command. This follows the binary's own
142/// convention — 0 accepted, 1 rejected, 2 usage — so `choir doctor &&
143/// choir propose …` means what it looks like it means.
144pub fn exit_code(checks: &[Check]) -> i32 {
145    match Status::worst(checks) {
146        Status::Fail => 1,
147        _ => 0,
148    }
149}
150
151/// Finds an executable on `PATH`, the way exec does.
152///
153/// Reading `PATH` is not configuration — it is the environment
154/// describing the machine, the same category as the `PATH`/`HOME`
155/// pass-through `choir-queue` already relies on. Walking it here rather
156/// than shelling out to `which` costs no subprocess and gives the same
157/// answer the failing `Command::new` would have got.
158pub fn on_path(tool: &str) -> Option<PathBuf> {
159    let path = std::env::var_os("PATH")?;
160    std::env::split_paths(&path)
161        .map(|dir| dir.join(tool))
162        .find(|candidate| is_executable(candidate))
163}
164
165#[cfg(unix)]
166fn is_executable(path: &std::path::Path) -> bool {
167    use std::os::unix::fs::PermissionsExt;
168    std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
169}
170
171#[cfg(not(unix))]
172fn is_executable(path: &std::path::Path) -> bool {
173    path.is_file()
174}
175
176/// Runs `<tool> <args>` and returns its first line of output.
177///
178/// Both streams are read because the version banners disagree about
179/// which one they belong on, and a tool that prints its version to
180/// stderr is not thereby broken.
181fn first_line(tool: &std::path::Path, args: &[&str]) -> Option<String> {
182    let out = std::process::Command::new(tool).args(args).output().ok()?;
183    let text = if out.stdout.is_empty() {
184        out.stderr
185    } else {
186        out.stdout
187    };
188    String::from_utf8_lossy(&text)
189        .lines()
190        .next()
191        .map(str::to_string)
192}
193
194/// One external binary this workspace shells out to.
195struct Tool {
196    name: &'static str,
197    /// How to ask it for its version, or empty when it has no clean way
198    /// to be asked. `ssh-keygen` is the empty case: every spelling of
199    /// `--version` is an unknown flag it answers with usage.
200    version: &'static [&'static str],
201    /// What stops working without it. Empty means it is required.
202    without: &'static str,
203    fix: &'static str,
204}
205
206/// The tools every command here assumes, and what each is for.
207///
208/// Required and optional in one table rather than two, because the
209/// difference is one field and two tables drift.
210const TOOLS: &[Tool] = &[
211    Tool {
212        name: "git",
213        version: &["--version"],
214        without: "",
215        fix: "install git (xcode-select --install, or your package manager)",
216    },
217    Tool {
218        name: "curl",
219        version: &["--version"],
220        without: "",
221        fix: "install curl",
222    },
223    Tool {
224        name: "openssl",
225        version: &["version"],
226        without: "",
227        fix: "brew install openssl@3 (macOS) or apt install openssl libssl-dev (Debian)",
228    },
229    Tool {
230        name: "ssh-keygen",
231        version: &[],
232        without: "",
233        fix: "install openssh-client",
234    },
235    Tool {
236        name: "mergiraf",
237        version: &["--version"],
238        without: "structured merge; line merge and first-class conflicts still work",
239        fix: "brew install mergiraf",
240    },
241    Tool {
242        name: "mdbook",
243        version: &["--version"],
244        without: "`choir docs` cannot build the book",
245        fix: "cargo install mdbook --locked",
246    },
247];
248
249/// Checks every external binary in [`TOOLS`].
250fn tool_checks() -> Vec<Check> {
251    TOOLS
252        .iter()
253        .map(|tool| match on_path(tool.name) {
254            Some(path) => {
255                let detail = match tool.version.is_empty() {
256                    true => path.display().to_string(),
257                    false => first_line(&path, tool.version)
258                        .unwrap_or_else(|| path.display().to_string()),
259                };
260                Check::pass(tool.name, detail)
261            }
262            None if tool.without.is_empty() => {
263                Check::fail(tool.name, "not on PATH").with_fix(tool.fix)
264            }
265            None => Check::warn(
266                tool.name,
267                format!("not on PATH — without it, {}", tool.without),
268            )
269            .with_fix(tool.fix),
270        })
271        .collect()
272}
273
274/// Checks the auth file: that it exists, and that nobody else can read
275/// it.
276///
277/// The mode check is not decoration. This file carries a bearer
278/// credential for a node, and a group- or world-readable one is a
279/// credential every account on the machine holds. It is reported as a
280/// failure rather than a warning for that reason, even though every
281/// command would keep working.
282fn auth_check(path: Option<&str>) -> Check {
283    let Some(path) = path else {
284        return Check::warn("auth file", "none given, and no default found").with_fix(
285            "choir join '<link>' to be issued one, or name yours: choir --auth-file <path> …",
286        );
287    };
288    let Ok(meta) = std::fs::metadata(path) else {
289        return Check::fail("auth file", format!("{path} cannot be read"))
290            .with_fix("choir join '<link>'");
291    };
292    #[cfg(unix)]
293    {
294        use std::os::unix::fs::PermissionsExt;
295        let mode = meta.permissions().mode() & 0o777;
296        if mode & 0o077 != 0 {
297            return Check::fail("auth file", format!("{path} is mode {mode:04o}"))
298                .with_fix(format!("chmod 600 {path}"));
299        }
300        Check::pass("auth file", format!("{path} ({mode:04o})"))
301    }
302    #[cfg(not(unix))]
303    {
304        let _ = meta;
305        Check::pass("auth file", path.to_string())
306    }
307}
308
309/// Checks that a node answers, using the same credential the other
310/// commands would.
311///
312/// `/healthz` rather than `/api/view`, because the question here is "is
313/// anything serving" and `/api/view` conflates that with "may I read
314/// it". The node authenticates `/healthz` like everything else, so this
315/// goes through [`HttpClient`] rather than a bare `curl`: a doctor that
316/// reached the node differently from the commands it is diagnosing
317/// would be checking a path nobody runs, and would report every
318/// correctly authenticated node as a 401.
319///
320/// A 401 *with* a credential is therefore a real finding — the token is
321/// wrong or the node does not know it — and is reported as one.
322///
323/// [`HttpClient`]: crate::mcp::HttpClient
324fn node_check(api: Option<&str>, auth: Option<&str>, curl: bool) -> Check {
325    let Some(api) = api else {
326        return Check::warn("node", "no node configured").with_fix(
327            "choir join '<link>' writes one to ~/.choir/config; or write `node = <url>` \
328             to .choir/config here, or pass the URL",
329        );
330    };
331    if !curl {
332        return Check::fail("node", format!("{api}: cannot check without curl"));
333    }
334    let client = match crate::mcp::HttpClient::new(api, auth.map(std::path::Path::new), None) {
335        Ok(client) => client,
336        Err(error) => return Check::fail("node", format!("{api}: {error}")),
337    };
338    match client.get("/healthz") {
339        Ok((200, _)) => Check::pass("node", format!("{api} is healthy")),
340        // The node serves 503 here when its own durability check has
341        // failed. That is the node telling the truth about itself, and
342        // it is the one answer this command must not soften.
343        Ok((503, body)) => Check::fail("node", format!("{api} reports unhealthy: {body}"))
344            .with_fix("check the node's log; a failed durable append exits it 75"),
345        Ok((401, _)) if auth.is_none() => {
346            Check::warn("node", format!("{api} is up; needs a credential"))
347                .with_fix("choir --auth-file <path> doctor")
348        }
349        Ok((401, _)) => Check::fail("node", format!("{api} refused the credential"))
350            .with_fix("check --auth-file names a token this node knows"),
351        Ok((code, _)) => Check::warn("node", format!("{api} answered {code} on /healthz")),
352        Err(error) => Check::fail("node", format!("{api}: {error}")),
353    }
354}
355
356/// Runs every check.
357///
358/// `api` and `auth` are passed in rather than discovered here so the
359/// caller's own resolution — the `.choir/config` walk, the `--auth-file`
360/// flag — is the one being reported on. A doctor that resolves its
361/// inputs differently from the commands it is diagnosing is checking a
362/// configuration nobody runs.
363pub fn run(api: Option<&str>, auth: Option<&str>) -> Vec<Check> {
364    let mut checks = vec![self_check()];
365    checks.extend(tool_checks());
366    let curl = checks
367        .iter()
368        .any(|c| c.name == "curl" && c.status == Status::Pass);
369    checks.push(daemon_check());
370    checks.push(auth_check(auth));
371    checks.push(node_check(api, auth, curl));
372    checks
373}
374
375/// How many attestations a seed's statement may trail the home's current
376/// one before the fork check says so (D80).
377///
378/// A seed polls every few seconds and a home attests after every ref it
379/// moves, so a handful behind is a seed between rounds; more than this
380/// is a seed that has stopped, which is worth a warning and is not a
381/// fork.
382pub const FAR_BEHIND: u64 = 8;
383
384/// What the fork check is called in the report.
385const FORK_CHECK: &str = "fork check";
386
387/// The fork rule applied to one seed's statement (D80), with nothing
388/// fetched: the statement, the node id and current attestation the home
389/// showed this reader, and the chain read from the home's own log.
390///
391/// Pure so the one judgement that can call a home a liar is testable
392/// against a forged statement without a node that would forge one.
393#[must_use]
394pub fn fork_check(
395    seed: &str,
396    statement: &choir_node::replica::SignedStatement,
397    home_node: &str,
398    current: &str,
399    chain: &choir_node::replica::SnapshotChain,
400) -> Check {
401    use choir_node::replica::Relation;
402    if let Err(why) = statement.verify() {
403        return Check::fail(
404            FORK_CHECK,
405            format!("{seed}: its statement does not verify: {why}"),
406        );
407    }
408    let said = &statement.statement;
409    if said.home_node_id != home_node {
410        return Check::fail(
411            FORK_CHECK,
412            format!(
413                "{seed}: witnesses the home {}, and this node is {home_node}",
414                said.home_node_id
415            ),
416        )
417        .with_fix("name in `seeds =` only seeds of the node `node =` names");
418    }
419    match chain.relate(&said.snapshot, current) {
420        Relation::Same => Check::pass(
421            FORK_CHECK,
422            format!(
423                "{seed}: agrees; it witnessed the home's current attestation (seq {})",
424                said.at_seq
425            ),
426        ),
427        Relation::Behind(steps) if steps > FAR_BEHIND => Check::warn(
428            FORK_CHECK,
429            format!(
430                "{seed}: agrees, {steps} attestations behind the home's current one \
431                 (it last witnessed seq {})",
432                said.at_seq
433            ),
434        )
435        .with_fix("read `replica` in the seed's GET /api/view: it says where replication stopped"),
436        Relation::Behind(steps) => Check::pass(
437            FORK_CHECK,
438            format!(
439                "{seed}: agrees; its statement is {steps} attestation(s) behind the home's \
440                 current one"
441            ),
442        ),
443        Relation::Ahead(steps) => Check::warn(
444            FORK_CHECK,
445            format!(
446                "{seed}: agrees, but it has witnessed {steps} attestation(s) past the one the \
447                 home is showing you, so the home may be serving you an older view"
448            ),
449        ),
450        Relation::Fork => Check::fail(
451            FORK_CHECK,
452            format!(
453                "{seed}: fork: the seed witnessed {} at seq {}, which is not on the \
454                 attestation chain the home shows you (current {current}); the home has \
455                 shown two histories",
456                said.snapshot, said.at_seq
457            ),
458        )
459        .with_fix(
460            "stop acting on this home's answers; an export from the home and one from the \
461             seed (choir-node --export) show which history each holds",
462        ),
463    }
464}
465
466/// `GET` through the crate's one credential path.
467fn get(
468    url: &str,
469    auth: Option<&str>,
470    user: Option<&str>,
471    path: &str,
472) -> Result<(u16, serde_json::Value), String> {
473    let client = crate::mcp::HttpClient::new(url, auth.map(std::path::Path::new), user)?;
474    let (status, body) = client.get(path)?;
475    let value = serde_json::from_str(&body)
476        .map_err(|_| format!("{url}{path} answered {status} with a body that is not JSON"))?;
477    Ok((status, value))
478}
479
480/// What the seed says about its own copy, from its `/api/witness` body: a
481/// gap, and a halt with the seq and reason. Both are warnings and not
482/// failures, because neither says the home lied; a halt on a page that
483/// failed a check is the reader's cue to look, which is why it is named
484/// here rather than left to show as a statement falling behind.
485fn copy_rows(seed: &str, witness: &serde_json::Value) -> Vec<Check> {
486    let mut rows = Vec::new();
487    if witness["gap"] == true {
488        rows.push(
489            Check::warn(
490                "seed gap",
491                format!("{seed}: its copy has a hole, so it is a copy and not a witness"),
492            )
493            .with_fix("re-seed it from an export of the home (choir-node --import)"),
494        );
495    }
496    if let (Some(seq), Some(reason)) = (
497        witness["halted"]["seq"].as_u64(),
498        witness["halted"]["reason"].as_str(),
499    ) {
500        rows.push(
501            Check::warn(
502                "seed halted",
503                format!("{seed}: stopped replicating at seq {seq}: {reason}"),
504            )
505            .with_fix("read that page from the home yourself; a seed never skips an entry"),
506        );
507    }
508    rows
509}
510
511/// One seed's rows: what it says about its copy, and its fork check.
512///
513/// The seed is read **before** the home. An honest home's current
514/// attestation is then never older than the one the seed folded, so a
515/// statement the home's chain cannot reach is two histories rather than
516/// a race between two reads.
517fn seed_checks(
518    home: &str,
519    seed: &str,
520    auth_for: &dyn Fn(&str) -> Option<String>,
521    user: Option<&str>,
522) -> Vec<Check> {
523    let warn = |detail: String| vec![Check::warn(FORK_CHECK, format!("{seed}: {detail}"))];
524    let seed_auth = auth_for(seed);
525    let witness = match get(seed, seed_auth.as_deref(), user, "/api/witness") {
526        Ok((200, witness)) => witness,
527        Ok((status, body)) => {
528            return warn(format!(
529                "answered {status} on /api/witness, so its statement cannot be checked: {}",
530                body["error"].as_str().unwrap_or("")
531            ))
532        }
533        Err(why) => return warn(format!("cannot be read: {why}")),
534    };
535    let mut rows = copy_rows(seed, &witness);
536    let Some(statement) = choir_node::replica::SignedStatement::from_json(&witness["latest"])
537    else {
538        rows.extend(warn(
539            "has signed no statement yet: it has folded no attestation".to_string(),
540        ));
541        return rows;
542    };
543    let home_auth = auth_for(home);
544    let view = match get(home, home_auth.as_deref(), user, "/api/view") {
545        Ok((200, view)) => view,
546        Ok((status, _)) => {
547            rows.extend(warn(format!("the home answered {status} on /api/view")));
548            return rows;
549        }
550        Err(why) => {
551            rows.extend(warn(format!("the home cannot be read: {why}")));
552            return rows;
553        }
554    };
555    let home_node = view["log"]["node"].as_str().unwrap_or_default().to_string();
556    let (Some(current), Some(current_at)) = (
557        view["snapshot"]["id"].as_str().map(str::to_string),
558        view["snapshot"]["at_seq"].as_u64(),
559    ) else {
560        rows.extend(warn(
561            "the home has made no attestation to compare".to_string(),
562        ));
563        return rows;
564    };
565    // The chain from the lower of the two positions to the head, which is
566    // everything the walk between them can pass through.
567    let mut chain = choir_node::replica::SnapshotChain::default();
568    let mut from = statement.statement.at_seq.min(current_at);
569    loop {
570        match get(
571            home,
572            home_auth.as_deref(),
573            user,
574            &format!("/api/log?from={from}"),
575        ) {
576            Ok((200, page)) => {
577                let entries = page["entries"].as_array().cloned().unwrap_or_default();
578                let Some(last) = entries.last().and_then(|e| e["seq"].as_u64()) else {
579                    break;
580                };
581                chain.read_page(&entries);
582                from = last + 1;
583            }
584            Ok((status, _)) => {
585                rows.extend(warn(format!(
586                    "the home answered {status} on /api/log; walking its attestation chain \
587                     needs the node-wide read grant (`@node auditor`)"
588                )));
589                return rows;
590            }
591            Err(why) => {
592                rows.extend(warn(format!("the home's log cannot be read: {why}")));
593                return rows;
594            }
595        }
596    }
597    rows.push(fork_check(seed, &statement, &home_node, &current, &chain));
598    rows
599}
600
601/// The fork check for every seed `.choir/config` names (D80), against the
602/// node `home` is.
603///
604/// `auth_for` is the caller's own credential rule, so a seed is handed a
605/// credential exactly when every other command would hand it one.
606pub fn seeds(
607    home: Option<&str>,
608    seeds: &[String],
609    auth_for: &dyn Fn(&str) -> Option<String>,
610    user: Option<&str>,
611) -> Vec<Check> {
612    let Some(home) = home else {
613        return vec![Check::warn(
614            FORK_CHECK,
615            "seeds are configured and no node is, so there is no home to compare them with",
616        )
617        .with_fix("write `node = <url>` beside `seeds =` in .choir/config")];
618    };
619    seeds
620        .iter()
621        .flat_map(|seed| seed_checks(home, seed, auth_for, user))
622        .collect()
623}
624
625/// What this `choir` is: its version, the commit it was built from, and
626/// the file it is running as.
627///
628/// First in the report because every other line describes the machine
629/// and this one describes the thing doing the reporting. The common
630/// support question after there are two ways to obtain the binary is
631/// "which one am I running" — an old copy in `~/.cargo/bin` shadowing a
632/// new one in a build tree answers every other check identically.
633///
634/// The stamp source is carried through rather than summarised: `env`
635/// means something set `CHOIR_GIT_HEAD` at build time, which is the
636/// release workflow and the on-box installer; `git` means a build in a
637/// checkout; `unavailable` means neither, and the commit reads
638/// `unknown`. See `crates/choir-node/build.rs`.
639///
640/// Deliberately not claimed: *which* installer put it there. The shell
641/// installer writes no receipt (the self-updater that would consume one
642/// is off, on purpose), so a binary in `$CARGO_HOME/bin` could equally
643/// have come from `cargo install` or `cargo binstall`. Naming one of
644/// them would be a guess printed as a fact, in the one command whose
645/// whole job is to stop people guessing.
646fn self_check() -> Check {
647    let Ok(exe) = std::env::current_exe() else {
648        // Not a failure: the binary plainly ran. It just cannot say
649        // which file it is, which is a curiosity rather than a fault.
650        return Check::warn(
651            "choir",
652            format!(
653                "{} {} (this process cannot name its own path)",
654                env!("CARGO_PKG_VERSION"),
655                choir_node::build_line()
656            ),
657        );
658    };
659    let where_ = if crate::supervise::in_build_directory(&exe) {
660        "a build directory"
661    } else if in_cargo_home(&exe) {
662        "an install directory"
663    } else {
664        "not an install or build directory"
665    };
666    Check::pass(
667        "choir",
668        format!(
669            "{} {}, {} ({where_})",
670            env!("CARGO_PKG_VERSION"),
671            choir_node::build_line(),
672            exe.display()
673        ),
674    )
675}
676
677/// Whether a path sits in the `bin` directory both installers write to.
678///
679/// `CARGO_HOME` before `HOME/.cargo`, in that order, because that is the
680/// order the shell installer resolves it in and this has to agree with
681/// the thing it is describing.
682fn in_cargo_home(exe: &std::path::Path) -> bool {
683    let bin = match std::env::var_os("CARGO_HOME") {
684        Some(home) => PathBuf::from(home).join("bin"),
685        None => match std::env::var_os("HOME") {
686            Some(home) => PathBuf::from(home).join(".cargo").join("bin"),
687            None => return false,
688        },
689    };
690    exe.parent() == Some(bin.as_path())
691}
692
693/// The six facts that describe a machine *hosting* a node, as opposed
694/// to one talking to somebody else's.
695///
696/// Appended to [`run`] rather than folded into it, because they are only
697/// findings on a machine that has a node: reporting "linger is off" to a
698/// laptop that only ever clones would be six rows of noise on a report
699/// whose value is that every row means something.
700///
701/// The last of them is a request to this node's *public* name, made from
702/// the node itself. That is a hairpin — the packet may never leave the
703/// box — and it still answers the two questions that go wrong most
704/// often: whether the name resolves, and whether the certificate
705/// presented matches it. A firewall closed to the outside is the case it
706/// cannot see, and the firewall row is what covers that.
707#[must_use]
708pub fn host(state: &std::path::Path, auth: Option<&str>) -> Vec<Check> {
709    let layout = crate::serve::Layout::new(state, port_of_state(state));
710    let tls = layout.tls();
711    let mut checks = vec![Check::pass(
712        "bind",
713        match tls {
714            Some(_) => format!("{}:{} (reachable from outside)", layout.bind(), layout.port),
715            None => format!("127.0.0.1:{} (loopback only)", layout.port),
716        },
717    )];
718
719    checks.push(match &tls {
720        Some((cert, _)) => Check::pass("tls", format!("on, {}", cert.display())),
721        // A pass, not a warning. A loopback node without a certificate
722        // is correct rather than degraded — invariant 9 is what makes it
723        // so — and a report that cries "degraded" at every laptop is a
724        // report people stop reading.
725        // A pass, not a warning. A loopback node without a certificate
726        // is correct rather than degraded — invariant 9 is what makes it
727        // so — and a report that cries "degraded" at every laptop is a
728        // report people stop reading. The pointer goes in the detail
729        // rather than in a fix, because a fix on a passing row is a fix
730        // for a problem the row just said there isn't.
731        None => Check::pass(
732            "tls",
733            "off — not needed on loopback; `choir host --domain <name>` publishes it",
734        ),
735    });
736
737    checks.push(match &tls {
738        None => Check::pass("certificate", "none needed for a loopback node"),
739        Some((cert, _)) => match crate::tls::expiry(cert) {
740            Err(error) => Check::fail("certificate", error)
741                .with_fix("sudo choir node tls <domain> --user $(id -un)"),
742            Ok(when) => {
743                // `openssl -checkend` rather than parsing that date and
744                // doing the arithmetic here: the question is "is it
745                // still good", openssl answers it with an exit code, and
746                // a date parser written for one report is a date parser
747                // that is wrong in one time zone.
748                if !checkend(cert, 0) {
749                    Check::fail("certificate", format!("EXPIRED — was valid until {when}"))
750                        .with_fix("sudo certbot renew --force-renewal && choir node restart")
751                } else if !checkend(cert, 21 * 24 * 3600) {
752                    Check::warn("certificate", format!("expires within 21 days — {when}"))
753                        .with_fix("sudo certbot renew --dry-run   (checks the renewal path)")
754                } else {
755                    Check::pass("certificate", format!("valid until {when}"))
756                }
757            }
758        },
759    });
760
761    let user = crate::host::username();
762    checks.push(match crate::host::linger(&user) {
763        None => Check::pass("linger", "not a systemd --user machine"),
764        Some(true) => Check::pass("linger", format!("on for {user}")),
765        Some(false) => Check::fail(
766            "linger",
767            format!("off — the node dies when {user} logs out"),
768        )
769        .with_fix(format!("sudo loginctl enable-linger {user}")),
770    });
771
772    checks.push(unit_check());
773
774    checks.push(match layout.public() {
775        None => Check::pass("public url", "none; this node is not published"),
776        Some(url) => {
777            match crate::mcp::HttpClient::new(&url, auth.map(std::path::Path::new), None) {
778                Err(error) => Check::fail("public url", format!("{url}: {error}")),
779                Ok(client) => match client.get("/healthz") {
780                    Ok((200 | 401, _)) => Check::pass("public url", format!("{url} answers")),
781                    Ok((code, _)) => Check::warn("public url", format!("{url} answered {code}")),
782                    Err(error) => Check::fail("public url", format!("{url}: {error}")).with_fix(
783                        "check DNS points here, and that the port is open in the firewall",
784                    ),
785                },
786            }
787        }
788    });
789
790    checks
791}
792
793/// Whether a certificate is still valid `seconds` from now.
794fn checkend(cert: &std::path::Path, seconds: u64) -> bool {
795    std::process::Command::new("openssl")
796        .args(["x509", "-noout", "-checkend", &seconds.to_string(), "-in"])
797        .arg(cert)
798        .output()
799        .is_ok_and(|out| out.status.success())
800}
801
802/// Whether the service manager has this node loaded and running.
803fn unit_check() -> Check {
804    let Some(supervisor) = crate::supervise::Supervisor::detect() else {
805        return Check::warn(
806            "unit",
807            format!("no service manager on {}", std::env::consts::OS),
808        )
809        .with_fix("run it in the foreground: choir node serve");
810    };
811    let (program, args) = match supervisor {
812        crate::supervise::Supervisor::Launchd => (
813            "launchctl",
814            vec![
815                "print".to_string(),
816                format!("gui/{}/{}", crate::tls::uid(), crate::supervise::LABEL),
817            ],
818        ),
819        crate::supervise::Supervisor::Systemd => (
820            "systemctl",
821            vec![
822                "--user".to_string(),
823                "is-active".to_string(),
824                "choir-node.service".to_string(),
825            ],
826        ),
827    };
828    match std::process::Command::new(program).args(&args).output() {
829        Ok(out) if out.status.success() => Check::pass("unit", "loaded and running"),
830        // A warning, not a failure. An unsupervised node is a real
831        // state and a supported one — `choir node serve` in a terminal,
832        // and `choir host --foreground` in a container, where the
833        // runtime is the supervisor and there is no unit to find. That
834        // the node is *answering* is the `node` row's question, and it
835        // is the one that fails when nothing does.
836        Ok(_) => Check::warn("unit", "not loaded — this node is not supervised")
837            .with_fix("choir node install, to survive a logout and a reboot"),
838        Err(error) => Check::warn("unit", format!("could not ask {program}: {error}")),
839    }
840}
841
842/// The port a state directory's node was installed on.
843///
844/// Read back out of the public URL when there is one, so a published
845/// node reports the port it actually serves rather than the default.
846fn port_of_state(state: &std::path::Path) -> u16 {
847    let layout = crate::serve::Layout::new(state, 8417);
848    layout
849        .public()
850        .as_deref()
851        .and_then(crate::serve::port_of)
852        .unwrap_or(8417)
853}
854
855/// Whether `choir node serve` has a daemon to exec.
856///
857/// A warning rather than a failure: a machine that only ever talks to
858/// somebody else's node needs no `choir-node` at all, and failing there
859/// would tell a perfectly healthy client install that it is broken. It
860/// is here because `serve` and `install` both depend on it, and finding
861/// out at install time — from a service manager that then retries every
862/// two seconds — is the worst place to find out.
863fn daemon_check() -> Check {
864    match crate::serve::find_daemon() {
865        Ok(path) => {
866            let where_ = path.display().to_string();
867            if crate::supervise::in_build_directory(&path) {
868                // Not a failure either: running from a build tree is
869                // exactly right in a checkout. It is only a unit
870                // pointing at one that breaks, and `node install`
871                // refuses that on its own.
872                Check::warn("choir-node", format!("{where_} (a build directory)")).with_fix(
873                    "fine for a checkout; `choir node install` refuses it, so install \
874                     the pair before supervising one",
875                )
876            } else {
877                Check::pass("choir-node", where_)
878            }
879        }
880        Err(_) => Check::warn("choir-node", "not beside `choir` or on PATH")
881            .with_fix("only needed to run a node yourself: cargo build --release -p choir-node"),
882    }
883}
884
885/// The line above the checks: what this machine is set up as.
886///
887/// Separate from [`report`] rather than folded into it because the role
888/// is read off paths and `report` is handed findings. Keeping the two
889/// apart is what lets the caller's own resolution — the `.choir/config`
890/// walk, the `--auth-file` flag — be the thing reported on, which is the
891/// same reason [`run`] takes its inputs rather than discovering them.
892#[must_use]
893pub fn heading(role: crate::join::Role, style: Style) -> String {
894    format!("\n  {}\n\n", style.bold(role.line()))
895}
896
897/// Renders the checks as the report the command prints.
898///
899/// Fixes are gathered under the table rather than shown per row: the
900/// table answers "what is wrong" at a glance, and a `fix` column would
901/// wrap every long command and destroy that. Ordering is the check
902/// order, not severity — the reader is looking for a name they
903/// recognise.
904pub fn report(checks: &[Check], style: Style) -> String {
905    let width = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
906    let mut out = String::new();
907    for check in checks {
908        let name = format!("{:width$}", check.name);
909        out.push_str(&format!(
910            "  {}  {}  {}\n",
911            check.status.paint(style),
912            style.dim(&name),
913            check.detail
914        ));
915    }
916    let fixes: Vec<&Check> = checks.iter().filter(|c| c.fix.is_some()).collect();
917    if !fixes.is_empty() {
918        out.push('\n');
919        for check in fixes {
920            let fix = check.fix.as_deref().unwrap_or_default();
921            out.push_str(&format!(
922                "  {} {}\n",
923                style.dim(&format!("{}:", check.name)),
924                fix
925            ));
926        }
927    }
928    out.push('\n');
929    out.push_str(&match Status::worst(checks) {
930        Status::Fail => format!("  {}\n", style.red("something required is missing")),
931        Status::Warn => format!("  {}\n", style.cyan("usable; some things are degraded")),
932        Status::Pass => format!("  {}\n", style.green("everything checks out")),
933    });
934    out
935}
936
937#[cfg(test)]
938mod tests {
939    /// The one judgement here that can call a home a liar: a statement,
940    /// well signed, over an attestation the home's chain does not reach.
941    #[test]
942    fn a_statement_off_the_homes_chain_is_a_fork() {
943        use choir_node::replica::{SignedStatement, SnapshotChain, WitnessStatement};
944        let key = choir_identity::ActorKey::generate();
945        let statement = |snapshot: &str| {
946            SignedStatement::sign(
947                &key,
948                WitnessStatement {
949                    format_version: 1,
950                    witness: key.actor_id().to_hex(),
951                    home_node_id: "1e-home".into(),
952                    snapshot: snapshot.into(),
953                    at_seq: 2,
954                    seen_at_seq: 2,
955                },
956            )
957        };
958        let mut chain = SnapshotChain::default();
959        chain.insert("s1".into(), None);
960        chain.insert("s2".into(), Some("s1".into()));
961
962        let same = super::fork_check("seed", &statement("s2"), "1e-home", "s2", &chain);
963        assert_eq!(same.status, super::Status::Pass, "{}", same.detail);
964        let behind = super::fork_check("seed", &statement("s1"), "1e-home", "s2", &chain);
965        assert_eq!(behind.status, super::Status::Pass, "{}", behind.detail);
966        let forged = super::fork_check("seed", &statement("elsewhere"), "1e-home", "s2", &chain);
967        assert_eq!(forged.status, super::Status::Fail);
968        assert!(forged.detail.contains("fork"), "{}", forged.detail);
969        let other = super::fork_check("seed", &statement("s2"), "1e-other", "s2", &chain);
970        assert_eq!(other.status, super::Status::Fail);
971
972        let mut far = SnapshotChain::default();
973        far.insert("a0".into(), None);
974        for i in 1..=super::FAR_BEHIND + 1 {
975            far.insert(format!("a{i}"), Some(format!("a{}", i - 1)));
976        }
977        let current = format!("a{}", super::FAR_BEHIND + 1);
978        let stale = super::fork_check("seed", &statement("a0"), "1e-home", &current, &far);
979        assert_eq!(stale.status, super::Status::Warn);
980        assert!(
981            stale.detail.contains("attestations behind"),
982            "{}",
983            stale.detail
984        );
985    }
986
987    /// A halt is named by seq and reason, and a clean copy adds no row.
988    #[test]
989    fn a_halted_seed_is_named_with_the_seq_it_stopped_at() {
990        let clean = serde_json::json!({"gap": false, "halted": null});
991        assert!(super::copy_rows("seed", &clean).is_empty());
992        let halted = serde_json::json!({
993            "gap": false,
994            "halted": {"seq": 3, "reason": "entry 3 does not hash to what the page claimed"},
995        });
996        let rows = super::copy_rows("seed", &halted);
997        assert_eq!(rows.len(), 1);
998        assert_eq!(rows[0].status, super::Status::Warn);
999        assert_eq!(rows[0].name, "seed halted");
1000        assert!(rows[0].detail.contains("seq 3"), "{}", rows[0].detail);
1001        assert!(
1002            rows[0].detail.contains("does not hash"),
1003            "{}",
1004            rows[0].detail
1005        );
1006    }
1007}