Skip to main content

agentmux_srv/backend/
dev_proxy.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Native reverse proxy for container-agent dev servers.
5//!
6//! Routes `http://<project>-<agent_id>.localhost:{DEV_PROXY_PORT}/...` to
7//! whatever internal `ip:port` that agent's dev server registered via the
8//! `RegisterDevServer` MCP tool (the HTTP endpoint backing that tool lives
9//! in `crates/srv/src/server/app_api/dev_server.rs`; this module owns the
10//! routing table and the proxy server itself). See
11//! `docs/specs/SPEC_NATIVE_CONTAINER_DEV_PROXY_2026_09_19.md`.
12//!
13//! Deliberately NOT a general-purpose reverse proxy (spec §4): Host-header
14//! → backend address is the entire feature. No path routing, no TLS, no
15//! auth, no rate limiting, no websocket upgrade handling.
16
17use std::collections::HashMap;
18use std::net::SocketAddr;
19use std::sync::Arc;
20
21use axum::body::Body;
22use axum::extract::{Request, State};
23use axum::http::{header, StatusCode};
24use axum::response::{IntoResponse, Response};
25use axum::routing::any;
26use axum::Router;
27use futures_util::TryStreamExt;
28use tokio::sync::RwLock;
29
30/// Fixed local port the proxy listens on. Chosen for this PR — nothing
31/// else in `agentmux-srv` binds it (checked: no other `TcpListener::bind`
32/// or config default references 8090 anywhere in this crate). See spec §5.
33pub const DEV_PROXY_PORT: u16 = 8090;
34
35/// One registered dev server: where to actually send the request, and
36/// which agent owns the registration (needed so `unregister_agent` can
37/// remove exactly this agent's entries — see its doc comment for why a
38/// plain string-suffix match on the routing key isn't safe).
39#[derive(Debug, Clone)]
40struct Route {
41    addr: SocketAddr,
42    agent_id: String,
43}
44
45/// `"<project>-<agent_id>"` → the container's internal `ip:port` its dev
46/// server is actually listening on. Cleared per-agent when that agent's
47/// container stops (`ContainerManager::stop`/`remove` in `container.rs`
48/// call `unregister_agent` — see that module's `cleanup_dev_proxy_routes`).
49///
50/// Cheap to clone (one `Arc` inside) — held directly by `AppState` and
51/// passed to the proxy's own axum router as state.
52#[derive(Clone, Default)]
53pub struct DevProxyRegistry {
54    routes: Arc<RwLock<HashMap<String, Route>>>,
55}
56
57impl DevProxyRegistry {
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// The exact routing key both `register` and the Host-header lookup
63    /// use — kept as one function so the two can never drift apart.
64    /// Lowercased: hostnames are case-insensitive, and a browser may send
65    /// whatever case the URL bar has, so registration and lookup must
66    /// agree regardless of how either side capitalized `project`/`agent_id`.
67    pub fn routing_key(project: &str, agent_id: &str) -> String {
68        format!("{}-{}", project.to_lowercase(), agent_id.to_lowercase())
69    }
70
71    pub async fn register(&self, project: &str, agent_id: &str, addr: SocketAddr) {
72        let key = Self::routing_key(project, agent_id);
73        self.routes.write().await.insert(
74            key,
75            Route {
76                addr,
77                agent_id: agent_id.to_lowercase(),
78            },
79        );
80    }
81
82    /// Removes every registration belonging to `agent_id`, regardless of
83    /// project name — called when that agent's container stops so a dead
84    /// container never keeps serving (stale) proxy traffic.
85    ///
86    /// Matches on the stored `agent_id` field, NOT a `key.ends_with(...)`
87    /// string check — agent ids can themselves contain hyphens (e.g. a
88    /// named identity like `korp-asaf`), so a suffix match on the combined
89    /// `"<project>-<agent_id>"` key could wrongly match a different
90    /// agent's registration whose id happens to end in the same substring.
91    pub async fn unregister_agent(&self, agent_id: &str) {
92        let agent_id = agent_id.to_lowercase();
93        self.routes
94            .write()
95            .await
96            .retain(|_, route| route.agent_id != agent_id);
97    }
98
99    pub async fn lookup(&self, key: &str) -> Option<SocketAddr> {
100        self.routes.read().await.get(key).map(|r| r.addr)
101    }
102
103    #[cfg(test)]
104    async fn len(&self) -> usize {
105        self.routes.read().await.len()
106    }
107}
108
109/// Host header → routing key. `"pulse-korp.localhost:8090"` and
110/// `"pulse-korp.localhost"` both become `"pulse-korp"`. A Host that isn't
111/// `.localhost` is still accepted (minus port, lowercased) rather than
112/// rejected outright — an empty/missing Host is the only thing that
113/// produces `None`. Whether the key actually resolves to anything is the
114/// registry lookup's job, not this function's; either way the caller gets
115/// a clear "no dev server registered for `<key>`" 404, never a confusing
116/// hostname-format error.
117pub fn routing_key_from_host(host: &str) -> Option<String> {
118    let host = host.trim();
119    if host.is_empty() {
120        return None;
121    }
122    // Lowercase BEFORE stripping the `.localhost` suffix, not after — a
123    // literal `.strip_suffix(".localhost")` is case-sensitive, so an
124    // upper/mixed-case Host (e.g. from a client that title-cases URLs)
125    // would otherwise fail to match and fall through with the suffix
126    // still attached.
127    let without_port = host.split(':').next().unwrap_or(host).to_lowercase();
128    let key = without_port.strip_suffix(".localhost").unwrap_or(&without_port);
129    if key.is_empty() {
130        None
131    } else {
132        Some(key.to_string())
133    }
134}
135
136#[derive(Clone)]
137struct ProxyState {
138    registry: DevProxyRegistry,
139    client: reqwest::Client,
140}
141
142/// Builds the proxy's axum router. Split out from [`serve`] so tests can
143/// drive it directly (via a real bound listener) without needing the fixed
144/// [`DEV_PROXY_PORT`] to be free.
145pub fn router(registry: DevProxyRegistry) -> Router {
146    let client = reqwest::Client::builder()
147        .build()
148        .unwrap_or_else(|_| reqwest::Client::new());
149    let state = ProxyState { registry, client };
150    // `.fallback` (not a path route): routing here is entirely by Host
151    // header, so every path/method for every host is the same handler.
152    Router::new()
153        .fallback(any(proxy_handler))
154        .with_state(state)
155}
156
157/// Binds [`DEV_PROXY_PORT`] on loopback and serves the proxy until the
158/// process exits. Spawned once at srv startup (`main.rs`). A bind failure
159/// (e.g. the port is already in use on this machine) is logged and the
160/// proxy is simply unavailable for this run — never fatal to srv startup;
161/// dev-server routing is an additive convenience, not core functionality.
162pub async fn serve(registry: DevProxyRegistry) {
163    let addr = SocketAddr::from(([127, 0, 0, 1], DEV_PROXY_PORT));
164    let listener = match tokio::net::TcpListener::bind(addr).await {
165        Ok(l) => l,
166        Err(e) => {
167            tracing::warn!(
168                port = DEV_PROXY_PORT,
169                error = %e,
170                "dev-proxy: failed to bind — dev-server routing unavailable this run"
171            );
172            return;
173        }
174    };
175    tracing::info!(port = DEV_PROXY_PORT, "dev-proxy: listening");
176    if let Err(e) = axum::serve(listener, router(registry)).await {
177        tracing::error!(error = %e, "dev-proxy: server error");
178    }
179}
180
181fn not_found_response(key: &str) -> Response {
182    (
183        StatusCode::NOT_FOUND,
184        format!(
185            "AgentMux dev proxy: no dev server registered for \"{key}\". \
186             Call RegisterDevServer from the owning agent once its dev server is listening."
187        ),
188    )
189        .into_response()
190}
191
192/// The one handler this proxy has. Reads `Host`, looks up the backend,
193/// forwards the request (method/headers/body, streamed both ways so a
194/// large response body — or a long-lived one — isn't buffered whole in
195/// memory), and streams the response back unchanged.
196async fn proxy_handler(State(state): State<ProxyState>, req: Request) -> Response {
197    let host = req
198        .headers()
199        .get(header::HOST)
200        .and_then(|v| v.to_str().ok())
201        .unwrap_or("")
202        .to_string();
203
204    let Some(key) = routing_key_from_host(&host) else {
205        return not_found_response(&host);
206    };
207
208    let Some(addr) = state.registry.lookup(&key).await else {
209        return not_found_response(&key);
210    };
211
212    let (parts, body) = req.into_parts();
213    let path_and_query = parts
214        .uri
215        .path_and_query()
216        .map(|pq| pq.as_str())
217        .unwrap_or("/");
218    let target_url = format!("http://{addr}{path_and_query}");
219
220    let req_stream = body.into_data_stream().map_err(std::io::Error::other);
221    let outbound_body = reqwest::Body::wrap_stream(req_stream);
222
223    let mut builder = state.client.request(parts.method.clone(), &target_url);
224    for (name, value) in parts.headers.iter() {
225        // Same hop-by-hop exclusion as the response path below, applied to
226        // the request side: `Host` describes a connection to THIS proxy, not
227        // the backend, and `Transfer-Encoding`/`Connection` describe the
228        // client's connection to us. Forwarding `Transfer-Encoding: chunked`
229        // verbatim alongside the independently re-streamed
230        // `reqwest::Body::wrap_stream` body would assert a chunked encoding
231        // reqwest is not actually producing — a conflicting signal that can
232        // break the forwarded request. reagent P1, PR #3439.
233        if name == header::HOST
234            || name == header::TRANSFER_ENCODING
235            || name == header::CONNECTION
236        {
237            continue;
238        }
239        builder = builder.header(name.clone(), value.clone());
240    }
241    let builder = builder.body(outbound_body);
242
243    match builder.send().await {
244        Ok(resp) => {
245            let status = resp.status();
246            let headers = resp.headers().clone();
247            let resp_stream = resp.bytes_stream().map_err(std::io::Error::other);
248            let mut out = Response::builder().status(status);
249            for (name, value) in headers.iter() {
250                // Hop-by-hop headers describing reqwest's own connection to
251                // the backend — passing them through would describe a
252                // connection that doesn't exist between the real client
253                // and this proxy (e.g. a stale Content-Length next to a
254                // re-chunked stream).
255                if name == header::TRANSFER_ENCODING || name == header::CONNECTION {
256                    continue;
257                }
258                out = out.header(name.clone(), value.clone());
259            }
260            out.body(Body::from_stream(resp_stream))
261                .unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
262        }
263        Err(e) => {
264            tracing::warn!(
265                key = %key,
266                target = %target_url,
267                error = %e,
268                "dev-proxy: upstream request failed"
269            );
270            (
271                StatusCode::BAD_GATEWAY,
272                format!("AgentMux dev proxy: could not reach dev server for \"{key}\": {e}"),
273            )
274                .into_response()
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn addr(port: u16) -> SocketAddr {
284        SocketAddr::from(([127, 0, 0, 1], port))
285    }
286
287    #[test]
288    fn routing_key_is_project_dash_agent_lowercased() {
289        assert_eq!(DevProxyRegistry::routing_key("Pulse", "Korp"), "pulse-korp");
290        assert_eq!(DevProxyRegistry::routing_key("api", "agent1"), "api-agent1");
291    }
292
293    #[test]
294    fn host_parsing_strips_port_and_localhost_suffix() {
295        assert_eq!(
296            routing_key_from_host("pulse-korp.localhost:8090"),
297            Some("pulse-korp".to_string())
298        );
299        assert_eq!(
300            routing_key_from_host("pulse-korp.localhost"),
301            Some("pulse-korp".to_string())
302        );
303        assert_eq!(
304            routing_key_from_host("Pulse-Korp.LOCALHOST:8090"),
305            Some("pulse-korp".to_string())
306        );
307    }
308
309    #[test]
310    fn host_parsing_passes_through_a_non_localhost_host_minus_port() {
311        // Not rejected outright — the registry lookup (empty for anything
312        // never registered) is what actually 404s an unrecognized host,
313        // not this parsing step.
314        assert_eq!(
315            routing_key_from_host("example.com:8090"),
316            Some("example.com".to_string())
317        );
318    }
319
320    #[test]
321    fn host_parsing_rejects_only_empty_host() {
322        assert_eq!(routing_key_from_host(""), None);
323        assert_eq!(routing_key_from_host("   "), None);
324    }
325
326    #[tokio::test]
327    async fn register_then_lookup_finds_the_backend() {
328        let registry = DevProxyRegistry::new();
329        registry.register("pulse", "korp", addr(4000)).await;
330        assert_eq!(
331            registry
332                .lookup(&DevProxyRegistry::routing_key("pulse", "korp"))
333                .await,
334            Some(addr(4000))
335        );
336    }
337
338    #[tokio::test]
339    async fn lookup_of_an_unregistered_key_is_none() {
340        let registry = DevProxyRegistry::new();
341        assert_eq!(registry.lookup("nope-nobody").await, None);
342    }
343
344    #[tokio::test]
345    async fn unregister_agent_removes_only_that_agents_routes() {
346        let registry = DevProxyRegistry::new();
347        registry.register("pulse", "korp", addr(4000)).await;
348        registry.register("stratum", "korp", addr(4001)).await;
349        registry.register("app", "narko", addr(4002)).await;
350        assert_eq!(registry.len().await, 3);
351
352        registry.unregister_agent("korp").await;
353
354        assert_eq!(registry.len().await, 1);
355        assert_eq!(
356            registry
357                .lookup(&DevProxyRegistry::routing_key("pulse", "korp"))
358                .await,
359            None
360        );
361        assert_eq!(
362            registry
363                .lookup(&DevProxyRegistry::routing_key("app", "narko"))
364                .await,
365            Some(addr(4002))
366        );
367    }
368
369    /// The exact scenario the doc comment on `unregister_agent` warns
370    /// about: an agent id that itself contains a hyphen and is a suffix of
371    /// a DIFFERENT agent's id must not be swept up by a naive
372    /// `key.ends_with("-<agent_id>")` check.
373    #[tokio::test]
374    async fn unregister_agent_is_exact_not_a_suffix_match() {
375        let registry = DevProxyRegistry::new();
376        registry.register("proj", "x-lib", addr(4000)).await; // key: "proj-x-lib"
377        registry.register("proj", "lib", addr(4001)).await; // key: "proj-lib"
378
379        registry.unregister_agent("lib").await;
380
381        assert_eq!(
382            registry
383                .lookup(&DevProxyRegistry::routing_key("proj", "x-lib"))
384                .await,
385            Some(addr(4000)),
386            "a different agent (\"x-lib\") whose id merely ends with \"lib\" must survive"
387        );
388        assert_eq!(
389            registry
390                .lookup(&DevProxyRegistry::routing_key("proj", "lib"))
391                .await,
392            None
393        );
394    }
395
396    #[tokio::test]
397    async fn re_registering_the_same_key_overwrites_the_address() {
398        let registry = DevProxyRegistry::new();
399        registry.register("pulse", "korp", addr(4000)).await;
400        registry.register("pulse", "korp", addr(5000)).await;
401        assert_eq!(
402            registry
403                .lookup(&DevProxyRegistry::routing_key("pulse", "korp"))
404                .await,
405            Some(addr(5000))
406        );
407    }
408
409    /// End-to-end through real HTTP: a tiny backend server, the proxy
410    /// router in front of it, and a real client request carrying a Host
411    /// header — not an in-process function call. Confirms the whole
412    /// request path (Host parsing → lookup → forward → stream response
413    /// back) actually works, and that an unregistered Host 404s cleanly
414    /// instead of hanging or producing a confusing proxy error.
415    #[tokio::test]
416    async fn proxies_a_real_request_and_404s_an_unregistered_host() {
417        // Fake dev server: echoes back a fixed, recognizable body.
418        let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
419            .await
420            .expect("bind fake backend");
421        let backend_addr = backend_listener.local_addr().unwrap();
422        let backend_router = Router::new().fallback(any(|| async {
423            (StatusCode::OK, "hello from the fake dev server")
424        }));
425        tokio::spawn(async move {
426            let _ = axum::serve(backend_listener, backend_router).await;
427        });
428
429        let registry = DevProxyRegistry::new();
430        registry.register("pulse", "korp", backend_addr).await;
431
432        let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
433            .await
434            .expect("bind proxy");
435        let proxy_addr = proxy_listener.local_addr().unwrap();
436        tokio::spawn(async move {
437            let _ = axum::serve(proxy_listener, router(registry)).await;
438        });
439
440        let client = reqwest::Client::new();
441
442        // Correct Host — should reach the fake backend.
443        let ok_resp = client
444            .get(format!("http://{proxy_addr}/anything"))
445            .header(header::HOST, "pulse-korp.localhost:8090")
446            .send()
447            .await
448            .expect("request through proxy");
449        assert_eq!(ok_resp.status(), StatusCode::OK);
450        let body = ok_resp.text().await.expect("body");
451        assert_eq!(body, "hello from the fake dev server");
452
453        // Wrong/unregistered Host — clean 404, not a hang or a raw
454        // connection-refused error.
455        let missing_resp = client
456            .get(format!("http://{proxy_addr}/anything"))
457            .header(header::HOST, "nobody-here.localhost:8090")
458            .send()
459            .await
460            .expect("request through proxy");
461        assert_eq!(missing_resp.status(), StatusCode::NOT_FOUND);
462        let missing_body = missing_resp.text().await.expect("body");
463        assert!(missing_body.contains("no dev server registered"));
464    }
465
466    /// reagent P1, PR #3439: hop-by-hop headers describing the CLIENT's
467    /// connection to THIS proxy (`Transfer-Encoding`, `Connection`) must not
468    /// be forwarded to the backend — the outbound body is independently
469    /// re-streamed via `reqwest::Body::wrap_stream`, so a forwarded
470    /// `Transfer-Encoding: chunked` would assert an encoding reqwest isn't
471    /// actually producing. Confirmed by having the fake backend report back
472    /// exactly which headers it received, not by inspecting the proxy's
473    /// outbound request in isolation — the same "real HTTP, not an
474    /// in-process function call" standard as the test above.
475    #[tokio::test]
476    async fn hop_by_hop_request_headers_are_not_forwarded_to_the_backend() {
477        let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
478            .await
479            .expect("bind fake backend");
480        let backend_addr = backend_listener.local_addr().unwrap();
481        let backend_router = Router::new().fallback(any(
482            |req: Request| async move {
483                let saw_transfer_encoding =
484                    req.headers().contains_key(header::TRANSFER_ENCODING);
485                let saw_connection = req.headers().contains_key(header::CONNECTION);
486                (
487                    StatusCode::OK,
488                    format!("te={saw_transfer_encoding} conn={saw_connection}"),
489                )
490            },
491        ));
492        tokio::spawn(async move {
493            let _ = axum::serve(backend_listener, backend_router).await;
494        });
495
496        let registry = DevProxyRegistry::new();
497        registry.register("pulse", "korp", backend_addr).await;
498
499        let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
500            .await
501            .expect("bind proxy");
502        let proxy_addr = proxy_listener.local_addr().unwrap();
503        tokio::spawn(async move {
504            let _ = axum::serve(proxy_listener, router(registry)).await;
505        });
506
507        let client = reqwest::Client::new();
508        let resp = client
509            .get(format!("http://{proxy_addr}/anything"))
510            .header(header::HOST, "pulse-korp.localhost:8090")
511            .header(header::TRANSFER_ENCODING, "chunked")
512            .header(header::CONNECTION, "keep-alive")
513            .send()
514            .await
515            .expect("request through proxy");
516        assert_eq!(resp.status(), StatusCode::OK);
517        let body = resp.text().await.expect("body");
518        assert_eq!(body, "te=false conn=false");
519    }
520}