Skip to main content

podbox/
socket_host.rs

1use std::os::fd::FromRawFd;
2use std::os::unix::net::UnixListener;
3use std::path::Path;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
6use std::time::Duration;
7
8use crate::config::Config;
9use crate::config::validation::parse_idle_timeout_secs;
10
11mod conn;
12mod handlers;
13mod monitor;
14
15use conn::handle_connection;
16use monitor::{listen_fd, setup_signal_handler};
17
18/// Maximum consecutive failed negotiations (bad hello, unauthenticated
19/// privileged message, malformed frame) before the connection is dropped.
20const MAX_NEGOTIATION_FAILURES: u32 = 5;
21
22/// Max number of concurrent host threads handling guest connections.
23const MAX_CONCURRENT: usize = 4;
24
25/// Max number of tracked terminal sessions (pidfd monitors).
26const MAX_SESSIONS: u32 = 64;
27
28/// How often the host sends a keepalive `Ping` to a connected guest.
29const PING_INTERVAL: Duration = Duration::from_mins(1);
30
31/// Shared mutable state between all connections and PID monitor threads.
32pub(crate) struct SharedState {
33    /// Number of active terminal sessions tracked via pidfd.
34    pub(crate) session_count: AtomicU32,
35    /// Container name, for `systemctl stop` on idle timeout.
36    pub(crate) container_name: String,
37    /// Idle timeout in seconds (0 = disabled).
38    pub(crate) idle_timeout_secs: u64,
39    /// Whether this process was launched via systemd socket activation
40    /// (`LISTEN_PID`/`LISTEN_FDS` set). If true, the process may
41    /// self-terminate on idle timeout — systemd will re-spawn it via
42    /// socket activation on the next connection.
43    pub(crate) was_socket_activated: bool,
44}
45
46/// Run the host socket server for a container.
47pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
48    let shutdown = Arc::new(AtomicBool::new(false));
49    setup_signal_handler(&shutdown)?;
50
51    let config = config.clone();
52    let path = socket_path.to_path_buf();
53    let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
54
55    let activation_fd = listen_fd();
56    let was_socket_activated = activation_fd.is_some();
57    let listener = match activation_fd {
58        Some(fd) => {
59            // SAFETY: `fd` comes from systemd's `LISTEN_FDS` activation
60            // protocol: the user manager hands over ownership of a valid
61            // listening socket fd, which this process must adopt exactly
62            // once. No safe wrapper exists for externally-sourced fds.
63            #[allow(unsafe_code)]
64            unsafe {
65                UnixListener::from_raw_fd(fd)
66            }
67        }
68        None => {
69            let _ = std::fs::remove_file(&path);
70            UnixListener::bind(&path)?
71        }
72    };
73    // Non-blocking + periodic tick so SIGTERM/SIGINT ends the accept loop
74    // promptly instead of blocking in accept(2) until systemd's
75    // TimeoutStopSec SIGKILL.
76    listener.set_nonblocking(true)?;
77
78    let state = Arc::new(SharedState {
79        session_count: AtomicU32::new(0),
80        container_name: container_name.to_string(),
81        idle_timeout_secs,
82        was_socket_activated,
83    });
84
85    let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
86
87    loop {
88        if shutdown.load(Ordering::Relaxed) {
89            tracing::info!("podbox: shutdown requested, draining connections...");
90            drop(listener);
91            for h in handles {
92                let _ = h.join();
93            }
94            return Ok(());
95        }
96
97        match listener.accept() {
98            Ok((mut stream, _)) => {
99                stream.set_nonblocking(false)?;
100                handles.retain_mut(|h| !h.is_finished());
101
102                if handles.len() >= MAX_CONCURRENT {
103                    tracing::warn!(
104                        "dropping connection: {} concurrent clients already in flight",
105                        handles.len()
106                    );
107                    continue;
108                }
109
110                let cfg = config.clone();
111                let state = Arc::clone(&state);
112                let handle = std::thread::spawn(move || {
113                    if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
114                        tracing::error!("error handling connection: {}", e);
115                    }
116                });
117                handles.push(handle);
118            }
119            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
120                std::thread::sleep(Duration::from_millis(200));
121            }
122            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
123            Err(e) => {
124                tracing::error!("socket accept failed: {}", e);
125                break;
126            }
127        }
128    }
129
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::conn::{has_cap, note_failure};
136    use super::handlers::{validate_host_exec_args, validate_uri};
137    use std::collections::HashSet;
138
139    // ── validate_uri tests ──
140
141    #[test]
142    fn allows_http_https_mailto() {
143        assert_eq!(
144            validate_uri("https://example.com"),
145            Some("https://example.com".to_string())
146        );
147        assert_eq!(
148            validate_uri("http://example.com"),
149            Some("http://example.com".to_string())
150        );
151        assert_eq!(
152            validate_uri("mailto:user@host"),
153            Some("mailto:user@host".to_string())
154        );
155    }
156
157    #[test]
158    fn refuses_path_traversal() {
159        assert_eq!(validate_uri("/etc/passwd"), None);
160        assert_eq!(validate_uri("../foo"), None);
161        assert_eq!(validate_uri(""), None);
162    }
163
164    #[test]
165    fn refuses_unknown_alphabetic_schemes() {
166        assert_eq!(validate_uri("javascript:alert(1)"), None);
167        assert_eq!(validate_uri("file:///etc/passwd"), None);
168    }
169
170    #[test]
171    fn wraps_bare_domain() {
172        assert_eq!(
173            validate_uri("example.com"),
174            Some("https://example.com".to_string())
175        );
176    }
177
178    #[test]
179    fn trims_whitespace() {
180        assert_eq!(
181            validate_uri("  https://example.com  "),
182            Some("https://example.com".to_string())
183        );
184    }
185
186    // ── validate_host_exec_args tests ──
187
188    #[test]
189    fn accepts_plain_args() {
190        assert!(validate_host_exec_args(&["ls".into()]).is_ok());
191        assert!(validate_host_exec_args(&["ls".into(), "-la".into(), "/tmp".into()]).is_ok());
192        assert!(validate_host_exec_args(&["git".into(), "log".into(), "--oneline".into()]).is_ok());
193    }
194
195    #[test]
196    fn rejects_shell_metacharacters() {
197        assert!(validate_host_exec_args(&["echo".into(), "foo;bar".into()]).is_err());
198        assert!(validate_host_exec_args(&["echo".into(), "foo|bar".into()]).is_err());
199        assert!(validate_host_exec_args(&["echo".into(), "foo&bar".into()]).is_err());
200        assert!(validate_host_exec_args(&["echo".into(), "$PATH".into()]).is_err());
201        assert!(validate_host_exec_args(&["echo".into(), "`ls`".into()]).is_err());
202    }
203
204    #[test]
205    fn rejects_redirection_operators() {
206        assert!(validate_host_exec_args(&["cat".into(), "<file".into()]).is_err());
207        assert!(validate_host_exec_args(&["echo".into(), ">file".into()]).is_err());
208        assert!(validate_host_exec_args(&["echo".into(), ">>file".into()]).is_err());
209    }
210
211    #[test]
212    fn rejects_glob_and_brace_chars() {
213        assert!(validate_host_exec_args(&["ls".into(), "*.rs".into()]).is_err());
214        assert!(validate_host_exec_args(&["ls".into(), "file?".into()]).is_err());
215        assert!(validate_host_exec_args(&["ls".into(), "[abc]".into()]).is_err());
216        assert!(validate_host_exec_args(&["echo".into(), "{a,b}".into()]).is_err());
217    }
218
219    #[test]
220    fn rejects_subshell_and_escape_chars() {
221        assert!(validate_host_exec_args(&["echo".into(), "$(whoami)".into()]).is_err());
222        assert!(validate_host_exec_args(&["echo".into(), "line1\nline2".into()]).is_err());
223    }
224
225    #[test]
226    fn rejects_restricted_flag_patterns() {
227        assert!(validate_host_exec_args(&["git".into(), "--exec-path=/tmp".into()]).is_err());
228        assert!(validate_host_exec_args(&["git".into(), "--config=user.name".into()]).is_err());
229        assert!(validate_host_exec_args(&["vim".into(), "--plugin=malicious".into()]).is_err());
230        assert!(validate_host_exec_args(&["python".into(), "--load=malicious".into()]).is_err());
231        assert!(validate_host_exec_args(&["python".into(), "--module=malicious".into()]).is_err());
232        assert!(validate_host_exec_args(&["git".into(), "--remote=evil".into()]).is_err());
233        assert!(
234            validate_host_exec_args(&[
235                "ssh".into(),
236                "-o".into(),
237                "StrictHostKeyChecking=no".into()
238            ])
239            .is_err()
240        );
241    }
242
243    #[test]
244    fn restricted_flag_detection_is_case_insensitive() {
245        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=/tmp".into()]).is_err());
246        assert!(validate_host_exec_args(&["GIT".into(), "--Config=evil".into()]).is_err());
247    }
248
249    #[test]
250    fn does_not_restrict_safe_flags() {
251        assert!(validate_host_exec_args(&["git".into(), "--exec".into()]).is_ok());
252        assert!(
253            validate_host_exec_args(&["git".into(), "--exec-path-is-ok".into()]).is_err(),
254            "--exec-path prefix still blocked"
255        );
256        assert!(validate_host_exec_args(&["ls".into(), "--color=auto".into()]).is_ok());
257        assert!(validate_host_exec_args(&["cargo".into(), "--offline".into()]).is_ok());
258    }
259
260    #[test]
261    fn rejects_empty_args_gracefully() {
262        assert!(
263            validate_host_exec_args(&[String::new()]).is_ok(),
264            "empty string is not a metachar"
265        );
266    }
267
268    #[test]
269    fn ascii_lowercase_only() {
270        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=".into()]).is_err());
271        assert!(
272            validate_host_exec_args(&["git".into(), "--\u{0130}".into()]).is_ok(),
273            "Turkish \u{0130} is non-ASCII"
274        );
275    }
276
277    // ── has_cap tests ──
278
279    #[test]
280    fn has_cap_none_negotiated_rejects() {
281        assert!(!has_cap(&None, crate::protocol::CAP_NOTIFY));
282        assert!(!has_cap(&None, crate::protocol::CAP_CLIPBOARD));
283    }
284
285    #[test]
286    fn has_cap_accepts_negotiated() {
287        let caps = HashSet::from(["notify".to_string(), "clipboard".to_string()]);
288        let negotiated = Some(caps);
289        assert!(has_cap(&negotiated, crate::protocol::CAP_NOTIFY));
290        assert!(has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD));
291        assert!(!has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN));
292        assert!(!has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC));
293    }
294
295    // ── note_failure tests ──
296
297    fn socket_pair() -> (
298        std::os::unix::net::UnixStream,
299        std::os::unix::net::UnixStream,
300    ) {
301        std::os::unix::net::UnixStream::pair().expect("socketpair")
302    }
303
304    #[test]
305    fn note_failure_is_false_below_threshold() {
306        let (mut server, _client) = socket_pair();
307        let mut failures: u32 = 0;
308        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
309            assert!(!note_failure(&mut server, &mut failures, "probe"));
310        }
311        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES - 1);
312    }
313
314    #[test]
315    fn note_failure_drops_after_threshold() {
316        let (mut server, mut client) = socket_pair();
317        let mut failures: u32 = 0;
318        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
319            assert!(!note_failure(&mut server, &mut failures, "probe"));
320        }
321        assert!(note_failure(&mut server, &mut failures, "probe"));
322        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES);
323
324        // Every failure was answered with a typed Error frame.
325        use crate::protocol::read_frame;
326        for _ in 0..super::MAX_NEGOTIATION_FAILURES {
327            let bytes = read_frame(&mut client).unwrap().expect("error frame");
328            let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
329            assert!(
330                matches!(msg, crate::protocol::HostMessage::Error { reason } if reason == "probe")
331            );
332        }
333    }
334
335    // ── handle_hello tests ──
336
337    #[test]
338    fn hello_protocol_mismatch_is_rejected() {
339        use super::handlers::{HelloOutcome, handle_hello};
340        let (mut server, mut client) = socket_pair();
341        let config = crate::config::Config::embedded();
342        let outcome = handle_hello(
343            &mut server,
344            &config.integration,
345            0,
346            crate::protocol::PROTOCOL_VERSION + 1,
347            "test".into(),
348            "test".into(),
349            vec![],
350        )
351        .unwrap();
352        assert!(matches!(outcome, HelloOutcome::Rejected));
353
354        // The peer is told to shut down, and no capabilities are granted.
355        let bytes = crate::protocol::read_frame(&mut client)
356            .unwrap()
357            .expect("shutdown frame");
358        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
359        assert!(matches!(msg, crate::protocol::HostMessage::Shutdown));
360    }
361
362    #[test]
363    fn hello_accepts_enabled_capabilities() {
364        use super::handlers::{HelloOutcome, handle_hello};
365        let (mut server, mut client) = socket_pair();
366        let mut config = crate::config::Config::embedded();
367        config.integration.notify = true;
368        config.integration.clipboard = true;
369        config.integration.xdg_open = false;
370        let outcome = handle_hello(
371            &mut server,
372            &config.integration,
373            0,
374            crate::protocol::PROTOCOL_VERSION,
375            "test".into(),
376            "test".into(),
377            vec![
378                crate::protocol::CAP_NOTIFY.to_string(),
379                crate::protocol::CAP_XDG_OPEN.to_string(),
380            ],
381        )
382        .unwrap();
383        let HelloOutcome::Accepted(accepted) = outcome else {
384            panic!("expected Accepted");
385        };
386        assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
387
388        let bytes = crate::protocol::read_frame(&mut client)
389            .unwrap()
390            .expect("hello ack");
391        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
392        match msg {
393            crate::protocol::HostMessage::HelloAck {
394                accepted, rejected, ..
395            } => {
396                assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
397                assert_eq!(rejected, vec![crate::protocol::CAP_XDG_OPEN]);
398            }
399            other => panic!("expected HelloAck, got {other:?}"),
400        }
401    }
402}