#![cfg(unix)]
use gate4agent_catalog::AgentRegistry;
use hatchery_enroll::KillSwitch;
use std::ffi::CString;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::OnceLock;
static RUN_AS: OnceLock<String> = OnceLock::new();
#[derive(Debug, thiserror::Error)]
pub enum IsolationError {
#[error("user '{0}' does not exist")]
UnknownUser(String),
#[error("run-as user must not be root")]
RootRefused,
#[error("kill-switch: {0}")]
KillSwitch(String),
}
struct Account {
uid: libc::uid_t,
gid: libc::gid_t,
home: String,
}
fn lookup(user: &str) -> Result<Account, IsolationError> {
let name = CString::new(user).map_err(|_| IsolationError::UnknownUser(user.to_owned()))?;
let pw = unsafe { libc::getpwnam(name.as_ptr()) };
if pw.is_null() {
return Err(IsolationError::UnknownUser(user.to_owned()));
}
let (uid, gid, home) = unsafe {
let pw = &*pw;
(pw.pw_uid, pw.pw_gid, std::ffi::CStr::from_ptr(pw.pw_dir).to_string_lossy().into_owned())
};
if uid == 0 {
return Err(IsolationError::RootRefused);
}
Ok(Account { uid, gid, home })
}
pub fn set_run_as(user: &str) -> Result<(), IsolationError> {
lookup(user)?;
let _ = RUN_AS.set(user.to_owned());
Ok(())
}
pub fn run_as() -> Option<&'static str> {
RUN_AS.get().map(String::as_str)
}
pub fn wrap_registry(catalog: AgentRegistry) -> Result<AgentRegistry, String> {
let Some(user) = run_as() else { return Ok(catalog) };
let me = std::env::current_exe().map_err(|e| format!("cannot locate the node binary: {e}"))?;
let mut specs = Vec::new();
for spec in catalog.iter() {
let mut spec = spec.clone();
let mut args = vec!["exec-as".to_owned(), user.to_owned(), "--".to_owned(), spec.launch.program.clone()];
args.append(&mut spec.launch.fixed_args);
spec.launch.program = me.to_string_lossy().into_owned();
spec.launch.fixed_args = args;
specs.push(spec);
}
AgentRegistry::new(specs).map_err(|e| e.to_string())
}
pub fn exec_as(user: &str, argv: &[String]) -> Result<std::convert::Infallible, String> {
let account = lookup(user).map_err(|e| e.to_string())?;
let (program, rest) = argv.split_first().ok_or("exec-as needs a program")?;
let cuser = CString::new(user).map_err(|e| e.to_string())?;
unsafe {
if libc::geteuid() != account.uid {
if libc::initgroups(cuser.as_ptr(), account.gid as _) != 0
|| libc::setgid(account.gid) != 0
|| libc::setuid(account.uid) != 0
{
return Err(format!("cannot drop privileges: {}", std::io::Error::last_os_error()));
}
libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_CLEAR_ALL as libc::c_ulong, 0, 0, 0);
let header = CapHeader { version: 0x2008_0522, pid: 0 };
let none = [CapData { effective: 0, permitted: 0, inheritable: 0 }; 2];
if libc::syscall(libc::SYS_capset, &header as *const CapHeader, none.as_ptr()) != 0 {
return Err(format!("cannot clear capabilities: {}", std::io::Error::last_os_error()));
}
if libc::setuid(0) == 0 {
return Err("privilege drop is reversible; refusing to exec".to_owned());
}
}
}
use std::os::unix::process::CommandExt;
let error = Command::new(program)
.args(rest)
.env("HOME", &account.home)
.env("USER", user)
.env("LOGNAME", user)
.exec();
Err(format!("cannot exec {program}: {error}"))
}
pub fn apply_kill_switch(ks: &KillSwitch) -> Result<&'static str, IsolationError> {
let fail = |m: String| IsolationError::KillSwitch(m);
let feed = |tool: &str, args: &[&str], text: &str| -> Result<(), String> {
let mut child = Command::new(tool)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("{tool}: {e}"))?;
child.stdin.take().expect("piped").write_all(text.as_bytes()).map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(format!("{tool} failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
}
};
let ruleset = format!("table inet {t}\ndelete table inet {t}\n{}", ks.nft_ruleset(), t = KillSwitch::TABLE);
let rules_file = std::env::temp_dir().join(format!("hatchery-killswitch-{}.nft", std::process::id()));
std::fs::write(&rules_file, &ruleset).map_err(|e| fail(e.to_string()))?;
let nft = Command::new("nft")
.arg("-f")
.arg(&rules_file)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("nft: {e}"))
.and_then(|out| {
if out.status.success() {
Ok(())
} else {
Err(format!("nft failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
}
});
let _ = std::fs::remove_file(&rules_file);
match nft {
Ok(()) => {
let list = Command::new("nft")
.args(["list", "table", "inet", KillSwitch::TABLE])
.output()
.map_err(|e| fail(e.to_string()))?;
if !list.status.success() {
return Err(fail("nft accepted the ruleset but the table is not present".into()));
}
Ok("nft")
}
Err(nft_error) => match ks.iptables_restore() {
Some(text) => feed("iptables-restore", &[], &text)
.map(|()| "iptables")
.map_err(|e| fail(format!("{nft_error}; {e}"))),
None => Err(fail(nft_error)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_and_unknown_users_are_refused() {
assert!(matches!(set_run_as("root"), Err(IsolationError::RootRefused)));
assert!(matches!(set_run_as("no-such-user-hatchery"), Err(IsolationError::UnknownUser(_))));
}
#[test]
fn without_run_as_the_catalog_is_untouched() {
let catalog = gate4agent_catalog::builtin_registry().clone();
let before = catalog.iter().map(|s| s.launch.program.clone()).collect::<Vec<_>>();
let after = wrap_registry(catalog).unwrap();
assert_eq!(before, after.iter().map(|s| s.launch.program.clone()).collect::<Vec<_>>());
}
}
const CAP_SETGID: u32 = 6;
const CAP_SETUID: u32 = 7;
#[repr(C)]
struct CapHeader {
version: u32,
pid: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct CapData {
effective: u32,
permitted: u32,
inheritable: u32,
}
fn last_cap() -> u32 {
std::fs::read_to_string("/proc/sys/kernel/cap_last_cap").ok().and_then(|t| t.trim().parse().ok()).unwrap_or(40)
}
pub fn drop_privileges(user: &str, argv: &[String]) -> Result<std::convert::Infallible, String> {
let account = lookup(user).map_err(|e| e.to_string())?;
let (program, rest) = argv.split_first().ok_or("drop-privileges needs a program")?;
let cuser = CString::new(user).map_err(|e| e.to_string())?;
unsafe {
if libc::geteuid() != 0 {
return Err("drop-privileges needs root".to_owned());
}
if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
}
for cap in 0..=last_cap() {
if cap != CAP_SETUID && cap != CAP_SETGID && libc::prctl(libc::PR_CAPBSET_DROP, cap as libc::c_ulong, 0, 0, 0) != 0 {
return Err(format!("cannot drop capability {cap}: {}", std::io::Error::last_os_error()));
}
}
if libc::prctl(libc::PR_SET_KEEPCAPS, 1, 0, 0, 0) != 0 {
return Err(format!("keepcaps: {}", std::io::Error::last_os_error()));
}
if libc::initgroups(cuser.as_ptr(), account.gid as _) != 0 || libc::setgid(account.gid) != 0 || libc::setuid(account.uid) != 0 {
return Err(format!("cannot switch user: {}", std::io::Error::last_os_error()));
}
let mask = (1_u32 << CAP_SETUID) | (1_u32 << CAP_SETGID);
let header = CapHeader { version: 0x2008_0522, pid: 0 };
let data = [CapData { effective: mask, permitted: mask, inheritable: mask }, CapData { effective: 0, permitted: 0, inheritable: 0 }];
if libc::syscall(libc::SYS_capset, &header as *const CapHeader, data.as_ptr()) != 0 {
return Err(format!("capset: {}", std::io::Error::last_os_error()));
}
for cap in [CAP_SETUID, CAP_SETGID] {
if libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_RAISE as libc::c_ulong, cap as libc::c_ulong, 0, 0) != 0 {
return Err(format!("ambient capability {cap}: {}", std::io::Error::last_os_error()));
}
}
}
use std::os::unix::process::CommandExt;
let error = Command::new(program)
.args(rest)
.env("HOME", &account.home)
.env("USER", user)
.env("LOGNAME", user)
.exec();
Err(format!("cannot exec {program}: {error}"))
}
pub fn net_up(state_dir: &std::path::Path, wg_key: Option<&std::path::Path>, interface: &str, kill_switch: bool) -> Result<(), String> {
let (_, state) = crate::enroll_client::load_enrolled_signing_only(state_dir).map_err(|e| e.to_string())?;
let r = &state.response;
let need = |v: &Option<String>, n: &str| v.clone().ok_or_else(|| format!("enrolled state lacks {n}"));
let (addr, peer_addr) = (need(&r.tunnel_ip, "tunnel address")?, need(&r.c2_tunnel_ip, "C2 tunnel address")?);
let (peer_key, endpoint) = (need(&r.c2_wg_public_key, "C2 public key")?, need(&r.c2_wg_endpoint, "C2 endpoint")?);
let key_path = wg_key.map(std::path::Path::to_path_buf).unwrap_or_else(|| hatchery_enroll::NodeIdentity::wg_path(state_dir));
let run = |program: &str, args: &[&str]| -> Result<(), String> {
let out = Command::new(program).args(args).output().map_err(|e| format!("{program}: {e}"))?;
if out.status.success() { Ok(()) } else { Err(format!("{program} {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())) }
};
if kill_switch {
let ks = KillSwitch::new(interface, &endpoint).map_err(|e| e.to_string())?;
apply_kill_switch(&ks).map_err(|e| e.to_string())?;
}
let _ = Command::new("ip").args(["link", "del", "dev", interface]).output();
run("ip", &["link", "add", "dev", interface, "type", "wireguard"])?;
let family = if peer_addr.contains(':') { 128 } else { 32 };
let allowed = format!("{peer_addr}/{family}");
run("wg", &["set", interface, "private-key", &key_path.to_string_lossy(), "peer", &peer_key, "endpoint", &endpoint, "allowed-ips", &allowed, "persistent-keepalive", "25"])?;
run("ip", &["addr", "replace", &format!("{addr}/{family}"), "dev", interface])?;
run("ip", &["link", "set", "up", "dev", interface])?;
run("ip", &["route", "replace", &allowed, "dev", interface])?;
Ok(())
}