fix: strip /agent/<name>/ via rewrite in named location

nginx forbids a URI part on proxy_pass inside named locations, so the
split-mode `@<name>_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/<name>/` via `rewrite ... break` and uses a bare upstream.
This commit is contained in:
müde 2026-06-01 19:10:38 +02:00
commit f9de183f4e

View file

@ -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
// `@<name>_dynamic`) forbid a URI part on `proxy_pass`. The
// legacy prefix-location path keeps the trailing `/` so nginx
// strips `/agent/<name>/` 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/<name>/ 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<String> = [MANAGER_NAME, "iris"]