1use 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
30pub const DEV_PROXY_PORT: u16 = 8090;
34
35#[derive(Debug, Clone)]
40struct Route {
41 addr: SocketAddr,
42 agent_id: String,
43}
44
45#[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 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 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
109pub fn routing_key_from_host(host: &str) -> Option<String> {
118 let host = host.trim();
119 if host.is_empty() {
120 return None;
121 }
122 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
142pub 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 Router::new()
153 .fallback(any(proxy_handler))
154 .with_state(state)
155}
156
157pub 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
192async 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 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 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 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 #[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; registry.register("proj", "lib", addr(4001)).await; 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 #[tokio::test]
416 async fn proxies_a_real_request_and_404s_an_unregistered_host() {
417 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 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 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 #[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}