Skip to main content

hatchery_node/
isolation.rs

1//! Node-side isolation: run provider sessions as an unprivileged user, and apply the egress
2//! kill-switch rendered by `hatchery_enroll::KillSwitch`. Linux/Unix only; both are opt-in
3//! and fail closed (a requested isolation that cannot be set up stops the node).
4
5#![cfg(unix)]
6
7use gate4agent_catalog::AgentRegistry;
8use hatchery_enroll::KillSwitch;
9use std::ffi::CString;
10use std::io::Write;
11use std::process::{Command, Stdio};
12use std::sync::OnceLock;
13
14static RUN_AS: OnceLock<String> = OnceLock::new();
15
16#[derive(Debug, thiserror::Error)]
17pub enum IsolationError {
18    #[error("user '{0}' does not exist")]
19    UnknownUser(String),
20    #[error("run-as user must not be root")]
21    RootRefused,
22    #[error("kill-switch: {0}")]
23    KillSwitch(String),
24}
25
26struct Account {
27    uid: libc::uid_t,
28    gid: libc::gid_t,
29    home: String,
30}
31
32fn lookup(user: &str) -> Result<Account, IsolationError> {
33    let name = CString::new(user).map_err(|_| IsolationError::UnknownUser(user.to_owned()))?;
34    // SAFETY: getpwnam returns a pointer into static storage that we copy out immediately.
35    let pw = unsafe { libc::getpwnam(name.as_ptr()) };
36    if pw.is_null() {
37        return Err(IsolationError::UnknownUser(user.to_owned()));
38    }
39    // SAFETY: non-null checked above.
40    let (uid, gid, home) = unsafe {
41        let pw = &*pw;
42        (pw.pw_uid, pw.pw_gid, std::ffi::CStr::from_ptr(pw.pw_dir).to_string_lossy().into_owned())
43    };
44    if uid == 0 {
45        return Err(IsolationError::RootRefused);
46    }
47    Ok(Account { uid, gid, home })
48}
49
50/// Validate and remember the unprivileged user for provider sessions.
51pub fn set_run_as(user: &str) -> Result<(), IsolationError> {
52    lookup(user)?;
53    let _ = RUN_AS.set(user.to_owned());
54    Ok(())
55}
56
57pub fn run_as() -> Option<&'static str> {
58    RUN_AS.get().map(String::as_str)
59}
60
61/// Make every catalog launcher start through `<node> exec-as USER -- <program> <args>`.
62/// The version probe keeps using the original catalog, so only spawned sessions change.
63pub fn wrap_registry(catalog: AgentRegistry) -> Result<AgentRegistry, String> {
64    let Some(user) = run_as() else { return Ok(catalog) };
65    let me = std::env::current_exe().map_err(|e| format!("cannot locate the node binary: {e}"))?;
66    let mut specs = Vec::new();
67    for spec in catalog.iter() {
68        let mut spec = spec.clone();
69        let mut args = vec!["exec-as".to_owned(), user.to_owned(), "--".to_owned(), spec.launch.program.clone()];
70        args.append(&mut spec.launch.fixed_args);
71        spec.launch.program = me.to_string_lossy().into_owned();
72        spec.launch.fixed_args = args;
73        specs.push(spec);
74    }
75    AgentRegistry::new(specs).map_err(|e| e.to_string())
76}
77
78/// `exec-as USER -- program args...`: drop to USER (groups, gid, uid), prove the drop cannot be
79/// undone, set HOME/USER/LOGNAME, and exec. Never returns on success.
80pub fn exec_as(user: &str, argv: &[String]) -> Result<std::convert::Infallible, String> {
81    let account = lookup(user).map_err(|e| e.to_string())?;
82    let (program, rest) = argv.split_first().ok_or("exec-as needs a program")?;
83    let cuser = CString::new(user).map_err(|e| e.to_string())?;
84    // SAFETY: plain libc credential calls, in order: groups, gid, uid. A process that is not root
85    // may do this only while it holds CAP_SETUID/CAP_SETGID (see `drop_privileges`); either way
86    // the drop must be irreversible before anything is exec'd.
87    unsafe {
88        if libc::geteuid() != account.uid {
89            if libc::initgroups(cuser.as_ptr(), account.gid as _) != 0
90                || libc::setgid(account.gid) != 0
91                || libc::setuid(account.uid) != 0
92            {
93                return Err(format!("cannot drop privileges: {}", std::io::Error::last_os_error()));
94            }
95            // A caller that already ran as a non-root user keeps its capabilities across the id
96            // change, so they are removed explicitly: nothing may survive into the session.
97            libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_CLEAR_ALL as libc::c_ulong, 0, 0, 0);
98            let header = CapHeader { version: 0x2008_0522, pid: 0 };
99            let none = [CapData { effective: 0, permitted: 0, inheritable: 0 }; 2];
100            if libc::syscall(libc::SYS_capset, &header as *const CapHeader, none.as_ptr()) != 0 {
101                return Err(format!("cannot clear capabilities: {}", std::io::Error::last_os_error()));
102            }
103            if libc::setuid(0) == 0 {
104                return Err("privilege drop is reversible; refusing to exec".to_owned());
105            }
106        }
107    }
108    use std::os::unix::process::CommandExt;
109    let error = Command::new(program)
110        .args(rest)
111        .env("HOME", &account.home)
112        .env("USER", user)
113        .env("LOGNAME", user)
114        .exec();
115    Err(format!("cannot exec {program}: {error}"))
116}
117
118/// Install the kill-switch with `nft` (preferred) or `iptables-restore`; verify afterwards.
119pub fn apply_kill_switch(ks: &KillSwitch) -> Result<&'static str, IsolationError> {
120    let fail = |m: String| IsolationError::KillSwitch(m);
121    let feed = |tool: &str, args: &[&str], text: &str| -> Result<(), String> {
122        let mut child = Command::new(tool)
123            .args(args)
124            .stdin(Stdio::piped())
125            .stdout(Stdio::null())
126            .stderr(Stdio::piped())
127            .spawn()
128            .map_err(|e| format!("{tool}: {e}"))?;
129        child.stdin.take().expect("piped").write_all(text.as_bytes()).map_err(|e| e.to_string())?;
130        let out = child.wait_with_output().map_err(|e| e.to_string())?;
131        if out.status.success() {
132            Ok(())
133        } else {
134            Err(format!("{tool} failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
135        }
136    };
137    // Replace our own table atomically if it already exists.
138    let ruleset = format!("table inet {t}\ndelete table inet {t}\n{}", ks.nft_ruleset(), t = KillSwitch::TABLE);
139    // A file, not stdin: minimal guests have no /dev/stdin link.
140    let rules_file = std::env::temp_dir().join(format!("hatchery-killswitch-{}.nft", std::process::id()));
141    std::fs::write(&rules_file, &ruleset).map_err(|e| fail(e.to_string()))?;
142    let nft = Command::new("nft")
143        .arg("-f")
144        .arg(&rules_file)
145        .stdout(Stdio::null())
146        .stderr(Stdio::piped())
147        .output()
148        .map_err(|e| format!("nft: {e}"))
149        .and_then(|out| {
150            if out.status.success() {
151                Ok(())
152            } else {
153                Err(format!("nft failed: {}", String::from_utf8_lossy(&out.stderr).trim()))
154            }
155        });
156    let _ = std::fs::remove_file(&rules_file);
157    match nft {
158        Ok(()) => {
159            let list = Command::new("nft")
160                .args(["list", "table", "inet", KillSwitch::TABLE])
161                .output()
162                .map_err(|e| fail(e.to_string()))?;
163            if !list.status.success() {
164                return Err(fail("nft accepted the ruleset but the table is not present".into()));
165            }
166            Ok("nft")
167        }
168        Err(nft_error) => match ks.iptables_restore() {
169            Some(text) => feed("iptables-restore", &[], &text)
170                .map(|()| "iptables")
171                .map_err(|e| fail(format!("{nft_error}; {e}"))),
172            None => Err(fail(nft_error)),
173        },
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn root_and_unknown_users_are_refused() {
183        assert!(matches!(set_run_as("root"), Err(IsolationError::RootRefused)));
184        assert!(matches!(set_run_as("no-such-user-hatchery"), Err(IsolationError::UnknownUser(_))));
185    }
186
187    #[test]
188    fn without_run_as_the_catalog_is_untouched() {
189        let catalog = gate4agent_catalog::builtin_registry().clone();
190        let before = catalog.iter().map(|s| s.launch.program.clone()).collect::<Vec<_>>();
191        let after = wrap_registry(catalog).unwrap();
192        assert_eq!(before, after.iter().map(|s| s.launch.program.clone()).collect::<Vec<_>>());
193    }
194}
195
196const CAP_SETGID: u32 = 6;
197const CAP_SETUID: u32 = 7;
198
199#[repr(C)]
200struct CapHeader {
201    version: u32,
202    pid: i32,
203}
204#[repr(C)]
205#[derive(Clone, Copy)]
206struct CapData {
207    effective: u32,
208    permitted: u32,
209    inheritable: u32,
210}
211
212fn last_cap() -> u32 {
213    std::fs::read_to_string("/proc/sys/kernel/cap_last_cap").ok().and_then(|t| t.trim().parse().ok()).unwrap_or(40)
214}
215
216/// `drop-privileges --user USER -- program args...` (run as root, once, before the long-lived
217/// node starts). The program runs as USER with: no_new_privs (no su/sudo/setuid escalation), a
218/// capability bounding set of only CAP_SETUID and CAP_SETGID (so it can start sessions as another
219/// user with `exec-as`, but can never gain CAP_NET_ADMIN, so the tunnel and the kill-switch
220/// installed earlier by `net-up` stay out of its reach), and those two capabilities as ambient.
221pub fn drop_privileges(user: &str, argv: &[String]) -> Result<std::convert::Infallible, String> {
222    let account = lookup(user).map_err(|e| e.to_string())?;
223    let (program, rest) = argv.split_first().ok_or("drop-privileges needs a program")?;
224    let cuser = CString::new(user).map_err(|e| e.to_string())?;
225    // SAFETY: libc credential and capability calls, each checked; order matters (bounding set and
226    // keepcaps first, then ids, then capset, then ambient).
227    unsafe {
228        if libc::geteuid() != 0 {
229            return Err("drop-privileges needs root".to_owned());
230        }
231        if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 {
232            return Err(format!("no_new_privs: {}", std::io::Error::last_os_error()));
233        }
234        for cap in 0..=last_cap() {
235            if cap != CAP_SETUID && cap != CAP_SETGID && libc::prctl(libc::PR_CAPBSET_DROP, cap as libc::c_ulong, 0, 0, 0) != 0 {
236                return Err(format!("cannot drop capability {cap}: {}", std::io::Error::last_os_error()));
237            }
238        }
239        if libc::prctl(libc::PR_SET_KEEPCAPS, 1, 0, 0, 0) != 0 {
240            return Err(format!("keepcaps: {}", std::io::Error::last_os_error()));
241        }
242        if libc::initgroups(cuser.as_ptr(), account.gid as _) != 0 || libc::setgid(account.gid) != 0 || libc::setuid(account.uid) != 0 {
243            return Err(format!("cannot switch user: {}", std::io::Error::last_os_error()));
244        }
245        let mask = (1_u32 << CAP_SETUID) | (1_u32 << CAP_SETGID);
246        let header = CapHeader { version: 0x2008_0522, pid: 0 };
247        let data = [CapData { effective: mask, permitted: mask, inheritable: mask }, CapData { effective: 0, permitted: 0, inheritable: 0 }];
248        if libc::syscall(libc::SYS_capset, &header as *const CapHeader, data.as_ptr()) != 0 {
249            return Err(format!("capset: {}", std::io::Error::last_os_error()));
250        }
251        for cap in [CAP_SETUID, CAP_SETGID] {
252            if libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_RAISE as libc::c_ulong, cap as libc::c_ulong, 0, 0) != 0 {
253                return Err(format!("ambient capability {cap}: {}", std::io::Error::last_os_error()));
254            }
255        }
256    }
257    use std::os::unix::process::CommandExt;
258    let error = Command::new(program)
259        .args(rest)
260        .env("HOME", &account.home)
261        .env("USER", user)
262        .env("LOGNAME", user)
263        .exec();
264    Err(format!("cannot exec {program}: {error}"))
265}
266
267/// `net-up --enroll-state DIR [--wg-key FILE] [--interface hatch0] [--kill-switch]` (root): bring up
268/// the kernel WireGuard interface from the enrolled state and optionally install the egress
269/// kill-switch. After this the long-lived node can run unprivileged with `--no-wg`, and the
270/// WireGuard private key can live in a root-only place the node never reads.
271pub fn net_up(state_dir: &std::path::Path, wg_key: Option<&std::path::Path>, interface: &str, kill_switch: bool) -> Result<(), String> {
272    let (_, state) = crate::enroll_client::load_enrolled_signing_only(state_dir).map_err(|e| e.to_string())?;
273    let r = &state.response;
274    let need = |v: &Option<String>, n: &str| v.clone().ok_or_else(|| format!("enrolled state lacks {n}"));
275    let (addr, peer_addr) = (need(&r.tunnel_ip, "tunnel address")?, need(&r.c2_tunnel_ip, "C2 tunnel address")?);
276    let (peer_key, endpoint) = (need(&r.c2_wg_public_key, "C2 public key")?, need(&r.c2_wg_endpoint, "C2 endpoint")?);
277    let key_path = wg_key.map(std::path::Path::to_path_buf).unwrap_or_else(|| hatchery_enroll::NodeIdentity::wg_path(state_dir));
278    let run = |program: &str, args: &[&str]| -> Result<(), String> {
279        let out = Command::new(program).args(args).output().map_err(|e| format!("{program}: {e}"))?;
280        if out.status.success() { Ok(()) } else { Err(format!("{program} {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim())) }
281    };
282    if kill_switch {
283        // First, so there is no window in which the interface is up and egress is open.
284        let ks = KillSwitch::new(interface, &endpoint).map_err(|e| e.to_string())?;
285        apply_kill_switch(&ks).map_err(|e| e.to_string())?;
286    }
287    let _ = Command::new("ip").args(["link", "del", "dev", interface]).output();
288    run("ip", &["link", "add", "dev", interface, "type", "wireguard"])?;
289    let family = if peer_addr.contains(':') { 128 } else { 32 };
290    let allowed = format!("{peer_addr}/{family}");
291    run("wg", &["set", interface, "private-key", &key_path.to_string_lossy(), "peer", &peer_key, "endpoint", &endpoint, "allowed-ips", &allowed, "persistent-keepalive", "25"])?;
292    run("ip", &["addr", "replace", &format!("{addr}/{family}"), "dev", interface])?;
293    run("ip", &["link", "set", "up", "dev", interface])?;
294    run("ip", &["route", "replace", &allowed, "dev", interface])?;
295    Ok(())
296}