Skip to main content

host_identity/sources/
lxc.rs

1//! LXC / LXD container identity.
2//!
3//! Extracts a user-chosen container name from `/proc/self/cgroup` or
4//! `/proc/self/mountinfo` and composes it with `/etc/machine-id` into a
5//! stable raw identifier of the shape `lxc:<machine_id>:<name>`. Names
6//! alone are not unique across hosts — two unrelated hosts can run a
7//! container called `web`. Salting with the host's `machine-id` makes
8//! the composed identifier host-unique before the resolver's [`Wrap`]
9//! stage hashes it into a UUID.
10//!
11//! Recognised markers (first match wins):
12//!
13//! - `/lxc.payload.<name>` — LXC ≥ 3.1 payload cgroup (modern LXC and LXD).
14//! - `/lxc.monitor.<name>` — LXC ≥ 3.1 monitor cgroup (lxc-monitord).
15//! - `/lxc/<name>`         — legacy pre-3.1 cgroup v1 layout.
16//!
17//! Any of the three may be wrapped in a systemd transient unit and so
18//! end in `.scope` or `.service`; the parser strips one such suffix.
19//!
20//! # Identity scope
21//!
22//! Per-container, like [`super::ContainerId`]. In a nested deployment
23//! (e.g. Docker inside an LXD container) the resolver's short-circuit
24//! behaviour together with the default chain — `ContainerId` before
25//! `LxcId` — ensures the innermost scope wins.
26//!
27//! [`Wrap`]: crate::wrap::Wrap
28
29use std::io::{BufRead, BufReader, Read};
30use std::path::{Path, PathBuf};
31
32use crate::error::Error;
33use crate::source::{Probe, Source, SourceKind};
34use crate::sources::util::{MAX_ID_FILE_BYTES, normalize, read_capped};
35
36const DEFAULT_CGROUP_PATH: &str = "/proc/self/cgroup";
37const DEFAULT_MOUNTINFO_PATH: &str = "/proc/self/mountinfo";
38const DEFAULT_MACHINE_ID_PATH: &str = "/etc/machine-id";
39
40/// Upper bound on bytes read from `/proc/self/mountinfo`. Matches the
41/// cap used by [`super::ContainerId`] for the same reason: bound memory
42/// against a corrupt or adversarial procfs.
43const MAX_MOUNTINFO_BYTES: u64 = 2 * 1024 * 1024;
44
45/// LXC / LXD container name extractor.
46#[derive(Debug, Clone)]
47pub struct LxcId {
48    cgroup: PathBuf,
49    mountinfo: PathBuf,
50    machine_id: PathBuf,
51}
52
53impl LxcId {
54    /// Read from the standard procfs and `/etc/machine-id` paths.
55    #[must_use]
56    pub fn new() -> Self {
57        Self {
58            cgroup: PathBuf::from(DEFAULT_CGROUP_PATH),
59            mountinfo: PathBuf::from(DEFAULT_MOUNTINFO_PATH),
60            machine_id: PathBuf::from(DEFAULT_MACHINE_ID_PATH),
61        }
62    }
63
64    /// Override the cgroup path. Useful for tests and alternate
65    /// procfs mount points.
66    #[must_use]
67    pub fn with_cgroup(mut self, path: impl Into<PathBuf>) -> Self {
68        self.cgroup = path.into();
69        self
70    }
71
72    /// Override the mountinfo path.
73    #[must_use]
74    pub fn with_mountinfo(mut self, path: impl Into<PathBuf>) -> Self {
75        self.mountinfo = path.into();
76        self
77    }
78
79    /// Override the machine-id path used as a salt.
80    #[must_use]
81    pub fn with_machine_id(mut self, path: impl Into<PathBuf>) -> Self {
82        self.machine_id = path.into();
83        self
84    }
85}
86
87impl Default for LxcId {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93impl Source for LxcId {
94    fn kind(&self) -> SourceKind {
95        SourceKind::Lxc
96    }
97
98    fn probe(&self) -> Result<Option<Probe>, Error> {
99        let Some(machine_id) = read_machine_id(&self.machine_id) else {
100            return Ok(None);
101        };
102        let Some(name) = scan_file(&self.cgroup, MAX_ID_FILE_BYTES)
103            .or_else(|| scan_file(&self.mountinfo, MAX_MOUNTINFO_BYTES))
104        else {
105            return Ok(None);
106        };
107        let raw = format!("lxc:{machine_id}:{name}");
108        Ok(Some(Probe::new(SourceKind::Lxc, raw)))
109    }
110}
111
112/// Read machine-id and return a usable trimmed value. `NotFound`,
113/// empty, and the `uninitialized` sentinel all map to `None` — the
114/// source should silently fall through rather than abort the chain
115/// from a secondary position.
116fn read_machine_id(path: &Path) -> Option<String> {
117    let content = read_capped(path).ok()?;
118    normalize(&content).map(str::to_owned)
119}
120
121fn scan_file(path: &Path, cap: u64) -> Option<String> {
122    let file = match std::fs::File::open(path) {
123        Ok(f) => f,
124        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
125        Err(err) => {
126            log::debug!("host-identity: lxc: reading {}: {err}", path.display());
127            return None;
128        }
129    };
130    BufReader::new(file.take(cap))
131        .lines()
132        .map_while(Result::ok)
133        .find_map(|line| {
134            line.split_ascii_whitespace()
135                .find_map(extract_name_from_word)
136        })
137}
138
139fn extract_name_from_word(word: &str) -> Option<String> {
140    word.split(':').find_map(match_lxc_marker)
141}
142
143fn match_lxc_marker(segment: &str) -> Option<String> {
144    // `.payload.` / `.monitor.` with literal dots don't occur in
145    // generic Linux paths, so substring match is safe. Legacy
146    // `/lxc/` is prefix-only so `/usr/share/lxc/templates/…` and
147    // `/var/lib/lxc-templates/…` don't false-match.
148    [LXC_PAYLOAD, LXC_MONITOR]
149        .iter()
150        .find_map(|&m| extract_name(&segment[segment.find(m)? + m.len()..]))
151        .or_else(|| segment.strip_prefix(LXC_LEGACY).and_then(extract_name))
152}
153
154const LXC_PAYLOAD: &str = "/lxc.payload.";
155const LXC_MONITOR: &str = "/lxc.monitor.";
156const LXC_LEGACY: &str = "/lxc/";
157
158fn extract_name(rest: &str) -> Option<String> {
159    let end = rest
160        .bytes()
161        .position(|b| !is_name_byte(b))
162        .unwrap_or(rest.len());
163    let candidate = &rest[..end];
164    let candidate = candidate
165        .strip_suffix(".scope")
166        .or_else(|| candidate.strip_suffix(".service"))
167        .unwrap_or(candidate);
168    (!candidate.is_empty()).then(|| candidate.to_owned())
169}
170
171fn is_name_byte(b: u8) -> bool {
172    b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::io::Write;
179    use tempfile::NamedTempFile;
180
181    fn probe_with(cgroup: &str, mountinfo: &str, machine_id: &str) -> Option<Probe> {
182        let mut cg = NamedTempFile::new().unwrap();
183        cg.write_all(cgroup.as_bytes()).unwrap();
184        let mut mi = NamedTempFile::new().unwrap();
185        mi.write_all(mountinfo.as_bytes()).unwrap();
186        let mut id = NamedTempFile::new().unwrap();
187        id.write_all(machine_id.as_bytes()).unwrap();
188        LxcId::new()
189            .with_cgroup(cg.path())
190            .with_mountinfo(mi.path())
191            .with_machine_id(id.path())
192            .probe()
193            .unwrap()
194    }
195
196    #[test]
197    fn payload_in_cgroup_v2_matches() {
198        let probe = probe_with("0::/lxc.payload.demo\n", "", "abc123\n").unwrap();
199        assert_eq!(probe.kind(), SourceKind::Lxc);
200        assert_eq!(probe.value(), "lxc:abc123:demo");
201    }
202
203    #[test]
204    fn payload_in_cgroup_v1_matches() {
205        let cg = "4:memory:/lxc.payload.demo/init.scope\n";
206        let probe = probe_with(cg, "", "abc123\n").unwrap();
207        assert_eq!(probe.value(), "lxc:abc123:demo");
208    }
209
210    #[test]
211    fn monitor_matches() {
212        let cg = "0::/lxc.monitor.demo\n";
213        let probe = probe_with(cg, "", "abc123\n").unwrap();
214        assert_eq!(probe.value(), "lxc:abc123:demo");
215    }
216
217    #[test]
218    fn legacy_slash_lxc_matches() {
219        let cg = "11:name=systemd:/lxc/demo\n";
220        let probe = probe_with(cg, "", "abc123\n").unwrap();
221        assert_eq!(probe.value(), "lxc:abc123:demo");
222    }
223
224    #[test]
225    fn scope_suffix_is_stripped() {
226        assert_eq!(extract_name("demo.scope"), Some("demo".to_owned()));
227    }
228
229    #[test]
230    fn service_suffix_is_stripped() {
231        assert_eq!(extract_name("demo.service"), Some("demo".to_owned()));
232    }
233
234    #[test]
235    fn name_with_dots_is_preserved() {
236        // Plain liblxc allows dots in names. Greedy consumption up to
237        // the first non-name byte preserves the full dotted name.
238        assert_eq!(
239            match_lxc_marker("/lxc.payload.foo.bar/init.scope"),
240            Some("foo.bar".to_owned())
241        );
242    }
243
244    #[test]
245    fn name_with_hyphen_is_preserved() {
246        assert_eq!(
247            match_lxc_marker("/lxc.payload.my-container"),
248            Some("my-container".to_owned())
249        );
250    }
251
252    #[test]
253    fn name_with_underscore_is_preserved() {
254        assert_eq!(
255            match_lxc_marker("/lxc.payload.my_ct"),
256            Some("my_ct".to_owned())
257        );
258    }
259
260    #[test]
261    fn empty_name_is_rejected() {
262        // `/lxc.payload.` with nothing after.
263        assert_eq!(match_lxc_marker("/lxc.payload./leftover"), None);
264    }
265
266    #[test]
267    fn legacy_substring_in_share_path_rejected() {
268        // Would false-match under substring semantics; prefix-only match
269        // confines the legacy pattern to genuine cgroup paths.
270        assert_eq!(match_lxc_marker("/usr/share/lxc/templates/download"), None);
271    }
272
273    #[test]
274    fn lxc_templates_hyphenated_path_rejected() {
275        // `/var/lib/lxc-templates/...` — no `/lxc/` prefix (it's
276        // `/lxc-templates/`) and no `.payload.` / `.monitor.` tokens.
277        assert_eq!(match_lxc_marker("/var/lib/lxc-templates/download"), None);
278    }
279
280    #[test]
281    fn payload_substring_in_deeper_path_still_matches() {
282        // Mountinfo can carry the payload cgroup as part of a longer
283        // path (e.g. bind-mount source inside /sys/fs/cgroup). Substring
284        // match is what makes that case work.
285        assert_eq!(
286            match_lxc_marker("/sys/fs/cgroup/lxc.payload.demo/memory"),
287            Some("demo".to_owned())
288        );
289    }
290
291    #[test]
292    fn name_stops_at_slash() {
293        // Path-traversal guard: `/` is not a valid name byte, so it
294        // cannot sneak into the composed `lxc:<mid>:<name>` value.
295        assert_eq!(match_lxc_marker("/lxc.payload.a/b"), Some("a".to_owned()),);
296    }
297
298    #[test]
299    fn name_stops_at_whitespace() {
300        assert_eq!(
301            match_lxc_marker("/lxc.payload.bad name"),
302            Some("bad".to_owned()),
303        );
304    }
305
306    #[test]
307    fn machine_id_missing_yields_none() {
308        let mut cg = NamedTempFile::new().unwrap();
309        cg.write_all(b"0::/lxc.payload.demo\n").unwrap();
310        let mi = NamedTempFile::new().unwrap();
311        // Use a path that won't exist.
312        let missing = cg.path().with_extension("definitely-not-there");
313        let probe = LxcId::new()
314            .with_cgroup(cg.path())
315            .with_mountinfo(mi.path())
316            .with_machine_id(missing)
317            .probe()
318            .unwrap();
319        assert!(probe.is_none());
320    }
321
322    #[test]
323    fn machine_id_sentinel_yields_none() {
324        let probe = probe_with("0::/lxc.payload.demo\n", "", "uninitialized\n");
325        assert!(probe.is_none());
326    }
327
328    #[test]
329    fn machine_id_empty_yields_none() {
330        let probe = probe_with("0::/lxc.payload.demo\n", "", "   \n");
331        assert!(probe.is_none());
332    }
333
334    #[test]
335    fn no_markers_yields_none() {
336        let probe = probe_with("0::/user.slice/user-1000.slice\n", "", "abc123\n");
337        assert!(probe.is_none());
338    }
339
340    #[test]
341    fn cgroup_wins_over_mountinfo() {
342        // When both files carry a marker, scan_file is consulted for
343        // cgroup first. The result should reflect the cgroup's name.
344        let probe = probe_with(
345            "0::/lxc.payload.from-cgroup\n",
346            "1 2 0:0 / /host rw - overlay overlay rw,lowerdir=/lxc.payload.from-mountinfo/rootfs\n",
347            "abc123\n",
348        )
349        .unwrap();
350        assert_eq!(probe.value(), "lxc:abc123:from-cgroup");
351    }
352
353    #[test]
354    fn mountinfo_fallback_when_cgroup_missing() {
355        let probe = probe_with(
356            "0::/\n",
357            "1 2 0:0 / /host rw - overlay overlay rw,lowerdir=/lxc.payload.demo/rootfs\n",
358            "abc123\n",
359        )
360        .unwrap();
361        assert_eq!(probe.value(), "lxc:abc123:demo");
362    }
363
364    #[test]
365    fn composed_value_is_stable_across_calls() {
366        let mut cg = NamedTempFile::new().unwrap();
367        cg.write_all(b"0::/lxc.payload.demo\n").unwrap();
368        let mi = NamedTempFile::new().unwrap();
369        let mut id = NamedTempFile::new().unwrap();
370        id.write_all(b"abc123\n").unwrap();
371        let src = LxcId::new()
372            .with_cgroup(cg.path())
373            .with_mountinfo(mi.path())
374            .with_machine_id(id.path());
375        let a = src.probe().unwrap().unwrap();
376        let b = src.probe().unwrap().unwrap();
377        assert_eq!(a.value(), b.value());
378        assert_eq!(a.value(), "lxc:abc123:demo");
379    }
380
381    #[test]
382    fn mountinfo_past_cap_is_ignored() {
383        // Regression guard: the 2 MiB cap protects against corrupt or
384        // adversarial mountinfo. Writing a padded prefix followed by a
385        // real marker past the cap must not match — otherwise the
386        // `file.take(cap)` wrapper was accidentally removed.
387        let mut mi = NamedTempFile::new().unwrap();
388        // Pad with non-matching lines just over 2 MiB.
389        let padding_line = "1 2 0:0 / /pad rw - overlay overlay rw,lowerdir=/var/lib/foo/bar\n";
390        let cap = usize::try_from(MAX_MOUNTINFO_BYTES).unwrap();
391        let mut written = 0usize;
392        while written <= cap {
393            mi.write_all(padding_line.as_bytes()).unwrap();
394            written += padding_line.len();
395        }
396        // Now write the only LXC marker, past the cap.
397        mi.write_all(
398            b"9 9 0:0 / /late rw - overlay overlay rw,lowerdir=/lxc.payload.hidden/rootfs\n",
399        )
400        .unwrap();
401        mi.flush().unwrap();
402        let cg = NamedTempFile::new().unwrap();
403        let mut id = NamedTempFile::new().unwrap();
404        id.write_all(b"abc123\n").unwrap();
405        let probe = LxcId::new()
406            .with_cgroup(cg.path())
407            .with_mountinfo(mi.path())
408            .with_machine_id(id.path())
409            .probe()
410            .unwrap();
411        assert!(probe.is_none(), "marker past cap must not match");
412    }
413}