Skip to main content

machine_krb/
exec.rs

1use std::ffi::OsStr;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::time::{Duration, Instant};
6
7use crate::error::{Error, Result};
8
9/// Hard cap on any single spawned tool. Observed in the field: on a half-up
10/// network (VPN tunnel dead but its DNS still configured) kinit resolves the
11/// KDCs and then hangs in its own per-KDC retry cycle for minutes — long
12/// enough to blow through the systemd unit's start timeout and get SIGTERM'd,
13/// which bypasses the transient/permanent exit-code contract entirely. A
14/// killed-after-30s kinit instead surfaces as a *transient* error and follows
15/// the normal retry/escalation path.
16const CHILD_TIMEOUT: Duration = Duration::from_secs(30);
17
18/// Absolute paths to the system Kerberos / AD tooling.
19///
20/// The defaults are absolute **on purpose**: resolving these through `PATH`
21/// is how you end up running Homebrew's `kinit`, which lacks the PKINIT
22/// plugin and behaves subtly differently from the system MIT krb5. Every
23/// field can be overridden for non-standard layouts.
24#[derive(Debug, Clone)]
25pub struct Tools {
26    pub kinit: PathBuf,
27    pub klist: PathBuf,
28    pub kdestroy: PathBuf,
29    pub realm: PathBuf,
30    pub sssctl: PathBuf,
31    pub getent: PathBuf,
32}
33
34impl Default for Tools {
35    fn default() -> Self {
36        Self {
37            kinit: "/usr/bin/kinit".into(),
38            klist: "/usr/bin/klist".into(),
39            kdestroy: "/usr/bin/kdestroy".into(),
40            realm: "/usr/bin/realm".into(),
41            sssctl: "/usr/bin/sssctl".into(),
42            getent: "/usr/bin/getent".into(),
43        }
44    }
45}
46
47/// Kerberos environment variables that could redirect the spawned tools to a
48/// different config, keytab, or cache than the ones passed explicitly. The
49/// shipped systemd unit starts env-clean anyway, but this crate is meant for
50/// reuse in arbitrary root contexts — so scrub them always.
51const SCRUBBED_ENV: &[&str] = &[
52    "KRB5_CONFIG",
53    "KRB5CCNAME",
54    "KRB5_KTNAME",
55    "KRB5_CLIENT_KTNAME",
56    "KRB5_TRACE",
57    "KRB5RCACHEDIR",
58    "KRB5RCACHETYPE",
59];
60
61/// Run `program` and capture its output; error if it cannot be spawned or if
62/// it exceeds [`CHILD_TIMEOUT`] (killed, reported as [`Error::Timeout`]).
63pub(crate) fn run<I, S>(program: &Path, args: I) -> Result<Output>
64where
65    I: IntoIterator<Item = S>,
66    S: AsRef<OsStr>,
67{
68    run_with_timeout(program, args, CHILD_TIMEOUT)
69}
70
71fn run_with_timeout<I, S>(program: &Path, args: I, timeout: Duration) -> Result<Output>
72where
73    I: IntoIterator<Item = S>,
74    S: AsRef<OsStr>,
75{
76    let name = || program.display().to_string();
77    // LC_ALL=C so output parsing is locale-independent (klist prints
78    // localized dates and headers otherwise).
79    let mut cmd = Command::new(program);
80    cmd.args(args)
81        .env("LC_ALL", "C")
82        .stdin(Stdio::null())
83        .stdout(Stdio::piped())
84        .stderr(Stdio::piped());
85    for var in SCRUBBED_ENV {
86        cmd.env_remove(var);
87    }
88    let mut child = cmd.spawn().map_err(|source| Error::Spawn {
89        program: name(),
90        source,
91    })?;
92
93    let deadline = Instant::now() + timeout;
94    let status = loop {
95        match child.try_wait() {
96            Ok(Some(status)) => break status,
97            Ok(None) if Instant::now() >= deadline => {
98                let _ = child.kill();
99                let _ = child.wait(); // reap — no zombies
100                return Err(Error::Timeout {
101                    program: name(),
102                    seconds: timeout.as_secs(),
103                });
104            }
105            Ok(None) => std::thread::sleep(Duration::from_millis(50)),
106            Err(source) => {
107                let _ = child.kill();
108                let _ = child.wait();
109                return Err(Error::Spawn {
110                    program: name(),
111                    source,
112                });
113            }
114        }
115    };
116
117    // Post-exit pipe drain is safe here: these tools emit far less than the
118    // kernel pipe buffer (64 KiB), so they can never block on a full pipe.
119    let mut stdout = Vec::new();
120    let mut stderr = Vec::new();
121    if let Some(mut out) = child.stdout.take() {
122        let _ = out.read_to_end(&mut stdout);
123    }
124    if let Some(mut err) = child.stderr.take() {
125        let _ = err.read_to_end(&mut stderr);
126    }
127    Ok(Output {
128        status,
129        stdout,
130        stderr,
131    })
132}
133
134/// Run `program`; return stdout on success, a `CommandFailed` error otherwise.
135pub(crate) fn run_ok<I, S>(program: &Path, args: I) -> Result<String>
136where
137    I: IntoIterator<Item = S>,
138    S: AsRef<OsStr>,
139{
140    let out = run(program, args)?;
141    if out.status.success() {
142        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
143    } else {
144        Err(Error::CommandFailed {
145            program: program.display().to_string(),
146            status: out
147                .status
148                .code()
149                .map_or_else(|| "killed by signal".to_string(), |c| format!("exit {c}")),
150            stderr: String::from_utf8_lossy(&out.stderr).trim().to_string(),
151        })
152    }
153}
154
155/// Run `program`; true iff it exited 0. Spawn failures count as false.
156pub(crate) fn succeeds<I, S>(program: &Path, args: I) -> bool
157where
158    I: IntoIterator<Item = S>,
159    S: AsRef<OsStr>,
160{
161    run(program, args)
162        .map(|o| o.status.success())
163        .unwrap_or(false)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn hung_child_is_killed_and_reported() {
172        let started = Instant::now();
173        let err = run_with_timeout(
174            Path::new("/usr/bin/sleep"),
175            ["30"],
176            Duration::from_millis(300),
177        )
178        .unwrap_err();
179        assert!(matches!(err, Error::Timeout { .. }), "{err}");
180        // killed promptly, not after the child's own 30s
181        assert!(started.elapsed() < Duration::from_secs(5));
182    }
183
184    #[test]
185    fn fast_child_completes_with_output() {
186        let out = run_with_timeout(
187            Path::new("/usr/bin/echo"),
188            ["hello"],
189            Duration::from_secs(10),
190        )
191        .unwrap();
192        assert!(out.status.success());
193        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hello");
194    }
195}