Skip to main content

agentmux_srv/backend/
lan_ports.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The fixed port range srv's listeners live in, so a firewall rule can name
5//! them (`docs/specs/SPEC_LAN_FIREWALL_SETUP_2026_10_01.md` ยง4.1).
6//!
7//! srv used to let the OS choose its web and ws ports on every start. A
8//! firewall rule that must survive updates, new portable builds and reinstalls
9//! cannot name a port that changes, and a program rule cannot either: the
10//! sidecar's filename embeds its version, so a per-exe rule stops matching on
11//! every update and re-registering it needs another elevation. A port range
12//! does neither.
13//!
14//! The loopback and LAN listeners share a port (see `lan_listeners.rs`), so
15//! choosing the *startup* ports from the range is enough: every LAN listener
16//! added later lands in the range too, and mDNS advertises the real port.
17//!
18//! Several instances on one host (channels, portable builds, `task dev`) each
19//! take the next free ports. If fewer than two are free srv does not fail: it
20//! falls back to OS-chosen ports exactly as before, and
21//! [`StartupListeners::in_range`] is `false` so the LAN indicator can say the
22//! rule does not cover this instance.
23
24use std::net::Ipv4Addr;
25use std::ops::RangeInclusive;
26
27use tokio::net::TcpListener;
28
29/// TCP ports srv's web and ws listeners are taken from.
30///
31/// **It must sit below 32768.** The OS hands ephemeral ports to *outbound*
32/// connections, and the defaults are 49152-65535 on Windows and macOS but
33/// 32768-60999 on Linux. A port in either range can be in use as the local end
34/// of an outgoing connection on a LAN address while it is free on loopback, and
35/// the startup bind only tests loopback: the LAN listener would then fail to
36/// bind that address later and LAN would stay partly off until the connection
37/// closed (Codex P2 on #4120; the first draft used 47892-47991, inside Linux's
38/// range). Below 32768 is outside all three defaults.
39///
40/// Within that, the block is chosen away from well-known neighbours: Syncthing
41/// (21027, 22000), Synergy (24800), Minecraft (25565), Steam and Source games
42/// (27000s), MongoDB (27017) and Kubernetes NodePorts (30000-32767). One
43/// hundred ports hold about fifty instances. Another program that happens to
44/// listen in the range is not harmed, but a firewall rule on the range would
45/// expose it to the local subnet, which is why the block is not larger.
46pub const LAN_PORT_RANGE: RangeInclusive<u16> = 29700..=29799;
47
48/// The two startup listeners, plus where they came from.
49pub struct StartupListeners {
50    pub web: TcpListener,
51    pub ws: TcpListener,
52    pub in_range: bool,
53}
54
55/// Bind the web and ws listeners on loopback, from [`LAN_PORT_RANGE`] when two
56/// ports are free there, else OS-chosen. Panics only if even an OS-chosen
57/// loopback bind fails, as the code this replaces did.
58pub async fn bind_startup_listeners() -> StartupListeners {
59    bind_startup_listeners_from(LAN_PORT_RANGE).await
60}
61
62/// [`bind_startup_listeners`] over an explicit candidate list. Separate so the
63/// tests can drive it with ports they control instead of racing real
64/// instances for the production range.
65pub(crate) async fn bind_startup_listeners_from(
66    candidates: impl IntoIterator<Item = u16>,
67) -> StartupListeners {
68    if let Some((web, ws)) = first_two_free(candidates).await {
69        return StartupListeners {
70            web,
71            ws,
72            in_range: true,
73        };
74    }
75    let any = (Ipv4Addr::LOCALHOST, 0);
76    let web = TcpListener::bind(any)
77        .await
78        .unwrap_or_else(|e| panic!("failed to bind web listener on 127.0.0.1:0: {e}"));
79    let ws = TcpListener::bind(any)
80        .await
81        .unwrap_or_else(|e| panic!("failed to bind ws listener on 127.0.0.1:0: {e}"));
82    StartupListeners {
83        web,
84        ws,
85        in_range: false,
86    }
87}
88
89/// The first two loopback ports among `candidates` that bind, in order. Not
90/// necessarily adjacent: an instance that started earlier may hold one in the
91/// middle. `None` when fewer than two are free; nothing is left bound then.
92async fn first_two_free(
93    candidates: impl IntoIterator<Item = u16>,
94) -> Option<(TcpListener, TcpListener)> {
95    let mut bound = Vec::with_capacity(2);
96    for port in candidates {
97        // A port another instance (or anything else) holds simply fails: skip it.
98        if let Ok(listener) = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).await {
99            bound.push(listener);
100            if bound.len() == 2 {
101                let ws = bound.pop()?;
102                let web = bound.pop()?;
103                return Some((web, ws));
104            }
105        }
106    }
107    // Fewer than two: `bound` drops here, so a lone free port is not left held.
108    None
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// These tests bind and release ports to learn which are free. Run in
116    /// parallel, one can be handed a port another just released, and the
117    /// candidate list it built then holds fewer free ports than it thinks.
118    /// One at a time (Codex P2 on #4120).
119    static PORT_TESTS: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
120
121    /// A port that is free right now (bound, noted, released).
122    fn free_port() -> u16 {
123        std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
124            .unwrap()
125            .local_addr()
126            .unwrap()
127            .port()
128    }
129
130    /// Two distinct free ports.
131    fn two_free_ports() -> (u16, u16) {
132        let a = free_port();
133        loop {
134            let b = free_port();
135            if b != a {
136                return (a, b);
137            }
138        }
139    }
140
141    #[tokio::test]
142    async fn takes_the_first_two_free_ports_in_order() {
143        let _serial = PORT_TESTS.lock().await;
144        let (a, b) = two_free_ports();
145        let got = bind_startup_listeners_from([a, b]).await;
146        assert!(got.in_range);
147        assert_eq!(got.web.local_addr().unwrap().port(), a);
148        assert_eq!(got.ws.local_addr().unwrap().port(), b);
149    }
150
151    #[tokio::test]
152    async fn skips_a_port_another_instance_holds() {
153        let _serial = PORT_TESTS.lock().await;
154        // `taken` stands for an instance that started earlier and is still running.
155        let taken = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
156        let taken_port = taken.local_addr().unwrap().port();
157        let (b, c) = two_free_ports();
158        let got = bind_startup_listeners_from([taken_port, b, c]).await;
159        assert!(
160            got.in_range,
161            "two free ports remain, so it is still in range"
162        );
163        assert_eq!(got.web.local_addr().unwrap().port(), b);
164        assert_eq!(got.ws.local_addr().unwrap().port(), c);
165        drop(taken);
166    }
167
168    #[tokio::test]
169    async fn a_taken_port_between_two_free_ones_is_skipped_not_paired() {
170        let _serial = PORT_TESTS.lock().await;
171        // Held FIRST: choosing the free ports afterwards means the OS cannot
172        // hand `taken_port` back as one of them.
173        let taken = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
174        let taken_port = taken.local_addr().unwrap().port();
175        let (a, c) = two_free_ports();
176        let got = bind_startup_listeners_from([a, taken_port, c]).await;
177        assert!(got.in_range);
178        assert_eq!(got.web.local_addr().unwrap().port(), a);
179        assert_eq!(
180            got.ws.local_addr().unwrap().port(),
181            c,
182            "not adjacent, and that is fine"
183        );
184        drop(taken);
185    }
186
187    #[tokio::test]
188    async fn fewer_than_two_free_falls_back_to_os_chosen_ports() {
189        let _serial = PORT_TESTS.lock().await;
190        let taken = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
191        let taken_port = taken.local_addr().unwrap().port();
192        let only_one_free = free_port();
193        let got = bind_startup_listeners_from([taken_port, only_one_free]).await;
194        assert!(!got.in_range, "one free port is not enough for the pair");
195        let (web, ws) = (got.web.local_addr().unwrap(), got.ws.local_addr().unwrap());
196        assert!(web.ip().is_loopback() && ws.ip().is_loopback());
197        assert_ne!(web.port(), ws.port());
198        assert_ne!(web.port(), taken_port);
199        // The lone free port was released again, not left half-bound.
200        assert!(std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, only_one_free)).is_ok());
201        drop(taken);
202    }
203
204    #[tokio::test]
205    async fn an_empty_candidate_list_falls_back() {
206        let _serial = PORT_TESTS.lock().await;
207        let got = bind_startup_listeners_from(std::iter::empty()).await;
208        assert!(!got.in_range);
209        assert!(got.web.local_addr().unwrap().ip().is_loopback());
210    }
211
212    #[test]
213    fn the_range_is_one_a_rule_can_rely_on() {
214        let (start, end) = (*LAN_PORT_RANGE.start(), *LAN_PORT_RANGE.end());
215        assert!(start >= 1024, "not a well-known port");
216        assert!(
217            end < 32768,
218            "must stay below Linux's default ephemeral start (32768); Windows and macOS start at 49152"
219        );
220        for neighbour in [21027, 22000, 24800, 25565, 27015, 27017, 28015, 30000] {
221            assert!(
222                !LAN_PORT_RANGE.contains(&neighbour),
223                "{neighbour} is a well-known neighbour; a rule on the range would expose it"
224            );
225        }
226        assert!(
227            usize::from(end - start) + 1 >= 100,
228            "room for about fifty instances (two ports each)"
229        );
230    }
231}