agentmux_srv/bootstrap/network.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Output of [`bind_listeners_and_network`].
9pub struct NetworkBundle {
10 pub web_listener: TcpListener,
11 pub ws_listener: TcpListener,
12 pub web_addr: std::net::SocketAddr,
13 pub ws_addr: std::net::SocketAddr,
14 pub local_web_url: String,
15 /// OS hostname, resolved once here because LAN discovery needs it for its
16 /// mDNS TXT records. Carried on the bundle so `build_app_state` can put it
17 /// on `AppState` too, rather than resolving it a second time.
18 pub hostname: String,
19 pub lan_discovery: Arc<backend::lan_discovery::LanDiscoveryController>,
20 /// Owns the LAN-facing listeners so `network:lan_discovery` takes effect
21 /// live, instead of only at the next startup — see
22 /// `backend::lan_listeners`. Needs its router handed to it by `main.rs`
23 /// after `build_router`.
24 pub lan_listeners: Arc<backend::lan_listeners::LanListenerSupervisor>,
25 pub lsp_supervisor: Arc<backend::lsp::LspSupervisor>,
26 pub process_tracker: Arc<backend::process_tracker::registry::AgentProcessRegistry>,
27 pub process_broker: Arc<crate::broker::ProcessBroker>,
28}
29
30/// Step 5: Bind 2 TCP listeners (web + ws — separate ports matching Go), then
31/// bring up LAN discovery (mDNS), the LSP supervisor, stale registry cleanup,
32/// and the process tracker/broker.
33pub async fn bind_listeners_and_network(
34 config: &config::Config,
35 event_bus: &Arc<EventBus>,
36 broker: &Arc<Broker>,
37 version: &str,
38) -> NetworkBundle {
39 // Startup listeners are ALWAYS loopback-only, regardless of the
40 // `network:lan_discovery` setting. LAN reachability is owned entirely by
41 // `backend::lan_listeners::LanListenerSupervisor`, which binds one extra
42 // socket per non-loopback interface address on these same ports and tears
43 // them down when the setting flips off. `main.rs` calls `apply()` with the
44 // current setting immediately after the router exists, so a user who
45 // already had LAN enabled is reachable from boot — just via the
46 // supervisor's sockets rather than a wildcard one here.
47 //
48 // Do NOT reintroduce a conditional `0.0.0.0:0` bind. A wildcard socket
49 // holding the port makes the supervisor's per-address binds fail with
50 // EADDRINUSE on Linux/macOS (they succeed on Windows, which lacks
51 // SO_EXCLUSIVEADDRUSE by default — so the bug reproduces on only two of
52 // three platforms). On Linux/macOS that leaves `active` empty, which drives
53 // `sync_advertising` to `discovery.apply(false)` and silently switches off
54 // the mDNS advertising the user had enabled — an ON→OFF regression on every
55 // restart for exactly the users who already opted in. On Windows it instead
56 // leaves two sockets on one port with ambiguous accept. Keeping startup
57 // strictly loopback makes the supervisor the single owner of every LAN
58 // socket on both counts. [reagent #3021 P0]
59 //
60 // The scoped `lan_key` (X-AuthKey header, broadcast in the mDNS TXT record)
61 // gates only the three LAN-forwarding routes (`lan_or_full_auth_middleware`)
62 // — not the full auth_key previously broadcast here, which gated the entire
63 // API surface (see Config::lan_key's doc comment).
64 // Loopback, OS-chosen ports — or, headless only, the fixed ports it was
65 // given (`--web-port` / `--ws-port`, SPEC_SRV_HEADLESS_MODE_2026_09_26.md).
66 //
67 // Not headless: the next two free ports of `lan_ports::LAN_PORT_RANGE`, so
68 // one firewall rule on that range covers every build and update
69 // (SPEC_LAN_FIREWALL_SETUP_2026_10_01.md §4.1); OS-chosen only if the range
70 // is exhausted. Headless keeps the fixed ports it was given.
71 let (web_listener, ws_listener) = if crate::headless::active() {
72 let web_bind = crate::headless::startup_bind_addr(crate::headless::Listener::Web);
73 let ws_bind = crate::headless::startup_bind_addr(crate::headless::Listener::Ws);
74 let web = TcpListener::bind(&web_bind)
75 .await
76 .unwrap_or_else(|e| panic!("failed to bind web listener on {web_bind}: {e}"));
77 let ws = TcpListener::bind(&ws_bind)
78 .await
79 .unwrap_or_else(|e| panic!("failed to bind ws listener on {ws_bind}: {e}"));
80 (web, ws)
81 } else {
82 let bound = backend::lan_ports::bind_startup_listeners().await;
83 if !bound.in_range {
84 tracing::warn!(
85 range = ?backend::lan_ports::LAN_PORT_RANGE,
86 "LAN port range exhausted: listening on OS-chosen ports, which a firewall rule on the range does not cover"
87 );
88 }
89 (bound.web, bound.ws)
90 };
91
92 let web_addr = web_listener.local_addr().unwrap();
93 let ws_addr = ws_listener.local_addr().unwrap();
94 // Always use 127.0.0.1 for the local URL regardless of bind address.
95 // When bound to 0.0.0.0, local_addr() returns 0.0.0.0:PORT which is not a
96 // valid connect destination (fails on Windows and some Linux configs).
97 let local_web_url = format!("http://127.0.0.1:{}", web_addr.port());
98
99 // Make local backend URL available to child processes (PTY shells).
100 // the muxbus client (agentbus-client package) reads AGENTMUX_LOCAL_URL for local PTY delivery
101 // instead of routing through the cloud muxbus relay.
102 std::env::set_var("AGENTMUX_LOCAL_URL", &local_web_url);
103
104 // LAN discovery via mDNS — opt-in to avoid Windows Firewall prompt.
105 // mDNS binds 0.0.0.0:5353 UDP which triggers the firewall dialog.
106 // The setting defaults to false; users opt in via the HostPopover toggle
107 // (or by editing settings.json). The controller supports live start/stop
108 // so flipping the setting does not require an app restart.
109 // See docs/specs/lan-discovery-toggle.md.
110 let hostname = whoami::fallible::hostname().unwrap_or_else(|_| "unknown".to_string());
111 let lan_discovery = Arc::new(backend::lan_discovery::LanDiscoveryController::new(
112 config.instance_id.clone(),
113 hostname.clone(),
114 version.to_string(),
115 web_addr.port(),
116 event_bus.clone(),
117 config.lan_key.clone(),
118 ));
119 // The LAN tier of one-live-instance-per-agent asks these peers.
120 backend::agent_admission::set_lan_discovery(lan_discovery.clone());
121 // Notices an instance that hears peers but cannot be heard (no IPv4 mDNS
122 // socket: `lan_mdns_health`), rebuilds the daemon, and tells the indicator.
123 // Idle while LAN discovery is off.
124 lan_discovery.clone().spawn_health_watchdog();
125 // Deliberately NOT applied here. The supervisor below is the single driver
126 // of `lan_discovery.apply`, so mDNS can never advertise an endpoint before
127 // (or without) a socket actually listening on it. `main.rs` reads the
128 // setting and calls `lan_listeners.apply(..)` as soon as the router exists,
129 // which reconciles listeners and then gates advertising on the result.
130
131 // LAN listeners. Startup bound loopback only (always — see the bind comment
132 // above); this supervisor adds/removes the LAN-facing listeners on the SAME
133 // ports so the setting takes effect live rather than at the next restart.
134 // `apply` is deferred to `main.rs`, which is where the router it needs to
135 // serve finally exists.
136 let lan_listeners = Arc::new(backend::lan_listeners::LanListenerSupervisor::new(
137 web_addr.port(),
138 ws_addr.port(),
139 ));
140 // Headless srv is loopback-only, whatever the channel's LAN setting says.
141 if crate::headless::active() {
142 lan_listeners.forbid_lan();
143 }
144 // The supervisor owns the advertise/reachable pairing: it re-gates mDNS on
145 // every reconcile so we never advertise an address nothing is listening on,
146 // and it performs the boot-time setting read too (via `main.rs`).
147 lan_listeners.set_discovery(lan_discovery.clone());
148 // Tells the status bar whether the OS firewall would let LAN peers in
149 // (Windows only for now). Read-only.
150 backend::lan_firewall::spawn_watcher(
151 lan_discovery.clone(),
152 web_addr.port(),
153 ws_addr.port(),
154 event_bus.clone(),
155 );
156
157 // LSP supervisor — owns LSP server child processes. Nothing spawned
158 // until the editor pane calls `lspstart`. Spec:
159 // docs/specs/SPEC_EDITOR_LSP_AND_THEMES_2026-05-26.md
160 let lsp_supervisor = Arc::new(backend::lsp::LspSupervisor::new(event_bus.clone()));
161
162 // Clean up stale cross-instance agent registry entries (entries older than 4h).
163 backend::reactive::registry::cleanup_stale(
164 &base::get_mux_data_dir(),
165 4 * 60 * 60 * 1000,
166 );
167
168 // Same sweep for the host-global shared registry (Tier 2b, issue #1916)
169 // — additionally drops entries whose owning PID no longer exists on this
170 // host, since a channel that crashed without a clean unregister can
171 // leave an entry behind indefinitely otherwise (no other channel's
172 // startup would ever revisit it).
173 if let Some(shared_dir) = registry::resolve_shared_reactive_dir() {
174 backend::reactive::registry::cleanup_stale_shared(&shared_dir, 4 * 60 * 60 * 1000);
175 }
176
177 // Periodic re-registration heartbeat for every LOCALLY-registered agent's
178 // Tier 2 (per-channel) and Tier 2b (host-global shared) registry
179 // entries. Without this, an entry's `updated_at` is stamped once at
180 // registration and never refreshed — so `should_evict_on_forward_failure`
181 // (registry.rs)'s grace-period check is only ever true in the ~60s right
182 // after an agent registers, useless for any steady-state agent (i.e.
183 // most of them). reagent P1 round 3 on PR #2640: the PID+age eviction
184 // guard from the earlier commits on that PR doesn't actually protect a
185 // live, long-running agent from a single transient forward failure,
186 // reproducing the exact bug the PR claims to fix. This heartbeat closes
187 // that gap: `reactive::get_global_handler().list_agents()` is the
188 // in-memory Tier-1 registry, updated synchronously on every
189 // register/unregister — so it's always accurate, with no staleness
190 // window of its own. Re-writing from it keeps a live agent's on-disk
191 // entries fresh indefinitely; a genuinely-dead agent simply stops
192 // appearing in `list_agents()` (its own register/unregister lifecycle
193 // already handles that), so its entries age past the grace period and
194 // become evictable again within one heartbeat interval. The interval is
195 // well under `FORWARD_FAILURE_GRACE_MS` so a live entry's age never
196 // approaches the grace threshold between heartbeats. See
197 // docs/retro/retro-cross-channel-jekt-eviction-2026-08-17.md.
198 {
199 let local_web_url = local_web_url.clone();
200 tokio::spawn(async move {
201 const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
202 let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
203 loop {
204 interval.tick().await;
205 let data_dir = base::get_mux_data_dir();
206 for reg in reactive::get_global_handler().list_agents() {
207 // _with_nonce, not the plain write/write_shared_from_env
208 // — those hardcode registration_nonce: 0, which would
209 // silently break remove_if_nonce/
210 // remove_shared_from_env_if_nonce's compare-and-remove
211 // on this agent's own clean-exit cleanup (reagent P1
212 // round 4 on PR #2640: every 20s heartbeat tick would
213 // zero out the real nonce already recorded in
214 // `reg.registration_nonce`).
215 backend::reactive::registry::write_with_nonce(
216 &data_dir,
217 ®.agent_id,
218 &local_web_url,
219 ®.block_id,
220 reg.registration_nonce,
221 );
222 backend::reactive::registry::write_shared_from_env_with_nonce(
223 ®.agent_id,
224 &local_web_url,
225 ®.block_id,
226 reg.registration_nonce,
227 );
228 }
229 }
230 });
231 }
232
233 // Linux: join a delegated systemd user scope first, so each agent gets a
234 // cgroup holding everything it starts (process_tracker::cgroup_linux).
235 // Before the registry exists, so no agent can spawn outside it.
236 #[cfg(target_os = "linux")]
237 backend::process_tracker::cgroup_linux::init().await;
238
239 // Tracks agent-spawned OS processes per block. Registered trackers
240 // live as long as their agent pane; the background poller emits
241 // delta events (`agent:process-added`/`-exited`) to the frontend.
242 let process_tracker = std::sync::Arc::new(
243 backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
244 );
245 backend::process_tracker::registry::set_global(process_tracker.clone());
246 backend::process_tracker::registry::spawn_poller(process_tracker.clone());
247
248 // Process Broker (Phase A) — unified read path over blockcontroller +
249 // process_tracker. See docs/specs/REPORT_PROCESS_ARCHITECTURE_STATE_AND_RETHINK_2026_07_22.md.
250 let process_broker = std::sync::Arc::new(crate::broker::ProcessBroker::new(Some(broker.clone())));
251 crate::broker::process::set_global(process_broker.clone());
252
253 NetworkBundle {
254 web_listener,
255 ws_listener,
256 web_addr,
257 ws_addr,
258 local_web_url,
259 hostname,
260 lan_discovery,
261 lan_listeners,
262 lsp_supervisor,
263 process_tracker,
264 process_broker,
265 }
266}