From f9de183f4e7b458581fb8935bb584dc14729c6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Mon, 1 Jun 2026 19:10:38 +0200 Subject: [PATCH] fix: strip /agent// via rewrite in named location nginx forbids a URI part on proxy_pass inside named locations, so the split-mode `@_dynamic` fallback (introduced when the gateway started serving static dist directly) failed config-test with: "proxy_pass" cannot have URI part in location given by regular expression, or inside named location Render two upstream forms: prefix locations keep the trailing slash so nginx auto-strips the location prefix; the named-location path strips `/agent//` via `rewrite ... break` and uses a bare upstream. --- hive-c0re/src/gateway_nginx.rs | 58 ++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/hive-c0re/src/gateway_nginx.rs b/hive-c0re/src/gateway_nginx.rs index e5cf30c6..e00d57b7 100644 --- a/hive-c0re/src/gateway_nginx.rs +++ b/hive-c0re/src/gateway_nginx.rs @@ -76,13 +76,22 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { ); for name in names { let port = lifecycle::agent_web_port(name); - let upstream = if agent_sockets::ready_marker_for(name).exists() { - format!( - "http://unix:{}:/", - agent_sockets::socket_path_for(name).display() + // Two upstream forms because named locations (split mode's + // `@_dynamic`) forbid a URI part on `proxy_pass`. The + // legacy prefix-location path keeps the trailing `/` so nginx + // strips `/agent//` automatically; the named-location + // path strips the prefix via `rewrite` and uses a bare upstream. + let (upstream_prefix, upstream_bare) = if agent_sockets::ready_marker_for(name).exists() { + let sock = agent_sockets::socket_path_for(name).display().to_string(); + ( + format!("http://unix:{sock}:/"), + format!("http://unix:{sock}:"), ) } else { - format!("http://127.0.0.1:{port}/") + ( + format!("http://127.0.0.1:{port}/"), + format!("http://127.0.0.1:{port}"), + ) }; if let Some(frontend) = frontend_dir { @@ -124,7 +133,8 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { }}\n\ \nlocation @{name}_dynamic {{\n\ {PROXY_HEADER_BLOCK}\n\ - \n proxy_pass {upstream};\n\ + \n rewrite ^/agent/{name}/(.*)$ /$1 break;\n\ + \n proxy_pass {upstream_bare};\n\ \n proxy_set_header X-Forwarded-Prefix /agent/{name};\n\ \n proxy_buffering off;\n\ \n proxy_read_timeout 1d;\n\ @@ -138,7 +148,7 @@ fn render(names: &[String], frontend_dir: Option<&str>) -> String { out, "\nlocation /agent/{name}/ {{\n\ {PROXY_HEADER_BLOCK}\n\ - \n proxy_pass {upstream};\n\ + \n proxy_pass {upstream_prefix};\n\ \n proxy_set_header X-Forwarded-Prefix /agent/{name};\n\ \n proxy_buffering off;\n\ \n proxy_read_timeout 1d;\n\ @@ -381,6 +391,40 @@ mod tests { assert!(body.contains("__hive_agent_unreachable")); } + #[test] + fn split_mode_named_location_strips_prefix_without_uri_part() { + // Named locations forbid a URI part on proxy_pass — nginx rejects + // `proxy_pass http://host/` inside `location @name`. Must use + // bare upstream (no trailing `/` or path) plus a `rewrite` to + // strip the /agent// prefix. + let names = vec!["iris".to_owned()]; + let body = render(&names, Some(FAKE_FRONTEND)); + assert!( + body.contains("rewrite ^/agent/iris/(.*)$ /$1 break;"), + "expected prefix-strip rewrite, got:\n{body}" + ); + // The named-location proxy_pass must have no URI part (no + // trailing slash or path). Extract the `@iris_dynamic { ... }` + // block and check every proxy_pass directive in it. + let block_start = body.find("location @iris_dynamic {").expect("named location"); + let block = &body[block_start..]; + let block_end = block.find("\n}\n").expect("block close"); + let block = &block[..block_end]; + for line in block.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("proxy_pass ") { + let target = rest.trim_end_matches(';'); + // No URI part means: TCP form `http://host:port` (no + // trailing `/`), UDS form `http://unix:/path:` (trailing + // colon, nothing after). Both cases: must not end in `/`. + assert!( + !target.ends_with('/'), + "named-location proxy_pass must have no URI part, got: {target}" + ); + } + } + } + #[test] fn split_mode_includes_manager() { let names: Vec = [MANAGER_NAME, "iris"]