use std::io;
use std::path::Path;
pub const RUNNER_ACCOUNT: &str = "rm-runner";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunnerAccount {
pub uid: u32,
pub gid: u32,
}
impl RunnerAccount {
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(account) = Self::lookup(RUNNER_ACCOUNT)
{
return Ok(Some(account));
}
if sys::bounding_set_is_narrowed() {
return Err(format!(
"the {RUNNER_ACCOUNT} account that this service runs jobs as does not exist, \
or the service lacks the capabilities to start jobs as it, 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(name: &str) -> Option<Self> {
#[cfg(unix)]
{
sys::lookup(name)
}
#[cfg(not(unix))]
{
let _ = name;
None
}
}
pub fn ensure() -> Result<Self, String> {
if let Some(account) = Self::lookup(RUNNER_ACCOUNT) {
return Ok(account);
}
let output = std::process::Command::new("useradd")
.args([
"--system",
"--user-group",
"--no-create-home",
"--home-dir",
"/nonexistent",
"--shell",
"/usr/sbin/nologin",
"--comment",
"runner-manager job runner",
RUNNER_ACCOUNT,
])
.output()
.map_err(|error| format!("cannot run useradd: {error}"))?;
if !output.status.success() {
return Err(format!(
"useradd could not create the {RUNNER_ACCOUNT} account: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Self::lookup(RUNNER_ACCOUNT)
.ok_or_else(|| format!("useradd reported success but {RUNNER_ACCOUNT} does not exist"))
}
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<()> {
let metadata = std::fs::symlink_metadata(path)?;
if !self.owns(&metadata) {
self.give(path)?;
}
if metadata.is_dir() {
for entry in std::fs::read_dir(path)? {
let child = entry?.path();
let owned_directory = std::fs::symlink_metadata(&child)
.is_ok_and(|metadata| metadata.is_dir() && self.owns(&metadata));
if !owned_directory {
self.give_tree(&child)?;
}
}
}
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
}
}
}
#[cfg(unix)]
mod sys {
use super::RunnerAccount;
#[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!(RunnerAccount::for_this_daemon(), Ok(None));
}
}
}