use super::{Doctor, Finding, Verdict};
use hennery_gateway::key::{CREDENTIAL_NAME, GIVE_UP, KEY_ENV};
use std::os::unix::fs::MetadataExt;
use std::path::Path;
pub const KEY_BYTES: u64 = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Secret {
Absent,
Unknown(String),
Link,
NotAFile,
Shared,
OtherOwner(u32),
Unreadable,
HardLinked,
WrongSize(u64),
Fine,
}
pub fn judge(path: &Path, owner: u32, hard_links_matter: bool) -> Secret {
let meta = match path.symlink_metadata() {
Ok(meta) => meta,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Secret::Absent,
Err(err) => return Secret::Unknown(err.to_string()),
};
if meta.file_type().is_symlink() {
Secret::Link
} else if !meta.file_type().is_file() {
Secret::NotAFile
} else if meta.mode() & 0o077 != 0 {
Secret::Shared
} else if meta.uid() != owner {
Secret::OtherOwner(meta.uid())
} else if meta.mode() & 0o400 == 0 {
Secret::Unreadable
} else if hard_links_matter && meta.nlink() != 1 {
Secret::HardLinked
} else if meta.len() != KEY_BYTES {
Secret::WrongSize(meta.len())
} else {
Secret::Fine
}
}
pub fn secret_files(doctor: &Doctor) -> Finding {
let Some(collector) = &doctor.dirs.collector else {
return Finding::NotRun {
number: 18,
why: "no collector data directory",
};
};
let mut verdict = Verdict::default();
let owner = match collector.metadata() {
Ok(meta) => meta.uid(),
Err(err) => {
verdict.warn(
format!("{} cannot be looked at: {err}", collector.display()),
"run doctor as the collector's user",
);
return Finding::Checked(verdict.check(18, "secret files"));
}
};
let has_run = collector.join("hennery.db").symlink_metadata().is_ok();
let vapid = collector.join(hennery_kernel::push::VAPID_KEY_FILE);
match judge(&vapid, owner, false) {
Secret::Absent if has_run => verdict.warn(
format!(
"{} is missing, though the collector has run: its next start makes a new one, and every push subscription made with the old one stops working",
vapid.display()
),
"restore vapid.key from a backup before the collector starts again; without one, every device must subscribe again",
),
Secret::Absent => verdict.ok("no vapid.key yet: the collector makes it on its first start"),
Secret::Fine => verdict.ok("vapid.key is private"),
bad => refused(
&mut verdict,
&vapid,
bad,
"every push subscription depends on it: restore it from a backup, or remove it and subscribe every device again",
"remove it and subscribe every device again",
),
}
let master = collector.join(hennery_gateway::key::KEY_FILE);
match judge(&master, owner, true) {
Secret::Absent if has_run => verdict.warn(
format!(
"{} is missing: if the gateway has stored credentials, the collector refuses to start until it is restored, or supplied by {KEY_ENV} or the systemd credential {CREDENTIAL_NAME}",
master.display()
),
format!("restore master.key from a backup, or supply the key through {KEY_ENV}. {GIVE_UP}"),
),
Secret::Absent => verdict.ok(format!(
"no master.key: the gateway makes one on its first start, unless {KEY_ENV} or the systemd credential {CREDENTIAL_NAME} supplies it"
)),
Secret::Fine => verdict.ok("master.key is private"),
Secret::WrongSize(64) => refused(
&mut verdict,
&master,
Secret::WrongSize(64),
&format!(
"64 bytes may be the key written as hex: supply it through {KEY_ENV} instead, or restore the file from a backup. {GIVE_UP}"
),
GIVE_UP,
),
bad => refused(
&mut verdict,
&master,
bad,
&format!(
"the gateway's stored credentials open only with it: restore it from a backup (unless {KEY_ENV} or the credential supplies the key: then this file is unused). {GIVE_UP}"
),
GIVE_UP,
),
}
Finding::Checked(verdict.check(18, "secret files"))
}
fn refused(verdict: &mut Verdict, path: &Path, bad: Secret, lost: &str, leaked: &str) {
let path = path.display();
let (summary, fix) = match bad {
Secret::Unknown(err) => {
verdict.warn(
format!("{path} cannot be looked at: {err}"),
"run doctor as the collector's user",
);
return;
}
Secret::Link => (
format!("{path} is a symbolic link, which the collector refuses"),
format!("put the file itself at {path}, mode 600"),
),
Secret::NotAFile => (
format!("{path} is not a regular file"),
format!("put the key file itself at {path}; {lost}"),
),
Secret::Shared => (
format!("{path} can be read or changed by other users, so the collector will not start"),
format!("run `chmod 600 {path}`. If it may have leaked: {leaked}"),
),
Secret::OtherOwner(uid) => (
format!("{path} belongs to uid {uid}, not to the collector directory's owner"),
"make the collector's user its owner (`chown`), mode 600".to_string(),
),
Secret::Unreadable => (
format!("{path} cannot be read by its owner, so the collector cannot open it (unless it runs as root)"),
format!("run `chmod 600 {path}`"),
),
Secret::HardLinked => (
format!(
"{path} has other hard links, a second way to read or replace it (and no backup: they are the same file)"
),
format!("find them with `find <its filesystem> -xdev -samefile {path}`, and remove all but this one"),
),
Secret::WrongSize(bytes) => (
format!("{path} holds {bytes} bytes, not {KEY_BYTES}: it is not a key the collector takes"),
lost.to_string(),
),
Secret::Absent | Secret::Fine => return,
};
verdict.fail(summary, fix);
}