Skip to main content

machine_krb/
error.rs

1use std::io;
2use std::path::PathBuf;
3
4/// Errors produced by this crate.
5#[derive(Debug, thiserror::Error)]
6pub enum Error {
7    /// The program could not be spawned at all (missing binary, permissions…).
8    #[error("failed to run {program}: {source}")]
9    Spawn {
10        program: String,
11        #[source]
12        source: io::Error,
13    },
14
15    /// The program ran but exited non-zero.
16    #[error("{program} failed ({status}): {stderr}")]
17    CommandFailed {
18        program: String,
19        status: String,
20        stderr: String,
21    },
22
23    /// The program hung past the per-command cap and was killed. Typical
24    /// cause: a half-up network where the KDC resolves but packets go
25    /// nowhere, so kinit sits in its own retry cycle indefinitely.
26    #[error("{program} timed out after {seconds}s and was killed")]
27    Timeout { program: String, seconds: u64 },
28
29    /// The keytab holds no `NAME$@REALM` (machine sAMAccountName) entry.
30    #[error("no machine principal (NAME$@REALM) found in keytab {}", keytab.display())]
31    NoMachinePrincipal { keytab: PathBuf },
32
33    /// The keytab has machine principals, but none in the requested realm.
34    #[error("no machine principal for realm {realm} in keytab {}", keytab.display())]
35    NoMachinePrincipalForRealm { realm: String, keytab: PathBuf },
36
37    /// The keytab exists but cannot be read — machine keytabs are root-only.
38    /// (The io::Error is the source, not embedded here, so error chains
39    /// don't print it twice.)
40    #[error(
41        "keytab {} is not readable \
42         (machine keytabs are root-only — run as root, e.g. via the systemd service)",
43        keytab.display()
44    )]
45    KeytabUnreadable {
46        keytab: PathBuf,
47        #[source]
48        source: io::Error,
49    },
50
51    /// The keytab does not exist at all — most likely not AD-joined.
52    #[error(
53        "keytab {} not found — is this machine AD-joined? (realm join <domain>)",
54        keytab.display()
55    )]
56    KeytabMissing { keytab: PathBuf },
57
58    /// The group to share the ticket cache with does not exist.
59    #[error("group '{0}' not found — create it (`groupadd -r {0}`) or pass a different group")]
60    GroupNotFound(String),
61
62    /// Renew and mint both ran, yet the cache still fails `klist -s`.
63    #[error("ticket cache {} is still invalid after renew/mint", .0.display())]
64    CacheStillInvalid(PathBuf),
65
66    /// Refusing to take ownership of a shared system directory.
67    #[error(
68        "refusing to manage {}: not a dedicated absolute directory (shared system path, \
69         relative, or contains '..')",
70        .0.display()
71    )]
72    RefusingSharedDir(PathBuf),
73
74    /// Refusing to operate through a symlink.
75    #[error("refusing to operate on symlink {}", .0.display())]
76    RefusingSymlink(PathBuf),
77
78    #[error(transparent)]
79    Io(#[from] io::Error),
80}
81
82pub type Result<T> = std::result::Result<T, Error>;
83
84impl Error {
85    /// Whether retrying shortly might succeed (a network / KDC hiccup) versus a
86    /// permanent setup or join problem that needs an operator to fix.
87    ///
88    /// Setup errors (missing/unreadable keytab, no matching principal, missing
89    /// group, refused path, missing tool) are permanent. A `kinit`/`klist`
90    /// failure is classified from the KDC's message: a stale/disabled/deleted
91    /// machine account is permanent; unreachable-KDC / clock-skew / network is
92    /// transient. Unknown states default to transient — a retry is cheap.
93    pub fn is_transient(&self) -> bool {
94        match self {
95            Error::KeytabMissing { .. }
96            | Error::KeytabUnreadable { .. }
97            | Error::NoMachinePrincipal { .. }
98            | Error::NoMachinePrincipalForRealm { .. }
99            | Error::GroupNotFound(_)
100            | Error::RefusingSharedDir(_)
101            | Error::RefusingSymlink(_)
102            | Error::Spawn { .. } => false,
103            Error::CommandFailed { stderr, .. } => krb_error_is_transient(stderr),
104            // A hung tool is a network condition (half-up VPN), not a setup
105            // problem — retry once connectivity settles.
106            Error::Timeout { .. } => true,
107            Error::CacheStillInvalid(_) => true,
108            // A permission / read-only-filesystem failure won't heal on retry
109            // (SELinux denial, wrong mount, sandbox misconfiguration); other
110            // io errors get the benefit of the doubt. (EROFS via raw errno —
111            // ErrorKind::ReadOnlyFilesystem is still unstable.)
112            Error::Io(e) => {
113                e.kind() != io::ErrorKind::PermissionDenied && e.raw_os_error() != Some(libc::EROFS)
114            }
115        }
116    }
117}
118
119/// Does this krb5 CLI error text describe a *transient* condition (retry may
120/// help) rather than a permanent auth/database failure?
121///
122/// NOTE this is deliberately a *blocklist of known-permanent markers*: an
123/// unknown error retries (cheap, hourly). The service layer adds an
124/// escalation valve on top — N consecutive transient failures get reported
125/// as permanent — so anything this list misses still surfaces eventually.
126fn krb_error_is_transient(stderr: &str) -> bool {
127    let s = stderr.to_ascii_lowercase();
128    // Permanent: the machine account is stale, disabled, expired, or gone, or
129    // the keytab no longer matches the KDC — a keytab refresh / re-join is
130    // required, retrying won't help. (Matched against LC_ALL=C output.)
131    const PERMANENT: &[&str] = &[
132        "preauthentication failed",
133        "client not found",
134        "credentials have been revoked",
135        "not found in kerberos database",
136        "decrypt integrity check failed",
137        "keytab entry not found",
138        "no key table entry found",
139        "entry in database has expired",  // KRB5KDC_ERR_NAME_EXP
140        "no support for encryption type", // KRB5KDC_ERR_ETYPE_NOSUPP (stale keytab etypes)
141        "client not yet valid",           // KRB5KDC_ERR_CLIENT_NOTYET
142    ];
143    // "KDC policy rejects request" is intentionally NOT here: it can be
144    // time-based (logon hours) and therefore transient.
145    !PERMANENT.iter().any(|m| s.contains(m))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn classifies_setup_errors_as_permanent() {
154        assert!(
155            !Error::KeytabMissing {
156                keytab: "/x".into()
157            }
158            .is_transient()
159        );
160        assert!(
161            !Error::NoMachinePrincipalForRealm {
162                realm: "R".into(),
163                keytab: "/x".into()
164            }
165            .is_transient()
166        );
167        assert!(!Error::GroupNotFound("g".into()).is_transient());
168    }
169
170    fn kinit_err(stderr: &str) -> Error {
171        Error::CommandFailed {
172            program: "kinit".into(),
173            status: "exit 1".into(),
174            stderr: stderr.into(),
175        }
176    }
177
178    #[test]
179    fn classifies_kdc_errors() {
180        for permanent in [
181            "kinit: Client's credentials have been revoked while getting initial credentials",
182            "kinit: Client's entry in database has expired while getting initial credentials",
183            "kinit: KDC has no support for encryption type while getting initial credentials",
184            "kinit: Client not yet valid - try again later while getting initial credentials",
185            "kinit: Preauthentication failed while getting initial credentials",
186        ] {
187            assert!(!kinit_err(permanent).is_transient(), "{permanent}");
188        }
189        for transient in [
190            "kinit: Cannot contact any KDC for realm 'EXAMPLE.COM'",
191            "kinit: KDC policy rejects request while getting initial credentials", // may be logon hours
192            "kinit: Clock skew too great while getting initial credentials",
193        ] {
194            assert!(kinit_err(transient).is_transient(), "{transient}");
195        }
196    }
197
198    #[test]
199    fn classifies_io_by_kind() {
200        let denied = Error::Io(io::Error::from(io::ErrorKind::PermissionDenied));
201        assert!(!denied.is_transient());
202        let rofs = Error::Io(io::Error::from_raw_os_error(libc::EROFS));
203        assert!(!rofs.is_transient());
204        let other = Error::Io(io::Error::from(io::ErrorKind::Interrupted));
205        assert!(other.is_transient());
206    }
207}