Skip to main content

runner_manager_platform/
runner_account.rs

1// owner: d1-platform-core
2
3//! The unprivileged account a Linux boot service runs its runners as.
4//!
5//! A boot service's daemon runs as `root`, because only `root` can read the
6//! machine-scoped credential. Its runners do not have to, and must not: a job
7//! running as `root` with no capabilities cannot change a file's owner, so
8//! `tar`, npm and `node-gyp` fail on any archive whose entries another account
9//! owns, and with capabilities a hostile workflow would hold them too.
10//!
11//! So the daemon keeps the capabilities it needs to hand a runner its
12//! directories and to signal it, and starts the runner as [`RUNNER_ACCOUNT`].
13//! Moving from `root` to another uid clears every capability the kernel would
14//! otherwise pass on, and `NoNewPrivileges=yes` stops a set-user-ID or
15//! file-capability binary from granting one back, so the job runs with none.
16//!
17//! The account has no password, no login shell and no home directory. Each
18//! runner gets a `HOME` inside its own attempt (see
19//! [`crate::runner_env::platform_defaults`]).
20
21use std::io;
22use std::path::Path;
23
24/// The system account Linux boot-mode runners run as.
25pub const RUNNER_ACCOUNT: &str = "rm-runner";
26
27/// A resolved account: what a runner is started as and what its directories
28/// are handed to.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct RunnerAccount {
31    /// The account's user id.
32    pub uid: u32,
33    /// Its primary group id.
34    pub gid: u32,
35}
36
37impl RunnerAccount {
38    /// The account runners launched by this process run as: `Ok(None)` when
39    /// they run as this process does.
40    ///
41    /// `Some` only for `root` on Linux with [`RUNNER_ACCOUNT`] present, which
42    /// is what `service install --start-at boot` sets up. A login service, a
43    /// foreground daemon and every other platform keep their runners in their
44    /// own account, as before.
45    ///
46    /// A boot unit's daemon is `root` with a narrowed capability bounding set
47    /// that still holds `CAP_DAC_OVERRIDE` and the rest of
48    /// [`crate::service::BOOT_CAPABILITIES`]. A runner it started as itself
49    /// would keep them, so without [`RUNNER_ACCOUNT`] it must start none. A
50    /// `root` with every capability -- `sudo runner-manager daemon run` in a
51    /// terminal -- keeps its runners as itself, as before.
52    ///
53    /// # Errors
54    ///
55    /// Why no runner may start: the account is missing under a confined `root`.
56    pub fn for_this_daemon() -> Result<Option<Self>, String> {
57        #[cfg(target_os = "linux")]
58        {
59            if !crate::host_fitness::is_elevated() {
60                return Ok(None);
61            }
62            // A unit registered before this account existed has an empty
63            // bounding set: it can switch to no account, and a runner it starts
64            // as itself holds no capability, so it keeps doing that.
65            if sys::bounding_set_is_narrowed() && !sys::bounding_set_holds_any() {
66                return Ok(None);
67            }
68            if sys::can_switch_account()
69                && let Some(account) = Self::lookup(RUNNER_ACCOUNT)
70            {
71                return Ok(Some(account));
72            }
73            if sys::bounding_set_is_narrowed() {
74                return Err(format!(
75                    "the {RUNNER_ACCOUNT} account that this service runs jobs as does not exist, \
76                     or the service lacks the capabilities to start jobs as it, so no runner \
77                     starts rather than run as root with the service's capabilities. Run \
78                     `sudo runner-manager service install --start-at boot` to set both up"
79                ));
80            }
81        }
82        Ok(None)
83    }
84
85    /// The account called `name`, when it exists.
86    #[must_use]
87    pub fn lookup(name: &str) -> Option<Self> {
88        #[cfg(unix)]
89        {
90            sys::lookup(name)
91        }
92        #[cfg(not(unix))]
93        {
94            let _ = name;
95            None
96        }
97    }
98
99    /// [`RUNNER_ACCOUNT`], created as a system account when it is missing.
100    ///
101    /// # Errors
102    ///
103    /// What `useradd` reported, when it had to run and failed.
104    pub fn ensure() -> Result<Self, String> {
105        if let Some(account) = Self::lookup(RUNNER_ACCOUNT) {
106            return Ok(account);
107        }
108        let output = std::process::Command::new("useradd")
109            .args([
110                "--system",
111                "--user-group",
112                "--no-create-home",
113                "--home-dir",
114                "/nonexistent",
115                "--shell",
116                "/usr/sbin/nologin",
117                "--comment",
118                "runner-manager job runner",
119                RUNNER_ACCOUNT,
120            ])
121            .output()
122            .map_err(|error| format!("cannot run useradd: {error}"))?;
123        if !output.status.success() {
124            return Err(format!(
125                "useradd could not create the {RUNNER_ACCOUNT} account: {}",
126                String::from_utf8_lossy(&output.stderr).trim()
127            ));
128        }
129        Self::lookup(RUNNER_ACCOUNT)
130            .ok_or_else(|| format!("useradd reported success but {RUNNER_ACCOUNT} does not exist"))
131    }
132
133    /// Hands `path` itself to this account, without following a link there.
134    ///
135    /// # Errors
136    ///
137    /// What `lchown` reported.
138    pub fn give(&self, path: &Path) -> io::Result<()> {
139        #[cfg(unix)]
140        {
141            std::os::unix::fs::lchown(path, Some(self.uid), Some(self.gid))
142        }
143        #[cfg(not(unix))]
144        {
145            let _ = path;
146            Ok(())
147        }
148    }
149
150    /// Hands `path` and everything under it to this account.
151    ///
152    /// Links are handed over themselves and never followed, so a link a
153    /// previous job left pointing outside the tree cannot carry the change
154    /// anywhere else. Below `path`, a directory the account already owns is
155    /// not entered: only the account writes inside one, so nothing there needs
156    /// handing over, and a retained `_work` or an already handed package is
157    /// not walked again.
158    ///
159    /// # Errors
160    ///
161    /// The first error met, after which the rest of the tree is not visited.
162    pub fn give_tree(&self, path: &Path) -> io::Result<()> {
163        let metadata = std::fs::symlink_metadata(path)?;
164        if !self.owns(&metadata) {
165            self.give(path)?;
166        }
167        if metadata.is_dir() {
168            for entry in std::fs::read_dir(path)? {
169                let child = entry?.path();
170                let owned_directory = std::fs::symlink_metadata(&child)
171                    .is_ok_and(|metadata| metadata.is_dir() && self.owns(&metadata));
172                if !owned_directory {
173                    self.give_tree(&child)?;
174                }
175            }
176        }
177        Ok(())
178    }
179
180    /// Whether this account already owns what `metadata` describes.
181    #[must_use]
182    pub fn owns(&self, metadata: &std::fs::Metadata) -> bool {
183        #[cfg(unix)]
184        {
185            use std::os::unix::fs::MetadataExt as _;
186            metadata.uid() == self.uid && metadata.gid() == self.gid
187        }
188        #[cfg(not(unix))]
189        {
190            let _ = metadata;
191            true
192        }
193    }
194}
195
196#[cfg(unix)]
197mod sys {
198    use super::RunnerAccount;
199
200    /// Whether this process's capability bounding set lacks `CAP_SYS_ADMIN`,
201    /// which only a deliberately confined process -- a systemd unit with a
202    /// `CapabilityBoundingSet=` -- does.
203    #[cfg(target_os = "linux")]
204    pub(super) fn bounding_set_is_narrowed() -> bool {
205        const CAP_SYS_ADMIN: libc::c_ulong = 21;
206        !in_bounding_set(CAP_SYS_ADMIN)
207    }
208
209    /// Whether this process's bounding set holds any capability at all.
210    #[cfg(target_os = "linux")]
211    pub(super) fn bounding_set_holds_any() -> bool {
212        // CAP_LAST_CAP has grown over kernel releases; an unknown number reads
213        // as an error, which `in_bounding_set` reports as absent.
214        (0..64).any(in_bounding_set)
215    }
216
217    /// Whether this process may start a child as another account and hand it
218    /// its directories: `CAP_CHOWN`, `CAP_SETGID` and `CAP_SETUID`.
219    #[cfg(target_os = "linux")]
220    pub(super) fn can_switch_account() -> bool {
221        const CAP_CHOWN: libc::c_ulong = 0;
222        const CAP_SETGID: libc::c_ulong = 6;
223        const CAP_SETUID: libc::c_ulong = 7;
224        [CAP_CHOWN, CAP_SETGID, CAP_SETUID]
225            .into_iter()
226            .all(in_bounding_set)
227    }
228
229    #[cfg(target_os = "linux")]
230    fn in_bounding_set(capability: libc::c_ulong) -> bool {
231        // SAFETY: PR_CAPBSET_READ only reads this process's bounding set.
232        unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability, 0, 0, 0) == 1 }
233    }
234
235    pub(super) fn lookup(name: &str) -> Option<RunnerAccount> {
236        let name = std::ffi::CString::new(name).ok()?;
237        let mut buffer = vec![0_u8; 4096];
238        loop {
239            // SAFETY: `passwd` is plain data that `getpwnam_r` fills in, and
240            // zero is a valid value for every field until it does.
241            let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
242            let mut found: *mut libc::passwd = std::ptr::null_mut();
243            // SAFETY: every pointer is valid for the call: `name` is
244            // NUL-terminated, `buffer` is writable for its length, and `entry`
245            // and `found` are local.
246            let status = unsafe {
247                libc::getpwnam_r(
248                    name.as_ptr(),
249                    &mut entry,
250                    buffer.as_mut_ptr().cast(),
251                    buffer.len(),
252                    &mut found,
253                )
254            };
255            if status == libc::ERANGE && buffer.len() < 1 << 20 {
256                buffer.resize(buffer.len() * 2, 0);
257                continue;
258            }
259            if status != 0 || found.is_null() {
260                return None;
261            }
262            return Some(RunnerAccount {
263                uid: entry.pw_uid,
264                gid: entry.pw_gid,
265            });
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[cfg(unix)]
275    #[test]
276    fn an_account_that_exists_is_found_and_one_that_does_not_is_not() {
277        let root = RunnerAccount::lookup("root").expect("every Unix host has root");
278        assert_eq!(root.uid, 0);
279        assert_eq!(
280            RunnerAccount::lookup("runner-manager-no-such-account-x"),
281            None
282        );
283    }
284
285    #[cfg(unix)]
286    #[test]
287    fn giving_a_tree_to_its_own_owner_changes_nothing_and_never_follows_links() {
288        use std::os::unix::fs::MetadataExt as _;
289
290        let directory = tempfile::tempdir().unwrap();
291        let outside = tempfile::tempdir().unwrap();
292        std::fs::create_dir_all(directory.path().join("a/b")).unwrap();
293        std::fs::write(directory.path().join("a/b/file"), b"x").unwrap();
294        std::os::unix::fs::symlink(outside.path(), directory.path().join("a/link")).unwrap();
295        let metadata = std::fs::metadata(directory.path()).unwrap();
296        let me = RunnerAccount {
297            uid: metadata.uid(),
298            gid: metadata.gid(),
299        };
300        me.give_tree(directory.path()).unwrap();
301        assert!(me.owns(&std::fs::metadata(directory.path().join("a/b/file")).unwrap()));
302
303        // Another owner is refused for an unprivileged test process, which
304        // shows the call reaches lchown; root is skipped since it may.
305        if !crate::host_fitness::is_elevated() {
306            let other = RunnerAccount {
307                uid: me.uid + 1,
308                gid: me.gid,
309            };
310            assert!(other.give_tree(directory.path()).is_err());
311        }
312    }
313
314    #[test]
315    fn no_account_is_used_unless_this_is_root_on_linux() {
316        if !(cfg!(target_os = "linux") && crate::host_fitness::is_elevated()) {
317            assert_eq!(RunnerAccount::for_this_daemon(), Ok(None));
318        }
319    }
320}