1use std::collections::HashSet;
2use std::os::fd::{AsFd, 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::socket::{getsockopt, sockopt};
10
11use crate::config::Config;
12use crate::config::validation::parse_idle_timeout_secs;
13use crate::process;
14use crate::protocol::{GuestMessage, HostMessage, read_frame, write_frame};
15use crate::systemd;
16
17mod handlers;
18
19const MAX_CONCURRENT: usize = 4;
21
22const MAX_SESSIONS: u32 = 64;
24
25const PING_INTERVAL: Duration = Duration::from_mins(1);
27
28fn setup_signal_handler(shutdown: &Arc<AtomicBool>) -> std::io::Result<()> {
33 for sig in [signal_hook::consts::SIGTERM, signal_hook::consts::SIGINT] {
34 signal_hook::flag::register(sig, Arc::clone(shutdown))?;
35 }
36 Ok(())
37}
38
39struct SharedState {
41 session_count: AtomicU32,
43 container_name: String,
45 idle_timeout_secs: u64,
47 was_socket_activated: bool,
52}
53
54pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
56 let shutdown = Arc::new(AtomicBool::new(false));
57 setup_signal_handler(&shutdown)?;
58
59 let config = config.clone();
60 let path = socket_path.to_path_buf();
61 let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
62
63 let activation_fd = listen_fd();
64 let was_socket_activated = activation_fd.is_some();
65 let listener = match activation_fd {
66 Some(fd) => {
67 #[allow(unsafe_code)]
72 unsafe {
73 UnixListener::from_raw_fd(fd)
74 }
75 }
76 None => {
77 let _ = std::fs::remove_file(&path);
78 UnixListener::bind(&path)?
79 }
80 };
81 listener.set_nonblocking(true)?;
85
86 let state = Arc::new(SharedState {
87 session_count: AtomicU32::new(0),
88 container_name: container_name.to_string(),
89 idle_timeout_secs,
90 was_socket_activated,
91 });
92
93 let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
94
95 loop {
96 if shutdown.load(Ordering::Relaxed) {
97 tracing::info!("podbox: shutdown requested, draining connections...");
98 drop(listener);
99 for h in handles {
100 let _ = h.join();
101 }
102 return Ok(());
103 }
104
105 match listener.accept() {
106 Ok((mut stream, _)) => {
107 stream.set_nonblocking(false)?;
108 handles.retain_mut(|h| !h.is_finished());
109
110 if handles.len() >= MAX_CONCURRENT {
111 tracing::warn!(
112 "dropping connection: {} concurrent clients already in flight",
113 handles.len()
114 );
115 continue;
116 }
117
118 let cfg = config.clone();
119 let state = Arc::clone(&state);
120 let handle = std::thread::spawn(move || {
121 if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
122 tracing::error!("error handling connection: {}", e);
123 }
124 });
125 handles.push(handle);
126 }
127 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
128 std::thread::sleep(Duration::from_millis(200));
129 }
130 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
131 Err(e) => {
132 tracing::error!("socket accept failed: {}", e);
133 break;
134 }
135 }
136 }
137
138 Ok(())
139}
140
141fn listen_fd() -> Option<RawFd> {
142 let pid = std::env::var("LISTEN_PID").ok()?.parse::<u32>().ok()?;
143 if pid != std::process::id() {
144 return None;
145 }
146 let fds = std::env::var("LISTEN_FDS").ok()?.parse::<u32>().ok()?;
147 if fds == 0 {
148 return None;
149 }
150 Some(3)
151}
152
153fn handle_connection(
154 stream: &mut UnixStream,
155 config: &Config,
156 state: &Arc<SharedState>,
157) -> anyhow::Result<()> {
158 stream.set_read_timeout(Some(PING_INTERVAL))?;
159 let mut last_ping = std::time::Instant::now();
160 let mut negotiated: Option<HashSet<String>> = None;
164 let mut failures: u32 = 0;
167
168 loop {
169 let msg_bytes = match read_frame(stream) {
170 Ok(Some(b)) => b,
171 Ok(None) => return Ok(()),
172 Err(e)
173 if e.kind() == std::io::ErrorKind::WouldBlock
174 || e.kind() == std::io::ErrorKind::TimedOut =>
175 {
176 if last_ping.elapsed() >= PING_INTERVAL {
177 if write_frame(stream, &HostMessage::Ping).is_err() {
178 return Ok(());
179 }
180 last_ping = std::time::Instant::now();
181 }
182 continue;
183 }
184 Err(e) => return Err(e.into()),
185 };
186
187 last_ping = std::time::Instant::now();
188 let msg: GuestMessage = match serde_json::from_slice(&msg_bytes) {
189 Ok(m) => m,
190 Err(e) => {
191 tracing::warn!("malformed frame from peer: {e}");
192 if note_failure(stream, &mut failures, "malformed frame") {
193 return Ok(());
194 }
195 continue;
196 }
197 };
198
199 match msg {
200 GuestMessage::Hello {
201 protocol_version,
202 guest_version,
203 container,
204 capabilities,
205 } => {
206 let outcome = handlers::handle_hello(
207 stream,
208 &config.integration,
209 state.idle_timeout_secs,
210 protocol_version,
211 guest_version,
212 container,
213 capabilities,
214 )?;
215 let handlers::HelloOutcome::Accepted(accepted) = outcome else {
216 if note_failure(stream, &mut failures, "hello rejected") {
219 return Ok(());
220 }
221 continue;
222 };
223 negotiated = Some(accepted.into_iter().collect());
224
225 }
233 GuestMessage::RegisterSession => {
234 if !peer_is_in_host_userns(stream) {
237 tracing::warn!("rejecting RegisterSession from foreign user namespace");
238 let _ = write_frame(
239 stream,
240 &HostMessage::Error {
241 reason: "register_session is host-only".into(),
242 },
243 );
244 return Ok(());
245 }
246 if state.session_count.load(Ordering::SeqCst) >= MAX_SESSIONS {
247 tracing::warn!("rejecting RegisterSession: session cap reached");
248 let _ = write_frame(
249 stream,
250 &HostMessage::Error {
251 reason: "session limit reached".into(),
252 },
253 );
254 return Ok(());
255 }
256 let raw_fd = match process::recv_fd(stream) {
258 Ok(Some(fd)) => fd,
259 Ok(None) => return Ok(()),
260 Err(_) => return Ok(()),
261 };
262 let fd = process::adopt_scm_fd(raw_fd);
263 state.session_count.fetch_add(1, Ordering::SeqCst);
264 let s = Arc::clone(state);
265 std::thread::spawn(move || monitor_pidfd(fd, s));
266 return Ok(());
269 }
270 GuestMessage::Busy => {
271 if negotiated.is_none() {
272 if note_failure(stream, &mut failures, "hello required") {
273 return Ok(());
274 }
275 }
276 }
277 GuestMessage::IdleTimeout => {
278 if negotiated.is_none() {
279 if note_failure(stream, &mut failures, "hello required") {
280 return Ok(());
281 }
282 continue;
283 }
284 if state.idle_timeout_secs > 0 {
285 let name = &state.container_name;
286 tracing::info!("container '{}' idle — stopping", name);
287 let _ = systemd::stop_unit(name);
288 if state.was_socket_activated {
294 std::process::exit(0);
295 }
296 }
297 }
298 GuestMessage::Notify {
299 summary,
300 body,
301 urgency: _,
302 actions,
303 app_name: _,
304 } => {
305 if !has_cap(&negotiated, crate::protocol::CAP_NOTIFY) {
306 if note_failure(stream, &mut failures, "capability 'notify' not accepted") {
307 return Ok(());
308 }
309 continue;
310 }
311 handlers::handle_notify(stream, summary, body, actions)?
312 }
313 GuestMessage::XdgOpen { uri } => {
314 if !has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN) {
315 if note_failure(stream, &mut failures, "capability 'xdg_open' not accepted") {
316 return Ok(());
317 }
318 continue;
319 }
320 handlers::handle_xdg_open(uri)?
321 }
322 GuestMessage::ClipboardSet { text } => {
323 if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
324 if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
325 return Ok(());
326 }
327 continue;
328 }
329 handlers::handle_clipboard_set(text)?
330 }
331 GuestMessage::ClipboardGet => {
332 if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
333 if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
334 return Ok(());
335 }
336 continue;
337 }
338 handlers::handle_clipboard_get(stream)?
339 }
340 GuestMessage::HostExec { cmd, args } => {
341 if !has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC) {
342 if note_failure(stream, &mut failures, "capability 'host_exec' not accepted") {
343 return Ok(());
344 }
345 continue;
346 }
347 handlers::handle_host_exec(stream, &config.integration, cmd, args)?
348 }
349 }
350 }
351}
352
353const MAX_NEGOTIATION_FAILURES: u32 = 5;
356
357fn note_failure(stream: &mut UnixStream, failures: &mut u32, reason: &str) -> bool {
361 *failures = failures.saturating_add(1);
362 let _ = write_frame(
363 stream,
364 &HostMessage::Error {
365 reason: reason.to_string(),
366 },
367 );
368 *failures >= MAX_NEGOTIATION_FAILURES
369}
370
371fn has_cap(negotiated: &Option<HashSet<String>>, cap: &str) -> bool {
373 negotiated
374 .as_ref()
375 .is_some_and(|caps| caps.iter().any(|c| c == cap))
376}
377
378fn peer_is_in_host_userns(stream: &UnixStream) -> bool {
384 let creds = match getsockopt(stream, sockopt::PeerCredentials) {
385 Ok(c) => c,
386 Err(_) => return false,
387 };
388 let self_ns = std::fs::read_link("/proc/self/ns/user").ok();
389 let peer_ns = std::fs::read_link(format!("/proc/{}/ns/user", creds.pid())).ok();
390 match (self_ns, peer_ns) {
391 (Some(a), Some(b)) => a == b,
392 _ => false,
393 }
394}
395
396fn monitor_pidfd(fd: OwnedFd, state: Arc<SharedState>) {
399 let mut fds = [nix::poll::PollFd::new(
400 fd.as_fd(),
401 nix::poll::PollFlags::POLLIN,
402 )];
403
404 loop {
405 match nix::poll::poll(&mut fds, nix::poll::PollTimeout::NONE) {
406 Ok(_) => break,
407 Err(nix::errno::Errno::EINTR) => {}
408 Err(_) => 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}