anvilsign in

collin/browser-terminal-extension

main / daemon / src / activation.rs
1//! Picking up a listening socket that someone else already bound.
2//!
3//! systemd's socket activation lets the *socket* outlive the daemon: systemd
4//! holds `127.0.0.1:7681` open from login, and only execs `termbridge serve`
5//! when the sidebar actually connects. Combined with `--idle-timeout` the
6//! daemon then exits once the last client goes away, and the next connection
7//! starts a fresh one.
8//!
9//! That is only safe because tmux, not this process, is the persistence layer.
10//! Sessions survive the daemon exiting, so "no clients" really is "nothing to
11//! keep alive".
12
13use std::net::TcpListener;
14
15/// The first fd systemd passes. Defined by the protocol, not by us.
16const LISTEN_FDS_START: i32 = 3;
17
18/// Take the listener systemd passed us, if this process was socket-activated.
19///
20/// `Ok(None)` means "started normally, bind your own socket". An `Err` means we
21/// *were* activated but the handoff was wrong, which must not fall back to
22/// binding: the port is already owned by systemd and the bind would fail (or,
23/// worse, succeed on a different port and leave the sidebar talking to nobody).
24pub fn systemd_listener() -> std::io::Result<Option<TcpListener>> {
25 let Some(fds) = listen_fds()? else {
26 return Ok(None);
27 };
28 if fds != 1 {
29 return Err(err(format!(
30 "systemd passed {fds} sockets, expected exactly 1 — check ListenStream in termbridge.socket"
31 )));
32 }
33
34 // Safe to own fd 3: the LISTEN_PID check below/above proved these variables
35 // were meant for this process, and nothing else in the daemon touches it.
36 let listener = unsafe {
37 use std::os::fd::FromRawFd;
38 TcpListener::from_raw_fd(LISTEN_FDS_START)
39 };
40
41 // systemd clears FD_CLOEXEC on fds it hands over, so they survive its own
42 // exec into this binary — but that leaves it cleared here too. Uncleared,
43 // this fd would survive our exec of tmux the same way, and every process
44 // tmux ever spawns would inherit a handle on the listening socket. systemd
45 // could then never rebind the port after this daemon exits, which is
46 // exactly the "Address already in use" `termbridge reload` must not hit.
47 set_cloexec(&listener)?;
48
49 // Same invariant the self-bound path asserts. A unit file with
50 // `ListenStream=0.0.0.0:7681` would otherwise silently expose a shell to
51 // the network, and the unit is a file the user can edit.
52 let addr = listener.local_addr()?;
53 if !addr.ip().is_loopback() {
54 return Err(err(format!(
55 "refusing the socket systemd passed: {addr} is not loopback"
56 )));
57 }
58
59 listener.set_nonblocking(true)?;
60 Ok(Some(listener))
61}
62
63/// `$LISTEN_FDS`, but only if `$LISTEN_PID` says the variables are ours.
64///
65/// The check matters because these variables are inherited by children. Without
66/// it, a shell spawned inside the pty would look socket-activated to any
67/// termbridge it ran.
68fn listen_fds() -> std::io::Result<Option<usize>> {
69 let Ok(pid) = std::env::var("LISTEN_PID") else {
70 return Ok(None);
71 };
72 let fds = std::env::var("LISTEN_FDS").unwrap_or_default();
73 // Clear before anything can fork: the pty spawns a shell, and these must
74 // not be part of its environment. Called from startup, single-threaded,
75 // before any other thread can be reading the environment.
76 unsafe {
77 std::env::remove_var("LISTEN_PID");
78 std::env::remove_var("LISTEN_FDS");
79 std::env::remove_var("LISTEN_FDNAMES");
80 }
81
82 if pid.trim().parse::<u32>().ok() != Some(std::process::id()) {
83 return Ok(None);
84 }
85 match fds.trim().parse::<usize>() {
86 Ok(n) => Ok(Some(n)),
87 Err(_) => Err(err(format!("LISTEN_PID is ours but LISTEN_FDS={fds:?}"))),
88 }
89}
90
91fn err(msg: String) -> std::io::Error {
92 std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
93}
94
95fn set_cloexec(listener: &TcpListener) -> std::io::Result<()> {
96 use std::os::fd::AsRawFd;
97 let fd = listener.as_raw_fd();
98 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
99 if flags < 0 {
100 return Err(std::io::Error::last_os_error());
101 }
102 if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
103 return Err(std::io::Error::last_os_error());
104 }
105 Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 // These mutate process-global environment, so they share one test to avoid
113 // racing each other under the default multi-threaded harness.
114 #[test]
115 fn env_handshake() {
116 // SAFETY (all of these): the harness runs this test alone in its
117 // process for the same reason the assertions below are batched.
118 unsafe { std::env::remove_var("LISTEN_PID") };
119 assert!(
120 listen_fds().unwrap().is_none(),
121 "no LISTEN_PID: not activated"
122 );
123
124 // Addressed to some other process: ignored, and consumed so it cannot
125 // be inherited further.
126 unsafe {
127 std::env::set_var("LISTEN_PID", "1");
128 std::env::set_var("LISTEN_FDS", "1");
129 }
130 assert!(
131 listen_fds().unwrap().is_none(),
132 "LISTEN_PID for another pid"
133 );
134 assert!(std::env::var("LISTEN_PID").is_err(), "consumed anyway");
135 assert!(std::env::var("LISTEN_FDS").is_err(), "consumed anyway");
136
137 unsafe {
138 std::env::set_var("LISTEN_PID", std::process::id().to_string());
139 std::env::set_var("LISTEN_FDS", "2");
140 }
141 assert_eq!(listen_fds().unwrap(), Some(2));
142
143 unsafe {
144 std::env::set_var("LISTEN_PID", std::process::id().to_string());
145 std::env::set_var("LISTEN_FDS", "not-a-number");
146 }
147 assert!(listen_fds().is_err(), "ours but malformed is an error");
148 }
149}