1use std::collections::HashSet;
2use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
3use std::os::unix::net::{UnixListener, UnixStream};
4use std::path::Path;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
7use std::time::Duration;
8
9use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
10use nix::sys::socket::{getsockopt, sockopt};
11
12use crate::config::Config;
13use crate::config::validation::parse_idle_timeout_secs;
14use crate::process;
15use crate::protocol::{GuestMessage, HostMessage, read_frame, write_frame};
16use crate::systemd;
17
18mod handlers;
19
20const MAX_CONCURRENT: usize = 4;
22
23const MAX_SESSIONS: u32 = 64;
25
26const PING_INTERVAL: Duration = Duration::from_mins(1);
28
29static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
30
31fn setup_signal_handler() -> nix::Result<()> {
35 extern "C" fn handle_signal(_: i32) {
36 SHUTDOWN_REQUESTED.store(true, Ordering::Relaxed);
37 }
38 let sig_action = SigAction::new(
39 SigHandler::Handler(handle_signal),
40 SaFlags::empty(),
41 SigSet::empty(),
42 );
43 unsafe {
44 sigaction(Signal::SIGTERM, &sig_action)?;
45 sigaction(Signal::SIGINT, &sig_action)?;
46 }
47 Ok(())
48}
49
50struct SharedState {
52 session_count: AtomicU32,
54 container_name: String,
56 idle_timeout_secs: u64,
58 was_socket_activated: bool,
63}
64
65pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
67 let _ = setup_signal_handler();
68
69 let config = config.clone();
70 let path = socket_path.to_path_buf();
71 let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
72
73 let activation_fd = listen_fd();
74 let was_socket_activated = activation_fd.is_some();
75 let listener = match activation_fd {
76 Some(fd) => unsafe { UnixListener::from_raw_fd(fd) },
77 None => {
78 let _ = std::fs::remove_file(&path);
79 UnixListener::bind(&path)?
80 }
81 };
82
83 let state = Arc::new(SharedState {
84 session_count: AtomicU32::new(0),
85 container_name: container_name.to_string(),
86 idle_timeout_secs,
87 was_socket_activated,
88 });
89
90 let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
91
92 loop {
93 if SHUTDOWN_REQUESTED.load(Ordering::Relaxed) {
94 tracing::info!("podbox: shutdown requested, draining connections...");
95 drop(listener);
96 for h in handles {
97 let _ = h.join();
98 }
99 return Ok(());
100 }
101
102 match listener.accept() {
103 Ok((mut stream, _)) => {
104 handles.retain_mut(|h| !h.is_finished());
105
106 if handles.len() >= MAX_CONCURRENT {
107 tracing::warn!(
108 "dropping connection: {} concurrent clients already in flight",
109 handles.len()
110 );
111 continue;
112 }
113
114 let cfg = config.clone();
115 let state = Arc::clone(&state);
116 let handle = std::thread::spawn(move || {
117 if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
118 tracing::error!("error handling connection: {}", e);
119 }
120 });
121 handles.push(handle);
122 }
123 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
124 Err(e) => {
125 tracing::error!("socket accept failed: {}", e);
126 break;
127 }
128 }
129 }
130
131 Ok(())
132}
133
134fn listen_fd() -> Option<RawFd> {
135 let pid = std::env::var("LISTEN_PID").ok()?.parse::<u32>().ok()?;
136 if pid != std::process::id() {
137 return None;
138 }
139 let fds = std::env::var("LISTEN_FDS").ok()?.parse::<u32>().ok()?;
140 if fds == 0 {
141 return None;
142 }
143 Some(3)
144}
145
146fn handle_connection(
147 stream: &mut UnixStream,
148 config: &Config,
149 state: &Arc<SharedState>,
150) -> anyhow::Result<()> {
151 stream.set_read_timeout(Some(PING_INTERVAL))?;
152 let mut last_ping = std::time::Instant::now();
153 let mut negotiated: Option<HashSet<String>> = None;
157 let mut failures: u32 = 0;
160
161 loop {
162 let msg_bytes = match read_frame(stream) {
163 Ok(Some(b)) => b,
164 Ok(None) => return Ok(()),
165 Err(e)
166 if e.kind() == std::io::ErrorKind::WouldBlock
167 || e.kind() == std::io::ErrorKind::TimedOut =>
168 {
169 if last_ping.elapsed() >= PING_INTERVAL {
170 if write_frame(stream, &HostMessage::Ping).is_err() {
171 return Ok(());
172 }
173 last_ping = std::time::Instant::now();
174 }
175 continue;
176 }
177 Err(e) => return Err(e.into()),
178 };
179
180 last_ping = std::time::Instant::now();
181 let msg: GuestMessage = match serde_json::from_slice(&msg_bytes) {
182 Ok(m) => m,
183 Err(e) => {
184 tracing::warn!("malformed frame from peer: {e}");
185 if note_failure(stream, &mut failures, "malformed frame") {
186 return Ok(());
187 }
188 continue;
189 }
190 };
191
192 match msg {
193 GuestMessage::Hello {
194 protocol_version,
195 guest_version,
196 container,
197 capabilities,
198 } => {
199 let outcome = handlers::handle_hello(
200 stream,
201 &config.integration,
202 state.idle_timeout_secs,
203 protocol_version,
204 guest_version,
205 container,
206 capabilities,
207 )?;
208 let handlers::HelloOutcome::Accepted(accepted) = outcome else {
209 if note_failure(stream, &mut failures, "hello rejected") {
212 return Ok(());
213 }
214 continue;
215 };
216 negotiated = Some(accepted.into_iter().collect());
217
218 }
226 GuestMessage::RegisterSession => {
227 if !peer_is_in_host_userns(stream) {
230 tracing::warn!("rejecting RegisterSession from foreign user namespace");
231 let _ = write_frame(
232 stream,
233 &HostMessage::Error {
234 reason: "register_session is host-only".into(),
235 },
236 );
237 return Ok(());
238 }
239 if state.session_count.load(Ordering::SeqCst) >= MAX_SESSIONS {
240 tracing::warn!("rejecting RegisterSession: session cap reached");
241 let _ = write_frame(
242 stream,
243 &HostMessage::Error {
244 reason: "session limit reached".into(),
245 },
246 );
247 return Ok(());
248 }
249 let raw_fd = match process::recv_fd(stream) {
251 Ok(Some(fd)) => fd,
252 Ok(None) => return Ok(()),
253 Err(_) => return Ok(()),
254 };
255 let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };
256 state.session_count.fetch_add(1, Ordering::SeqCst);
257 let s = Arc::clone(state);
258 std::thread::spawn(move || monitor_pidfd(fd, s));
259 return Ok(());
262 }
263 GuestMessage::Busy => {
264 if negotiated.is_none() {
265 if note_failure(stream, &mut failures, "hello required") {
266 return Ok(());
267 }
268 }
269 }
270 GuestMessage::IdleTimeout => {
271 if negotiated.is_none() {
272 if note_failure(stream, &mut failures, "hello required") {
273 return Ok(());
274 }
275 continue;
276 }
277 if state.idle_timeout_secs > 0 {
278 let name = &state.container_name;
279 tracing::info!("container '{}' idle — stopping", name);
280 let _ = systemd::stop_unit(name);
281 if state.was_socket_activated {
287 std::process::exit(0);
288 }
289 }
290 }
291 GuestMessage::Notify {
292 summary,
293 body,
294 urgency: _,
295 actions,
296 app_name: _,
297 } => {
298 if !has_cap(&negotiated, crate::protocol::CAP_NOTIFY) {
299 if note_failure(stream, &mut failures, "capability 'notify' not accepted") {
300 return Ok(());
301 }
302 continue;
303 }
304 handlers::handle_notify(stream, summary, body, actions)?
305 }
306 GuestMessage::XdgOpen { uri } => {
307 if !has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN) {
308 if note_failure(stream, &mut failures, "capability 'xdg_open' not accepted") {
309 return Ok(());
310 }
311 continue;
312 }
313 handlers::handle_xdg_open(uri)?
314 }
315 GuestMessage::ClipboardSet { text } => {
316 if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
317 if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
318 return Ok(());
319 }
320 continue;
321 }
322 handlers::handle_clipboard_set(text)?
323 }
324 GuestMessage::ClipboardGet => {
325 if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
326 if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
327 return Ok(());
328 }
329 continue;
330 }
331 handlers::handle_clipboard_get(stream)?
332 }
333 GuestMessage::HostExec { cmd, args } => {
334 if !has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC) {
335 if note_failure(stream, &mut failures, "capability 'host_exec' not accepted") {
336 return Ok(());
337 }
338 continue;
339 }
340 handlers::handle_host_exec(stream, &config.integration, cmd, args)?
341 }
342 }
343 }
344}
345
346const MAX_NEGOTIATION_FAILURES: u32 = 5;
349
350fn note_failure(stream: &mut UnixStream, failures: &mut u32, reason: &str) -> bool {
354 *failures = failures.saturating_add(1);
355 let _ = write_frame(
356 stream,
357 &HostMessage::Error {
358 reason: reason.to_string(),
359 },
360 );
361 *failures >= MAX_NEGOTIATION_FAILURES
362}
363
364fn has_cap(negotiated: &Option<HashSet<String>>, cap: &str) -> bool {
366 negotiated
367 .as_ref()
368 .is_some_and(|caps| caps.iter().any(|c| c == cap))
369}
370
371fn peer_is_in_host_userns(stream: &UnixStream) -> bool {
377 let creds = match getsockopt(stream, sockopt::PeerCredentials) {
378 Ok(c) => c,
379 Err(_) => return false,
380 };
381 let self_ns = std::fs::read_link("/proc/self/ns/user").ok();
382 let peer_ns = std::fs::read_link(format!("/proc/{}/ns/user", creds.pid())).ok();
383 match (self_ns, peer_ns) {
384 (Some(a), Some(b)) => a == b,
385 _ => false,
386 }
387}
388
389fn monitor_pidfd(fd: OwnedFd, state: Arc<SharedState>) {
392 let mut pfd = nix::libc::pollfd {
393 fd: fd.as_raw_fd(),
394 events: nix::libc::POLLIN,
395 revents: 0,
396 };
397
398 loop {
399 let ret = unsafe { nix::libc::poll(&raw mut pfd, 1, -1) };
400 if ret < 0 {
401 let errno = unsafe { *nix::libc::__errno_location() };
402 if errno == nix::libc::EINTR {
403 continue;
404 }
405 break;
406 }
407 if pfd.revents & (nix::libc::POLLIN | nix::libc::POLLHUP | nix::libc::POLLERR) != 0 {
408 break;
409 }
410 }
411
412 let _ = state.session_count.fetch_sub(1, Ordering::SeqCst);
413 }
416
417#[cfg(test)]
418mod tests {
419 use super::handlers::{validate_host_exec_args, validate_uri};
420 use super::{has_cap, note_failure};
421 use std::collections::HashSet;
422
423 #[test]
426 fn allows_http_https_mailto() {
427 assert_eq!(
428 validate_uri("https://example.com"),
429 Some("https://example.com".to_string())
430 );
431 assert_eq!(
432 validate_uri("http://example.com"),
433 Some("http://example.com".to_string())
434 );
435 assert_eq!(
436 validate_uri("mailto:user@host"),
437 Some("mailto:user@host".to_string())
438 );
439 }
440
441 #[test]
442 fn refuses_path_traversal() {
443 assert_eq!(validate_uri("/etc/passwd"), None);
444 assert_eq!(validate_uri("../foo"), None);
445 assert_eq!(validate_uri(""), None);
446 }
447
448 #[test]
449 fn refuses_unknown_alphabetic_schemes() {
450 assert_eq!(validate_uri("javascript:alert(1)"), None);
451 assert_eq!(validate_uri("file:///etc/passwd"), None);
452 }
453
454 #[test]
455 fn wraps_bare_domain() {
456 assert_eq!(
457 validate_uri("example.com"),
458 Some("https://example.com".to_string())
459 );
460 }
461
462 #[test]
463 fn trims_whitespace() {
464 assert_eq!(
465 validate_uri(" https://example.com "),
466 Some("https://example.com".to_string())
467 );
468 }
469
470 #[test]
473 fn accepts_plain_args() {
474 assert!(validate_host_exec_args(&["ls".into()]).is_ok());
475 assert!(validate_host_exec_args(&["ls".into(), "-la".into(), "/tmp".into()]).is_ok());
476 assert!(validate_host_exec_args(&["git".into(), "log".into(), "--oneline".into()]).is_ok());
477 }
478
479 #[test]
480 fn rejects_shell_metacharacters() {
481 assert!(validate_host_exec_args(&["echo".into(), "foo;bar".into()]).is_err());
482 assert!(validate_host_exec_args(&["echo".into(), "foo|bar".into()]).is_err());
483 assert!(validate_host_exec_args(&["echo".into(), "foo&bar".into()]).is_err());
484 assert!(validate_host_exec_args(&["echo".into(), "$PATH".into()]).is_err());
485 assert!(validate_host_exec_args(&["echo".into(), "`ls`".into()]).is_err());
486 }
487
488 #[test]
489 fn rejects_redirection_operators() {
490 assert!(validate_host_exec_args(&["cat".into(), "<file".into()]).is_err());
491 assert!(validate_host_exec_args(&["echo".into(), ">file".into()]).is_err());
492 assert!(validate_host_exec_args(&["echo".into(), ">>file".into()]).is_err());
493 }
494
495 #[test]
496 fn rejects_glob_and_brace_chars() {
497 assert!(validate_host_exec_args(&["ls".into(), "*.rs".into()]).is_err());
498 assert!(validate_host_exec_args(&["ls".into(), "file?".into()]).is_err());
499 assert!(validate_host_exec_args(&["ls".into(), "[abc]".into()]).is_err());
500 assert!(validate_host_exec_args(&["echo".into(), "{a,b}".into()]).is_err());
501 }
502
503 #[test]
504 fn rejects_subshell_and_escape_chars() {
505 assert!(validate_host_exec_args(&["echo".into(), "$(whoami)".into()]).is_err());
506 assert!(validate_host_exec_args(&["echo".into(), "line1\nline2".into()]).is_err());
507 }
508
509 #[test]
510 fn rejects_restricted_flag_patterns() {
511 assert!(validate_host_exec_args(&["git".into(), "--exec-path=/tmp".into()]).is_err());
512 assert!(validate_host_exec_args(&["git".into(), "--config=user.name".into()]).is_err());
513 assert!(validate_host_exec_args(&["vim".into(), "--plugin=malicious".into()]).is_err());
514 assert!(validate_host_exec_args(&["python".into(), "--load=malicious".into()]).is_err());
515 assert!(validate_host_exec_args(&["python".into(), "--module=malicious".into()]).is_err());
516 assert!(validate_host_exec_args(&["git".into(), "--remote=evil".into()]).is_err());
517 assert!(
518 validate_host_exec_args(&[
519 "ssh".into(),
520 "-o".into(),
521 "StrictHostKeyChecking=no".into()
522 ])
523 .is_err()
524 );
525 }
526
527 #[test]
528 fn restricted_flag_detection_is_case_insensitive() {
529 assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=/tmp".into()]).is_err());
530 assert!(validate_host_exec_args(&["GIT".into(), "--Config=evil".into()]).is_err());
531 }
532
533 #[test]
534 fn does_not_restrict_safe_flags() {
535 assert!(validate_host_exec_args(&["git".into(), "--exec".into()]).is_ok());
536 assert!(
537 validate_host_exec_args(&["git".into(), "--exec-path-is-ok".into()]).is_err(),
538 "--exec-path prefix still blocked"
539 );
540 assert!(validate_host_exec_args(&["ls".into(), "--color=auto".into()]).is_ok());
541 assert!(validate_host_exec_args(&["cargo".into(), "--offline".into()]).is_ok());
542 }
543
544 #[test]
545 fn rejects_empty_args_gracefully() {
546 assert!(
547 validate_host_exec_args(&[String::new()]).is_ok(),
548 "empty string is not a metachar"
549 );
550 }
551
552 #[test]
553 fn ascii_lowercase_only() {
554 assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=".into()]).is_err());
555 assert!(
556 validate_host_exec_args(&["git".into(), "--\u{0130}".into()]).is_ok(),
557 "Turkish \u{0130} is non-ASCII"
558 );
559 }
560
561 #[test]
564 fn has_cap_none_negotiated_rejects() {
565 assert!(!has_cap(&None, crate::protocol::CAP_NOTIFY));
566 assert!(!has_cap(&None, crate::protocol::CAP_CLIPBOARD));
567 }
568
569 #[test]
570 fn has_cap_accepts_negotiated() {
571 let caps = HashSet::from(["notify".to_string(), "clipboard".to_string()]);
572 let negotiated = Some(caps);
573 assert!(has_cap(&negotiated, crate::protocol::CAP_NOTIFY));
574 assert!(has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD));
575 assert!(!has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN));
576 assert!(!has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC));
577 }
578
579 fn socket_pair() -> (
582 std::os::unix::net::UnixStream,
583 std::os::unix::net::UnixStream,
584 ) {
585 std::os::unix::net::UnixStream::pair().expect("socketpair")
586 }
587
588 #[test]
589 fn note_failure_is_false_below_threshold() {
590 let (mut server, _client) = socket_pair();
591 let mut failures: u32 = 0;
592 for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
593 assert!(!note_failure(&mut server, &mut failures, "probe"));
594 }
595 assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES - 1);
596 }
597
598 #[test]
599 fn note_failure_drops_after_threshold() {
600 let (mut server, mut client) = socket_pair();
601 let mut failures: u32 = 0;
602 for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
603 assert!(!note_failure(&mut server, &mut failures, "probe"));
604 }
605 assert!(note_failure(&mut server, &mut failures, "probe"));
606 assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES);
607
608 use crate::protocol::read_frame;
610 for _ in 0..super::MAX_NEGOTIATION_FAILURES {
611 let bytes = read_frame(&mut client).unwrap().expect("error frame");
612 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
613 assert!(
614 matches!(msg, crate::protocol::HostMessage::Error { reason } if reason == "probe")
615 );
616 }
617 }
618
619 #[test]
622 fn hello_protocol_mismatch_is_rejected() {
623 use super::handlers::{HelloOutcome, handle_hello};
624 let (mut server, mut client) = socket_pair();
625 let config = crate::config::Config::embedded();
626 let outcome = handle_hello(
627 &mut server,
628 &config.integration,
629 0,
630 crate::protocol::PROTOCOL_VERSION + 1,
631 "test".into(),
632 "test".into(),
633 vec![],
634 )
635 .unwrap();
636 assert!(matches!(outcome, HelloOutcome::Rejected));
637
638 let bytes = crate::protocol::read_frame(&mut client)
640 .unwrap()
641 .expect("shutdown frame");
642 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
643 assert!(matches!(msg, crate::protocol::HostMessage::Shutdown));
644 }
645
646 #[test]
647 fn hello_accepts_enabled_capabilities() {
648 use super::handlers::{HelloOutcome, handle_hello};
649 let (mut server, mut client) = socket_pair();
650 let mut config = crate::config::Config::embedded();
651 config.integration.notify = true;
652 config.integration.clipboard = true;
653 config.integration.xdg_open = false;
654 let outcome = handle_hello(
655 &mut server,
656 &config.integration,
657 0,
658 crate::protocol::PROTOCOL_VERSION,
659 "test".into(),
660 "test".into(),
661 vec![
662 crate::protocol::CAP_NOTIFY.to_string(),
663 crate::protocol::CAP_XDG_OPEN.to_string(),
664 ],
665 )
666 .unwrap();
667 let HelloOutcome::Accepted(accepted) = outcome else {
668 panic!("expected Accepted");
669 };
670 assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
671
672 let bytes = crate::protocol::read_frame(&mut client)
673 .unwrap()
674 .expect("hello ack");
675 let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
676 match msg {
677 crate::protocol::HostMessage::HelloAck {
678 accepted, rejected, ..
679 } => {
680 assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
681 assert_eq!(rejected, vec![crate::protocol::CAP_XDG_OPEN]);
682 }
683 other => panic!("expected HelloAck, got {other:?}"),
684 }
685 }
686}