1use std::io;
2use std::path::PathBuf;
3
4#[derive(Debug, thiserror::Error)]
6pub enum Error {
7 #[error("failed to run {program}: {source}")]
9 Spawn {
10 program: String,
11 #[source]
12 source: io::Error,
13 },
14
15 #[error("{program} failed ({status}): {stderr}")]
17 CommandFailed {
18 program: String,
19 status: String,
20 stderr: String,
21 },
22
23 #[error("{program} timed out after {seconds}s and was killed")]
27 Timeout { program: String, seconds: u64 },
28
29 #[error("no machine principal (NAME$@REALM) found in keytab {}", keytab.display())]
31 NoMachinePrincipal { keytab: PathBuf },
32
33 #[error("no machine principal for realm {realm} in keytab {}", keytab.display())]
35 NoMachinePrincipalForRealm { realm: String, keytab: PathBuf },
36
37 #[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 #[error(
53 "keytab {} not found — is this machine AD-joined? (realm join <domain>)",
54 keytab.display()
55 )]
56 KeytabMissing { keytab: PathBuf },
57
58 #[error("group '{0}' not found — create it (`groupadd -r {0}`) or pass a different group")]
60 GroupNotFound(String),
61
62 #[error("ticket cache {} is still invalid after renew/mint", .0.display())]
64 CacheStillInvalid(PathBuf),
65
66 #[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 #[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 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 Error::Timeout { .. } => true,
107 Error::CacheStillInvalid(_) => true,
108 Error::Io(e) => {
113 e.kind() != io::ErrorKind::PermissionDenied && e.raw_os_error() != Some(libc::EROFS)
114 }
115 }
116 }
117}
118
119fn krb_error_is_transient(stderr: &str) -> bool {
127 let s = stderr.to_ascii_lowercase();
128 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", "no support for encryption type", "client not yet valid", ];
143 !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", "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}