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}