Skip to main content

machine_krb/
join.rs

1use std::fs::DirBuilder;
2use std::os::unix::fs::DirBuilderExt;
3use std::path::{Path, PathBuf};
4
5use crate::ccache::ArmorCache;
6use crate::error::Result;
7use crate::exec::{self, Tools};
8use crate::identity::MachineIdentity;
9
10/// Layered answer to "is this device properly joined to AD?".
11///
12/// Layers 1–3 each prove strictly more than the previous:
13/// 1. `configured_realms` — realmd/SSSD *say* we're joined (`realm list`).
14/// 2. `keytab_principal` — a machine credential exists (`klist -k`, root).
15/// 3. `credential_valid` — the credential actually **works**: `kinit -k`
16///    against a throwaway cache succeeded. This is the same proof
17///    `adcli testjoin` performs, and it is the strongest client-side check
18///    there is — an AD-side disabled/deleted computer object shows up here
19///    as a failure, not before.
20///
21/// `sssd_status` is an *orthogonal, advisory* signal — SSSD's own
22/// online/offline view. "Offline" usually just means the network/VPN is
23/// down and neither proves nor disproves the join; "Online" implies the
24/// machine credential currently works (SSSD binds LDAP with it).
25#[derive(Debug, Clone, Default)]
26#[cfg_attr(feature = "serde", derive(serde::Serialize))]
27pub struct JoinStatus {
28    pub configured_realms: Vec<String>,
29    pub keytab_principal: Option<String>,
30    /// Why layer 2 could not be checked (typically: not root).
31    pub keytab_error: Option<String>,
32    /// `Some(true|false)` only when a deep check ran.
33    pub credential_valid: Option<bool>,
34    pub credential_error: Option<String>,
35    /// Raw `sssctl domain-status` output, when it ran successfully.
36    pub sssd_status: Option<String>,
37}
38
39impl JoinStatus {
40    /// realmd/SSSD configuration claims a join.
41    pub fn is_configured(&self) -> bool {
42        !self.configured_realms.is_empty()
43    }
44
45    /// The strongest verdict this report can support:
46    /// deep-checked and the machine credential authenticated successfully.
47    pub fn is_properly_joined(&self) -> bool {
48        self.credential_valid == Some(true)
49    }
50}
51
52/// Gather the join report. Never fails as a whole — each layer degrades into
53/// its `*_error` field so callers always get a full picture.
54///
55/// `deep` performs the real credential test (`kinit -k` into a private
56/// throwaway cache). It needs root (keytab) and a reachable KDC.
57///
58/// `realm` selects which machine principal to check on a multi-realm host
59/// (case-insensitive); `None` uses the first principal in the keytab.
60pub fn check(tools: &Tools, keytab: &Path, realm: Option<&str>, deep: bool) -> JoinStatus {
61    let mut status = JoinStatus::default();
62
63    // 1. configured?
64    if let Ok(out) = exec::run_ok(&tools.realm, ["list", "--name-only"]) {
65        status.configured_realms = out
66            .lines()
67            .map(str::trim)
68            .filter(|l| !l.is_empty())
69            .map(String::from)
70            .collect();
71    }
72
73    // 2. machine credential present?
74    let discovered = match realm {
75        Some(r) => MachineIdentity::discover_in_realm(tools, keytab, r),
76        None => MachineIdentity::discover(tools, keytab),
77    };
78    let identity = match discovered {
79        Ok(id) => {
80            status.keytab_principal = Some(id.principal.clone());
81            Some(id)
82        }
83        Err(e) => {
84            status.keytab_error = Some(e.to_string());
85            None
86        }
87    };
88
89    // 3. credential actually valid? (deep — authenticates against the KDC)
90    if deep {
91        match identity {
92            Some(ref id) => match verify_credential(tools, id) {
93                Ok(()) => status.credential_valid = Some(true),
94                Err(e) => {
95                    status.credential_valid = Some(false);
96                    status.credential_error = Some(e.to_string());
97                }
98            },
99            None => {
100                status.credential_error =
101                    Some("skipped: machine credential not readable".to_string());
102            }
103        }
104    }
105
106    // 4. SSSD's own view (root only; silently absent otherwise).
107    if let Some(realm) = status.configured_realms.first() {
108        if let Ok(out) = exec::run_ok(&tools.sssctl, ["domain-status", "--", realm]) {
109            let trimmed = out.trim();
110            if !trimmed.is_empty() {
111                status.sssd_status = Some(trimmed.to_string());
112            }
113        }
114    }
115
116    status
117}
118
119/// Prove the machine credential works: `kinit -k` into a private throwaway
120/// cache, then destroy it. Equivalent to `adcli testjoin`.
121///
122/// The cache lives in a freshly-created private (0700) directory rather than
123/// a predictable bare path in `/tmp` — `mkdir` fails on a pre-existing entry
124/// (including an attacker's symlink), so root never writes a ticket through a
125/// path someone else planted.
126pub fn verify_credential(tools: &Tools, identity: &MachineIdentity) -> Result<()> {
127    let dir = private_scratch_dir()?;
128    let cache = ArmorCache::new(dir.join("verify.ccache"));
129    let outcome = cache.mint(tools, identity);
130    cache.destroy(tools);
131    let _ = std::fs::remove_dir_all(&dir);
132    outcome
133}
134
135fn private_scratch_dir() -> Result<PathBuf> {
136    // Fixed /tmp on purpose (not std::env::temp_dir): TMPDIR is inherited from
137    // the caller and could point a root check-join at a directory whose owner
138    // can rename/replace entries. /tmp is guaranteed sticky (1777), and the
139    // 0700 mkdir below fails on any pre-existing entry — including a planted
140    // symlink — so root never writes a ticket through a path it didn't create.
141    let base = Path::new("/tmp");
142    let pid = std::process::id();
143    for attempt in 0u32.. {
144        let dir = base.join(format!("machine-krb-verify-{pid}-{attempt}"));
145        match DirBuilder::new().mode(0o700).create(&dir) {
146            Ok(()) => return Ok(dir),
147            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
148            Err(e) => return Err(e.into()),
149        }
150    }
151    unreachable!("u32 attempt space exhausted");
152}