1#![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 let pw = unsafe { libc::getpwnam(name.as_ptr()) };
36 if pw.is_null() {
37 return Err(IsolationError::UnknownUser(user.to_owned()));
38 }
39 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
50pub 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
61pub 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
78pub 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 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 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
118pub 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 let ruleset = format!("table inet {t}\ndelete table inet {t}\n{}", ks.nft_ruleset(), t = KillSwitch::TABLE);
139 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
216pub 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 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
267pub 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 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}