#[cfg(unix)]
mod walk;
use std::io;
use std::path::Path;
pub const RUNNER_GROUP: &str = "rm-runner";
pub const POOL_SIZE: u32 = 32;
#[must_use]
pub fn pool_account_name(n: u32) -> String {
format!("{RUNNER_GROUP}-{n}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunnerAccount {
pub uid: u32,
pub gid: u32,
}
impl RunnerAccount {
#[must_use]
pub fn lookup(name: &str) -> Option<Self> {
#[cfg(unix)]
{
sys::lookup(name)
}
#[cfg(not(unix))]
{
let _ = name;
None
}
}
pub fn give(&self, path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::lchown(path, Some(self.uid), Some(self.gid))
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
pub fn give_tree(&self, path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let mut root = true;
walk::walk(path, &mut |node| {
let below = !std::mem::take(&mut root);
let stat = node.stat();
let owned = stat.st_uid == self.uid && stat.st_gid == self.gid;
if !owned {
node.chown(self.uid, self.gid)?;
}
Ok(!(below && owned))
})
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
#[must_use]
pub fn owns(&self, metadata: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
metadata.uid() == self.uid && metadata.gid() == self.gid
}
#[cfg(not(unix))]
{
let _ = metadata;
true
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunnerPool {
pub gid: u32,
pub accounts: Vec<RunnerAccount>,
pub legacy: Option<RunnerAccount>,
}
impl RunnerPool {
pub fn for_this_daemon() -> Result<Option<Self>, String> {
#[cfg(target_os = "linux")]
{
if !crate::host_fitness::is_elevated() {
return Ok(None);
}
if sys::bounding_set_is_narrowed() && !sys::bounding_set_holds_any() {
return Ok(None);
}
if sys::can_switch_account()
&& let Some(pool) = Self::lookup()
{
return Ok(Some(pool));
}
if sys::bounding_set_is_narrowed() {
return Err(format!(
"the {RUNNER_GROUP} accounts that this service runs jobs as do not exist, \
or the service lacks the capabilities to start jobs as them, so no runner \
starts rather than run as root with the service's capabilities. Run \
`sudo runner-manager service install --start-at boot` to set both up"
));
}
}
Ok(None)
}
#[must_use]
pub fn lookup() -> Option<Self> {
let gid = Self::group_id()?;
let accounts = (1..=POOL_SIZE)
.filter_map(|n| RunnerAccount::lookup(&pool_account_name(n)))
.filter(|account| account.gid == gid)
.collect::<Vec<_>>();
let legacy = RunnerAccount::lookup(RUNNER_GROUP).filter(|account| account.gid == gid);
(!accounts.is_empty() || legacy.is_some()).then_some(Self {
gid,
accounts,
legacy,
})
}
fn group_id() -> Option<u32> {
#[cfg(unix)]
{
sys::group(RUNNER_GROUP)
}
#[cfg(not(unix))]
{
None
}
}
pub fn ensure() -> Result<Self, String> {
if Self::group_id().is_none() {
run("groupadd", &["--system", RUNNER_GROUP])?;
}
for n in 1..=POOL_SIZE {
let name = pool_account_name(n);
if RunnerAccount::lookup(&name).is_none() {
run(
"useradd",
&[
"--system",
"--gid",
RUNNER_GROUP,
"--no-create-home",
"--home-dir",
"/nonexistent",
"--shell",
"/usr/sbin/nologin",
"--comment",
"runner-manager job runner",
&name,
],
)?;
}
}
Self::lookup()
.filter(|pool| pool.accounts.len() == POOL_SIZE as usize)
.ok_or_else(|| {
format!("the {RUNNER_GROUP} accounts are incomplete after creating them")
})
}
#[must_use]
pub fn account(&self, uid: u32) -> Option<RunnerAccount> {
self.accounts
.iter()
.chain(self.legacy.iter())
.find(|account| account.uid == uid)
.copied()
}
#[must_use]
pub fn allocate(
&self,
owner: Option<RunnerAccount>,
held: &std::collections::BTreeSet<u32>,
busy: impl FnOnce() -> std::collections::BTreeSet<u32>,
) -> Option<RunnerAccount> {
if self.accounts.is_empty() {
return owner.or(self.legacy);
}
if let Some(owner) = owner.filter(|owner| !held.contains(&owner.uid)) {
return Some(owner);
}
let busy = busy();
self.accounts
.iter()
.copied()
.find(|account| !held.contains(&account.uid) && !busy.contains(&account.uid))
}
pub fn share_tree(&self, path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
sys::share_tree(self.gid, path)
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
#[must_use]
pub fn is_shared(&self, metadata: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
sys::is_shared(self.gid, metadata)
}
#[cfg(not(unix))]
{
let _ = metadata;
true
}
}
}
fn run(program: &str, arguments: &[&str]) -> Result<(), String> {
let output = std::process::Command::new(program)
.args(arguments)
.output()
.map_err(|error| format!("cannot run {program}: {error}"))?;
if output.status.success() {
Ok(())
} else {
Err(format!(
"{program} {} failed: {}",
arguments.last().unwrap_or(&""),
String::from_utf8_lossy(&output.stderr).trim()
))
}
}
#[cfg(unix)]
mod sys {
use std::io;
use std::os::unix::fs::MetadataExt as _;
use std::path::Path;
use super::RunnerAccount;
pub(super) fn is_shared(gid: u32, metadata: &std::fs::Metadata) -> bool {
metadata.is_dir() && metadata.gid() == gid && metadata.mode() & 0o2070 == 0o2070
}
pub(super) fn share_tree(gid: u32, path: &Path) -> io::Result<()> {
let mut root = true;
super::walk::walk(path, &mut |node| {
let below = !std::mem::take(&mut root);
let stat = node.stat();
let directory = matches!(node, super::walk::Node::Directory { .. });
#[allow(clippy::useless_conversion)]
let mode = u32::from(stat.st_mode) & 0o7777;
if below && directory && stat.st_gid == gid && mode & 0o2070 == 0o2070 {
return Ok(false);
}
if stat.st_gid != gid {
node.chown(u32::MAX, gid)?;
}
let wanted = if directory {
mode | 0o2070
} else {
mode | ((mode & 0o700) >> 3)
};
if wanted != mode {
node.chmod(wanted)?;
}
if let super::walk::Node::Directory { fd, .. } = node {
set_default_acl(fd)?;
}
Ok(true)
})
}
#[cfg(any(test, target_os = "linux"))]
pub(super) fn default_acl() -> Vec<u8> {
const VERSION: u32 = 2;
const UNDEFINED: u32 = u32::MAX;
let mut acl = VERSION.to_le_bytes().to_vec();
for (tag, permissions) in [(0x01_u16, 7_u16), (0x04, 7), (0x20, 0)] {
acl.extend_from_slice(&tag.to_le_bytes());
acl.extend_from_slice(&permissions.to_le_bytes());
acl.extend_from_slice(&UNDEFINED.to_le_bytes());
}
acl
}
fn set_default_acl(directory: &std::os::fd::OwnedFd) -> io::Result<()> {
#[cfg(target_os = "linux")]
match super::walk::set_attribute(directory, c"system.posix_acl_default", &default_acl()) {
Err(error) if error.raw_os_error() != Some(libc::ENOTSUP) => return Err(error),
_ => {}
}
#[cfg(not(target_os = "linux"))]
let _ = directory;
Ok(())
}
pub(super) fn group(name: &str) -> Option<u32> {
let name = std::ffi::CString::new(name).ok()?;
let mut buffer = vec![0_u8; 4096];
loop {
let mut entry: libc::group = unsafe { std::mem::zeroed() };
let mut found: *mut libc::group = std::ptr::null_mut();
let status = unsafe {
libc::getgrnam_r(
name.as_ptr(),
&mut entry,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut found,
)
};
if status == libc::ERANGE && buffer.len() < 1 << 20 {
buffer.resize(buffer.len() * 2, 0);
continue;
}
if status != 0 || found.is_null() {
return None;
}
return Some(entry.gr_gid);
}
}
#[cfg(target_os = "linux")]
pub(super) fn bounding_set_is_narrowed() -> bool {
const CAP_SYS_ADMIN: libc::c_ulong = 21;
!in_bounding_set(CAP_SYS_ADMIN)
}
#[cfg(target_os = "linux")]
pub(super) fn bounding_set_holds_any() -> bool {
(0..64).any(in_bounding_set)
}
#[cfg(target_os = "linux")]
pub(super) fn can_switch_account() -> bool {
const CAP_CHOWN: libc::c_ulong = 0;
const CAP_SETGID: libc::c_ulong = 6;
const CAP_SETUID: libc::c_ulong = 7;
[CAP_CHOWN, CAP_SETGID, CAP_SETUID]
.into_iter()
.all(in_bounding_set)
}
#[cfg(target_os = "linux")]
fn in_bounding_set(capability: libc::c_ulong) -> bool {
unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability, 0, 0, 0) == 1 }
}
pub(super) fn lookup(name: &str) -> Option<RunnerAccount> {
let name = std::ffi::CString::new(name).ok()?;
let mut buffer = vec![0_u8; 4096];
loop {
let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
let mut found: *mut libc::passwd = std::ptr::null_mut();
let status = unsafe {
libc::getpwnam_r(
name.as_ptr(),
&mut entry,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut found,
)
};
if status == libc::ERANGE && buffer.len() < 1 << 20 {
buffer.resize(buffer.len() * 2, 0);
continue;
}
if status != 0 || found.is_null() {
return None;
}
return Some(RunnerAccount {
uid: entry.pw_uid,
gid: entry.pw_gid,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn an_account_that_exists_is_found_and_one_that_does_not_is_not() {
let root = RunnerAccount::lookup("root").expect("every Unix host has root");
assert_eq!(root.uid, 0);
assert_eq!(
RunnerAccount::lookup("runner-manager-no-such-account-x"),
None
);
}
#[cfg(unix)]
#[test]
fn giving_a_tree_to_its_own_owner_changes_nothing_and_never_follows_links() {
use std::os::unix::fs::MetadataExt as _;
let directory = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir_all(directory.path().join("a/b")).unwrap();
std::fs::write(directory.path().join("a/b/file"), b"x").unwrap();
std::os::unix::fs::symlink(outside.path(), directory.path().join("a/link")).unwrap();
let metadata = std::fs::metadata(directory.path()).unwrap();
let me = RunnerAccount {
uid: metadata.uid(),
gid: metadata.gid(),
};
me.give_tree(directory.path()).unwrap();
assert!(me.owns(&std::fs::metadata(directory.path().join("a/b/file")).unwrap()));
if !crate::host_fitness::is_elevated() {
let other = RunnerAccount {
uid: me.uid + 1,
gid: me.gid,
};
assert!(other.give_tree(directory.path()).is_err());
}
}
#[test]
fn no_account_is_used_unless_this_is_root_on_linux() {
if !(cfg!(target_os = "linux") && crate::host_fitness::is_elevated()) {
assert_eq!(RunnerPool::for_this_daemon(), Ok(None));
}
}
#[test]
fn pool_accounts_are_numbered_from_one_in_the_runner_group() {
assert_eq!(pool_account_name(1), "rm-runner-1");
assert_eq!(pool_account_name(POOL_SIZE), "rm-runner-32");
}
#[test]
fn a_pool_hands_out_its_own_accounts_and_the_legacy_one_only_alone() {
let legacy = RunnerAccount { uid: 997, gid: 972 };
let first = RunnerAccount { uid: 990, gid: 972 };
let mut pool = RunnerPool {
gid: 972,
accounts: vec![first],
legacy: Some(legacy),
};
let none = std::collections::BTreeSet::new();
let held = std::collections::BTreeSet::from([990]);
assert_eq!(pool.allocate(None, &none, Default::default), Some(first));
assert_eq!(pool.allocate(None, &held, Default::default), None);
assert_eq!(pool.allocate(None, &none, || held.clone()), None);
assert_eq!(
pool.allocate(Some(first), &none, || held.clone()),
Some(first)
);
assert_eq!(pool.account(997), Some(legacy));
assert_eq!(pool.account(990), Some(first));
assert_eq!(pool.account(0), None);
pool.accounts.clear();
assert_eq!(pool.allocate(None, &held, Default::default), Some(legacy));
}
#[cfg(unix)]
#[test]
fn the_default_acl_is_the_kernel_encoding_of_group_rwx() {
assert_eq!(
sys::default_acl(),
[
2, 0, 0, 0, 1, 0, 7, 0, 255, 255, 255, 255, 4, 0, 7, 0, 255, 255, 255, 255, 32, 0, 0, 0, 255, 255, 255, 255, ]
);
}
#[cfg(target_os = "linux")]
#[test]
fn sharing_a_tree_opens_it_to_the_group_once_and_never_follows_links() {
use std::os::unix::fs::MetadataExt as _;
let directory = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let root = directory.path().join("cache");
std::fs::create_dir_all(root.join("npm/_cacache")).unwrap();
std::fs::write(root.join("npm/_cacache/entry"), b"x").unwrap();
std::fs::set_permissions(
root.join("npm/_cacache/entry"),
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("link")).unwrap();
let gid = std::fs::metadata(&root).unwrap().gid();
let pool = RunnerPool {
gid,
accounts: Vec::new(),
legacy: None,
};
pool.share_tree(&root).unwrap();
let mode = |path: &Path| std::fs::metadata(path).unwrap().mode() & 0o7777;
assert_eq!(mode(&root.join("npm")) & 0o2070, 0o2070);
assert_eq!(mode(&root.join("npm/_cacache/entry")), 0o660);
assert!(pool.is_shared(&std::fs::metadata(root.join("npm")).unwrap()));
assert_eq!(
mode(outside.path()) & 0o2000,
0,
"a link's target is never touched"
);
}
}