Skip to main content

runner_manager_platform/
runner_account.rs

1// owner: d1-platform-core
2
3//! The unprivileged accounts 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 each runner as an account of its
13//! own from a [`RunnerPool`]. One account per concurrent runner is what keeps
14//! jobs apart from each other: a job cannot read another job's workspace, its
15//! runner's credentials or its processes' environment, nor signal them. The
16//! accounts share one group, [`RUNNER_GROUP`], which is how they share the
17//! dependency caches the operator chose to share.
18//! Moving from `root` to another uid clears every capability the kernel would
19//! otherwise pass on, and `NoNewPrivileges=yes` stops a set-user-ID or
20//! file-capability binary from granting one back, so the job runs with none.
21//!
22//! The account has no password, no login shell and no home directory. Each
23//! runner gets a `HOME` inside its own attempt (see
24//! [`crate::runner_env::platform_defaults`]).
25
26#[cfg(unix)]
27mod walk;
28
29use std::io;
30use std::path::Path;
31
32/// The group every runner account belongs to, and the account 0.4.56 ran every
33/// runner as, which a pool still accepts for an attempt that predates it.
34pub const RUNNER_GROUP: &str = "rm-runner";
35
36/// How many runner accounts `service install --start-at boot` creates: the
37/// most runners a host can run at once without two of them sharing an account.
38pub const POOL_SIZE: u32 = 32;
39
40/// The name of the pool's `n`th account, counting from one.
41#[must_use]
42pub fn pool_account_name(n: u32) -> String {
43    format!("{RUNNER_GROUP}-{n}")
44}
45
46/// A resolved account: what a runner is started as and what its directories
47/// are handed to.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct RunnerAccount {
50    /// The account's user id.
51    pub uid: u32,
52    /// Its primary group id.
53    pub gid: u32,
54}
55
56impl RunnerAccount {
57    /// The account called `name`, when it exists.
58    #[must_use]
59    pub fn lookup(name: &str) -> Option<Self> {
60        #[cfg(unix)]
61        {
62            sys::lookup(name)
63        }
64        #[cfg(not(unix))]
65        {
66            let _ = name;
67            None
68        }
69    }
70
71    /// Hands `path` itself to this account, without following a link there.
72    ///
73    /// # Errors
74    ///
75    /// What `lchown` reported.
76    pub fn give(&self, path: &Path) -> io::Result<()> {
77        #[cfg(unix)]
78        {
79            std::os::unix::fs::lchown(path, Some(self.uid), Some(self.gid))
80        }
81        #[cfg(not(unix))]
82        {
83            let _ = path;
84            Ok(())
85        }
86    }
87
88    /// Hands `path` and everything under it to this account.
89    ///
90    /// Links are handed over themselves and never followed, so a link a
91    /// previous job left pointing outside the tree cannot carry the change
92    /// anywhere else. Below `path`, a directory the account already owns is
93    /// not entered: only the account writes inside one, so nothing there needs
94    /// handing over, and a retained `_work` or an already handed package is
95    /// not walked again.
96    ///
97    /// # Errors
98    ///
99    /// The first error met, after which the rest of the tree is not visited.
100    pub fn give_tree(&self, path: &Path) -> io::Result<()> {
101        #[cfg(unix)]
102        {
103            let mut root = true;
104            walk::walk(path, &mut |node| {
105                let below = !std::mem::take(&mut root);
106                let stat = node.stat();
107                let owned = stat.st_uid == self.uid && stat.st_gid == self.gid;
108                if !owned {
109                    node.chown(self.uid, self.gid)?;
110                }
111                Ok(!(below && owned))
112            })
113        }
114        #[cfg(not(unix))]
115        {
116            let _ = path;
117            Ok(())
118        }
119    }
120
121    /// Whether this account already owns what `metadata` describes.
122    #[must_use]
123    pub fn owns(&self, metadata: &std::fs::Metadata) -> bool {
124        #[cfg(unix)]
125        {
126            use std::os::unix::fs::MetadataExt as _;
127            metadata.uid() == self.uid && metadata.gid() == self.gid
128        }
129        #[cfg(not(unix))]
130        {
131            let _ = metadata;
132            true
133        }
134    }
135}
136
137/// The accounts a Linux boot service's runners run as, one per concurrent
138/// runner, all in [`RUNNER_GROUP`].
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct RunnerPool {
141    /// [`RUNNER_GROUP`]'s id.
142    pub gid: u32,
143    /// The accounts a new attempt may be given, in order of preference.
144    pub accounts: Vec<RunnerAccount>,
145    /// The single account an earlier release ran every runner as: accepted
146    /// for an attempt it already owns, and given to a new one only when the
147    /// pool has no account of its own yet.
148    pub legacy: Option<RunnerAccount>,
149}
150
151impl RunnerPool {
152    /// The pool runners launched by this process run as: `Ok(None)` when they
153    /// run as this process does.
154    ///
155    /// `Some` only for `root` on Linux with the pool present, which is what
156    /// `service install --start-at boot` sets up. A login service, a foreground
157    /// daemon and every other platform keep their runners in their own
158    /// account, as before.
159    ///
160    /// A boot unit's daemon is `root` with a narrowed capability bounding set
161    /// that still holds `CAP_DAC_OVERRIDE` and the rest of
162    /// [`crate::service::BOOT_CAPABILITIES`]. A runner it started as itself
163    /// would keep them, so without the pool it must start none. A `root` with
164    /// every capability -- `sudo runner-manager daemon run` in a terminal --
165    /// keeps its runners as itself, as before.
166    ///
167    /// # Errors
168    ///
169    /// Why no runner may start: the pool is missing under a confined `root`.
170    pub fn for_this_daemon() -> Result<Option<Self>, String> {
171        #[cfg(target_os = "linux")]
172        {
173            if !crate::host_fitness::is_elevated() {
174                return Ok(None);
175            }
176            // A unit registered before the pool existed has an empty bounding
177            // set: it can switch to no account, and a runner it starts as
178            // itself holds no capability, so it keeps doing that.
179            if sys::bounding_set_is_narrowed() && !sys::bounding_set_holds_any() {
180                return Ok(None);
181            }
182            if sys::can_switch_account()
183                && let Some(pool) = Self::lookup()
184            {
185                return Ok(Some(pool));
186            }
187            if sys::bounding_set_is_narrowed() {
188                return Err(format!(
189                    "the {RUNNER_GROUP} accounts that this service runs jobs as do not exist, \
190                     or the service lacks the capabilities to start jobs as them, so no runner \
191                     starts rather than run as root with the service's capabilities. Run \
192                     `sudo runner-manager service install --start-at boot` to set both up"
193                ));
194            }
195        }
196        Ok(None)
197    }
198
199    /// The pool as it exists on this host, or `None` when it has no account
200    /// at all.
201    #[must_use]
202    pub fn lookup() -> Option<Self> {
203        let gid = Self::group_id()?;
204        let accounts = (1..=POOL_SIZE)
205            .filter_map(|n| RunnerAccount::lookup(&pool_account_name(n)))
206            .filter(|account| account.gid == gid)
207            .collect::<Vec<_>>();
208        let legacy = RunnerAccount::lookup(RUNNER_GROUP).filter(|account| account.gid == gid);
209        (!accounts.is_empty() || legacy.is_some()).then_some(Self {
210            gid,
211            accounts,
212            legacy,
213        })
214    }
215
216    fn group_id() -> Option<u32> {
217        #[cfg(unix)]
218        {
219            sys::group(RUNNER_GROUP)
220        }
221        #[cfg(not(unix))]
222        {
223            None
224        }
225    }
226
227    /// The pool, with [`RUNNER_GROUP`] and every account up to [`POOL_SIZE`]
228    /// created as system accounts where missing.
229    ///
230    /// # Errors
231    ///
232    /// What `groupadd` or `useradd` reported, when one had to run and failed.
233    pub fn ensure() -> Result<Self, String> {
234        if Self::group_id().is_none() {
235            run("groupadd", &["--system", RUNNER_GROUP])?;
236        }
237        for n in 1..=POOL_SIZE {
238            let name = pool_account_name(n);
239            if RunnerAccount::lookup(&name).is_none() {
240                run(
241                    "useradd",
242                    &[
243                        "--system",
244                        "--gid",
245                        RUNNER_GROUP,
246                        "--no-create-home",
247                        "--home-dir",
248                        "/nonexistent",
249                        "--shell",
250                        "/usr/sbin/nologin",
251                        "--comment",
252                        "runner-manager job runner",
253                        &name,
254                    ],
255                )?;
256            }
257        }
258        Self::lookup()
259            .filter(|pool| pool.accounts.len() == POOL_SIZE as usize)
260            .ok_or_else(|| {
261                format!("the {RUNNER_GROUP} accounts are incomplete after creating them")
262            })
263    }
264
265    /// The pool account, or the legacy one, with user id `uid`.
266    #[must_use]
267    pub fn account(&self, uid: u32) -> Option<RunnerAccount> {
268        self.accounts
269            .iter()
270            .chain(self.legacy.iter())
271            .find(|account| account.uid == uid)
272            .copied()
273    }
274
275    /// The account an attempt runs as. `owner` is the one that already owns
276    /// its directory, kept unless another live attempt holds it (`held`): a
277    /// persistent slot kept since an earlier attempt then moves to a free
278    /// account. Otherwise the first account neither held nor `busy` (which
279    /// is asked only then) is chosen. While the pool has no account of its
280    /// own, every attempt shares the legacy one. `None` when all are taken.
281    #[must_use]
282    pub fn allocate(
283        &self,
284        owner: Option<RunnerAccount>,
285        held: &std::collections::BTreeSet<u32>,
286        busy: impl FnOnce() -> std::collections::BTreeSet<u32>,
287    ) -> Option<RunnerAccount> {
288        if self.accounts.is_empty() {
289            return owner.or(self.legacy);
290        }
291        if let Some(owner) = owner.filter(|owner| !held.contains(&owner.uid)) {
292            return Some(owner);
293        }
294        let busy = busy();
295        self.accounts
296            .iter()
297            .copied()
298            .find(|account| !held.contains(&account.uid) && !busy.contains(&account.uid))
299    }
300
301    /// Shares `path` and everything under it with every account in the pool:
302    /// the group becomes [`RUNNER_GROUP`], which may read and write it, new
303    /// entries in a directory inherit that group (set-group-ID), and a default
304    /// ACL gives the group write access to what any account creates later,
305    /// whatever umask that account's job runs with.
306    ///
307    /// A directory already shared is not entered, so a second call over a
308    /// large cache costs one `lstat`. Links are never followed.
309    ///
310    /// # Errors
311    ///
312    /// The first error met, after which the rest of the tree is not visited.
313    pub fn share_tree(&self, path: &Path) -> io::Result<()> {
314        #[cfg(unix)]
315        {
316            sys::share_tree(self.gid, path)
317        }
318        #[cfg(not(unix))]
319        {
320            let _ = path;
321            Ok(())
322        }
323    }
324
325    /// Whether `metadata` describes a directory [`Self::share_tree`] has
326    /// already shared.
327    #[must_use]
328    pub fn is_shared(&self, metadata: &std::fs::Metadata) -> bool {
329        #[cfg(unix)]
330        {
331            sys::is_shared(self.gid, metadata)
332        }
333        #[cfg(not(unix))]
334        {
335            let _ = metadata;
336            true
337        }
338    }
339}
340
341fn run(program: &str, arguments: &[&str]) -> Result<(), String> {
342    let output = std::process::Command::new(program)
343        .args(arguments)
344        .output()
345        .map_err(|error| format!("cannot run {program}: {error}"))?;
346    if output.status.success() {
347        Ok(())
348    } else {
349        Err(format!(
350            "{program} {} failed: {}",
351            arguments.last().unwrap_or(&""),
352            String::from_utf8_lossy(&output.stderr).trim()
353        ))
354    }
355}
356
357#[cfg(unix)]
358mod sys {
359    use std::io;
360    use std::os::unix::fs::MetadataExt as _;
361    use std::path::Path;
362
363    use super::RunnerAccount;
364
365    pub(super) fn is_shared(gid: u32, metadata: &std::fs::Metadata) -> bool {
366        metadata.is_dir() && metadata.gid() == gid && metadata.mode() & 0o2070 == 0o2070
367    }
368
369    /// Shares `path` and what is under it that is not shared yet. A
370    /// directory below `path` that is already shared is not entered.
371    pub(super) fn share_tree(gid: u32, path: &Path) -> io::Result<()> {
372        let mut root = true;
373        super::walk::walk(path, &mut |node| {
374            let below = !std::mem::take(&mut root);
375            let stat = node.stat();
376            let directory = matches!(node, super::walk::Node::Directory { .. });
377            // `mode_t` is narrower than `u32` on macOS.
378            #[allow(clippy::useless_conversion)]
379            let mode = u32::from(stat.st_mode) & 0o7777;
380            if below && directory && stat.st_gid == gid && mode & 0o2070 == 0o2070 {
381                return Ok(false);
382            }
383            if stat.st_gid != gid {
384                node.chown(u32::MAX, gid)?;
385            }
386            let wanted = if directory {
387                mode | 0o2070
388            } else {
389                // The group gets what the owner has, short of the special bits.
390                mode | ((mode & 0o700) >> 3)
391            };
392            if wanted != mode {
393                node.chmod(wanted)?;
394            }
395            if let super::walk::Node::Directory { fd, .. } = node {
396                set_default_acl(fd)?;
397            }
398            Ok(true)
399        })
400    }
401
402    /// The default ACL `user::rwx group::rwx other::---`, in the kernel's
403    /// `system.posix_acl_default` encoding.
404    #[cfg(any(test, target_os = "linux"))]
405    pub(super) fn default_acl() -> Vec<u8> {
406        const VERSION: u32 = 2;
407        const UNDEFINED: u32 = u32::MAX;
408        let mut acl = VERSION.to_le_bytes().to_vec();
409        // (tag, permissions): ACL_USER_OBJ, ACL_GROUP_OBJ, ACL_OTHER.
410        for (tag, permissions) in [(0x01_u16, 7_u16), (0x04, 7), (0x20, 0)] {
411            acl.extend_from_slice(&tag.to_le_bytes());
412            acl.extend_from_slice(&permissions.to_le_bytes());
413            acl.extend_from_slice(&UNDEFINED.to_le_bytes());
414        }
415        acl
416    }
417
418    fn set_default_acl(directory: &std::os::fd::OwnedFd) -> io::Result<()> {
419        #[cfg(target_os = "linux")]
420        match super::walk::set_attribute(directory, c"system.posix_acl_default", &default_acl()) {
421            // A filesystem without ACLs still gets the group and the
422            // set-group-ID bit; what one account creates there is then
423            // readable by the others, though not writable.
424            Err(error) if error.raw_os_error() != Some(libc::ENOTSUP) => return Err(error),
425            _ => {}
426        }
427        #[cfg(not(target_os = "linux"))]
428        let _ = directory;
429        Ok(())
430    }
431
432    pub(super) fn group(name: &str) -> Option<u32> {
433        let name = std::ffi::CString::new(name).ok()?;
434        let mut buffer = vec![0_u8; 4096];
435        loop {
436            // SAFETY: `group` is plain data that `getgrnam_r` fills in, and
437            // zero is a valid value for every field until it does.
438            let mut entry: libc::group = unsafe { std::mem::zeroed() };
439            let mut found: *mut libc::group = std::ptr::null_mut();
440            // SAFETY: as for `getpwnam_r` in `lookup` below.
441            let status = unsafe {
442                libc::getgrnam_r(
443                    name.as_ptr(),
444                    &mut entry,
445                    buffer.as_mut_ptr().cast(),
446                    buffer.len(),
447                    &mut found,
448                )
449            };
450            if status == libc::ERANGE && buffer.len() < 1 << 20 {
451                buffer.resize(buffer.len() * 2, 0);
452                continue;
453            }
454            if status != 0 || found.is_null() {
455                return None;
456            }
457            return Some(entry.gr_gid);
458        }
459    }
460
461    /// Whether this process's capability bounding set lacks `CAP_SYS_ADMIN`,
462    /// which only a deliberately confined process -- a systemd unit with a
463    /// `CapabilityBoundingSet=` -- does.
464    #[cfg(target_os = "linux")]
465    pub(super) fn bounding_set_is_narrowed() -> bool {
466        const CAP_SYS_ADMIN: libc::c_ulong = 21;
467        !in_bounding_set(CAP_SYS_ADMIN)
468    }
469
470    /// Whether this process's bounding set holds any capability at all.
471    #[cfg(target_os = "linux")]
472    pub(super) fn bounding_set_holds_any() -> bool {
473        // CAP_LAST_CAP has grown over kernel releases; an unknown number reads
474        // as an error, which `in_bounding_set` reports as absent.
475        (0..64).any(in_bounding_set)
476    }
477
478    /// Whether this process may start a child as another account and hand it
479    /// its directories: `CAP_CHOWN`, `CAP_SETGID` and `CAP_SETUID`.
480    #[cfg(target_os = "linux")]
481    pub(super) fn can_switch_account() -> bool {
482        const CAP_CHOWN: libc::c_ulong = 0;
483        const CAP_SETGID: libc::c_ulong = 6;
484        const CAP_SETUID: libc::c_ulong = 7;
485        [CAP_CHOWN, CAP_SETGID, CAP_SETUID]
486            .into_iter()
487            .all(in_bounding_set)
488    }
489
490    #[cfg(target_os = "linux")]
491    fn in_bounding_set(capability: libc::c_ulong) -> bool {
492        // SAFETY: PR_CAPBSET_READ only reads this process's bounding set.
493        unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability, 0, 0, 0) == 1 }
494    }
495
496    pub(super) fn lookup(name: &str) -> Option<RunnerAccount> {
497        let name = std::ffi::CString::new(name).ok()?;
498        let mut buffer = vec![0_u8; 4096];
499        loop {
500            // SAFETY: `passwd` is plain data that `getpwnam_r` fills in, and
501            // zero is a valid value for every field until it does.
502            let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
503            let mut found: *mut libc::passwd = std::ptr::null_mut();
504            // SAFETY: every pointer is valid for the call: `name` is
505            // NUL-terminated, `buffer` is writable for its length, and `entry`
506            // and `found` are local.
507            let status = unsafe {
508                libc::getpwnam_r(
509                    name.as_ptr(),
510                    &mut entry,
511                    buffer.as_mut_ptr().cast(),
512                    buffer.len(),
513                    &mut found,
514                )
515            };
516            if status == libc::ERANGE && buffer.len() < 1 << 20 {
517                buffer.resize(buffer.len() * 2, 0);
518                continue;
519            }
520            if status != 0 || found.is_null() {
521                return None;
522            }
523            return Some(RunnerAccount {
524                uid: entry.pw_uid,
525                gid: entry.pw_gid,
526            });
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[cfg(unix)]
536    #[test]
537    fn an_account_that_exists_is_found_and_one_that_does_not_is_not() {
538        let root = RunnerAccount::lookup("root").expect("every Unix host has root");
539        assert_eq!(root.uid, 0);
540        assert_eq!(
541            RunnerAccount::lookup("runner-manager-no-such-account-x"),
542            None
543        );
544    }
545
546    #[cfg(unix)]
547    #[test]
548    fn giving_a_tree_to_its_own_owner_changes_nothing_and_never_follows_links() {
549        use std::os::unix::fs::MetadataExt as _;
550
551        let directory = tempfile::tempdir().unwrap();
552        let outside = tempfile::tempdir().unwrap();
553        std::fs::create_dir_all(directory.path().join("a/b")).unwrap();
554        std::fs::write(directory.path().join("a/b/file"), b"x").unwrap();
555        std::os::unix::fs::symlink(outside.path(), directory.path().join("a/link")).unwrap();
556        let metadata = std::fs::metadata(directory.path()).unwrap();
557        let me = RunnerAccount {
558            uid: metadata.uid(),
559            gid: metadata.gid(),
560        };
561        me.give_tree(directory.path()).unwrap();
562        assert!(me.owns(&std::fs::metadata(directory.path().join("a/b/file")).unwrap()));
563
564        // Another owner is refused for an unprivileged test process, which
565        // shows the call reaches lchown; root is skipped since it may.
566        if !crate::host_fitness::is_elevated() {
567            let other = RunnerAccount {
568                uid: me.uid + 1,
569                gid: me.gid,
570            };
571            assert!(other.give_tree(directory.path()).is_err());
572        }
573    }
574
575    #[test]
576    fn no_account_is_used_unless_this_is_root_on_linux() {
577        if !(cfg!(target_os = "linux") && crate::host_fitness::is_elevated()) {
578            assert_eq!(RunnerPool::for_this_daemon(), Ok(None));
579        }
580    }
581
582    #[test]
583    fn pool_accounts_are_numbered_from_one_in_the_runner_group() {
584        assert_eq!(pool_account_name(1), "rm-runner-1");
585        assert_eq!(pool_account_name(POOL_SIZE), "rm-runner-32");
586    }
587
588    #[test]
589    fn a_pool_hands_out_its_own_accounts_and_the_legacy_one_only_alone() {
590        let legacy = RunnerAccount { uid: 997, gid: 972 };
591        let first = RunnerAccount { uid: 990, gid: 972 };
592        let mut pool = RunnerPool {
593            gid: 972,
594            accounts: vec![first],
595            legacy: Some(legacy),
596        };
597        let none = std::collections::BTreeSet::new();
598        let held = std::collections::BTreeSet::from([990]);
599        assert_eq!(pool.allocate(None, &none, Default::default), Some(first));
600        assert_eq!(pool.allocate(None, &held, Default::default), None);
601        assert_eq!(pool.allocate(None, &none, || held.clone()), None);
602        assert_eq!(
603            pool.allocate(Some(first), &none, || held.clone()),
604            Some(first)
605        );
606        assert_eq!(pool.account(997), Some(legacy));
607        assert_eq!(pool.account(990), Some(first));
608        assert_eq!(pool.account(0), None);
609        pool.accounts.clear();
610        assert_eq!(pool.allocate(None, &held, Default::default), Some(legacy));
611    }
612
613    #[cfg(unix)]
614    #[test]
615    fn the_default_acl_is_the_kernel_encoding_of_group_rwx() {
616        assert_eq!(
617            sys::default_acl(),
618            [
619                2, 0, 0, 0, // version
620                1, 0, 7, 0, 255, 255, 255, 255, // user::rwx
621                4, 0, 7, 0, 255, 255, 255, 255, // group::rwx
622                32, 0, 0, 0, 255, 255, 255, 255, // other::---
623            ]
624        );
625    }
626
627    #[cfg(target_os = "linux")]
628    #[test]
629    fn sharing_a_tree_opens_it_to_the_group_once_and_never_follows_links() {
630        use std::os::unix::fs::MetadataExt as _;
631
632        let directory = tempfile::tempdir().unwrap();
633        let outside = tempfile::tempdir().unwrap();
634        let root = directory.path().join("cache");
635        std::fs::create_dir_all(root.join("npm/_cacache")).unwrap();
636        std::fs::write(root.join("npm/_cacache/entry"), b"x").unwrap();
637        std::fs::set_permissions(
638            root.join("npm/_cacache/entry"),
639            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
640        )
641        .unwrap();
642        std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
643        let gid = std::fs::metadata(&root).unwrap().gid();
644        let pool = RunnerPool {
645            gid,
646            accounts: Vec::new(),
647            legacy: None,
648        };
649
650        pool.share_tree(&root).unwrap();
651
652        let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
653        assert_eq!(mode(&root.join("npm")) & 0o2070, 0o2070);
654        assert_eq!(mode(&root.join("npm/_cacache/entry")), 0o660);
655        assert!(pool.is_shared(&std::fs::metadata(root.join("npm")).unwrap()));
656        assert_eq!(
657            mode(outside.path()) & 0o2000,
658            0,
659            "a link's target is never touched"
660        );
661    }
662}