diff --git a/Cargo.lock b/Cargo.lock index b89e4d40a..b6fec76c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ dependencies = [ "ipnet", "jsonschema", "once_cell", + "regex", "schemars 0.8.22", "serde", "serde_json", @@ -373,6 +374,7 @@ dependencies = [ "jsonwebtoken", "lofty", "rand 0.8.5", + "regex", "reqwest 0.12.28", "serde", "serde_json", diff --git a/config.example.yaml b/config.example.yaml index c66ca767b..c4e8e09c7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -51,6 +51,23 @@ proxy: # trusted_proxies: ["10.0.0.0/8", "127.0.0.1/32"] # recursive: true # header: x-forwarded-for + # Entry-level URL rewriting: map legacy URL shapes onto AISIX endpoints + # before routing. The first rule whose `match` regex matches the request + # path rewrites it (once, no cascading); the request then flows through + # the normal endpoint — auth, ACL, quota — as if the client had sent the + # rewritten path. `match` runs against the RAW, percent-encoded path (no + # decoding or normalization); `rewrite` replaces the matched portion + # ($1/${name} expand capture groups; use ${1}text to follow a group with + # literal text); the query string is preserved. Invalid regexes, unknown + # group references, and `?`/`#`/whitespace in the template fail startup. + # In env-only deployments set the whole list as one JSON array: + # AISIX_PROXY__URL_REWRITES='[{"match":"...","rewrite":"..."}]' + # Example: serve per-server MCP URLs like /mcp-servers/github/mcp on + # the /mcp/{server} endpoint. + # url_rewrites: + # - name: per-server-mcp-compat + # match: "^/mcp-servers/([^/]+)/mcp$" + # rewrite: "/mcp/$1" admin: addr: "127.0.0.1:3001" diff --git a/config.managed.yaml b/config.managed.yaml index c3cf4debf..cd7311ab7 100644 --- a/config.managed.yaml +++ b/config.managed.yaml @@ -44,6 +44,16 @@ proxy: # 0 = no request-body cap (the default); set a value to bound # per-request memory. # request_body_limit_bytes: 0 + # Entry-level URL rewriting (first matching rule wins; `match` runs on + # the raw, percent-encoded path; `rewrite` replaces the matched portion, + # $1 = capture group; the query string is preserved). Lets clients keep + # legacy URL shapes, e.g. per-server MCP paths served on the + # /mcp/{server} endpoint. When configuring through env vars only, set + # one JSON array: AISIX_PROXY__URL_REWRITES='[{"match":"...","rewrite":"..."}]' + # url_rewrites: + # - name: per-server-mcp-compat + # match: "^/mcp-servers/([^/]+)/mcp$" + # rewrite: "/mcp/$1" admin: # Bind to an unbindable port so even if managed mode somehow diff --git a/crates/aisix-admin/src/playground_handler.rs b/crates/aisix-admin/src/playground_handler.rs index cc0e70431..3477f6a88 100644 --- a/crates/aisix-admin/src/playground_handler.rs +++ b/crates/aisix-admin/src/playground_handler.rs @@ -79,6 +79,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-core/Cargo.toml b/crates/aisix-core/Cargo.toml index 717095b92..d513a2c1a 100644 --- a/crates/aisix-core/Cargo.toml +++ b/crates/aisix-core/Cargo.toml @@ -17,6 +17,7 @@ thiserror.workspace = true anyhow.workspace = true config.workspace = true jsonschema.workspace = true +regex.workspace = true schemars = { workspace = true, features = ["chrono"] } # ApiKey::expires_at parses RFC 3339 timestamps for key-expiry # enforcement in the proxy auth path (#933). diff --git a/crates/aisix-core/src/config.rs b/crates/aisix-core/src/config.rs index a4a973154..6fb4814c3 100644 --- a/crates/aisix-core/src/config.rs +++ b/crates/aisix-core/src/config.rs @@ -393,6 +393,21 @@ pub struct ProxyConfig { /// an L7 LB / ingress that sets `x-forwarded-for`. #[serde(default)] pub real_ip: RealIpConfig, + /// Entry-level URL rewrite rules, applied to every proxy-listener + /// request **before** routing (the admin and metrics listeners are + /// unaffected). The first rule whose `match` regex matches the request + /// path rewrites it — once, no cascading — and the request then flows + /// through the normal endpoint (auth, ACL, quota, …) as if the client + /// had sent the rewritten path. Lets operators map legacy URL shapes + /// onto AISIX endpoints, e.g. per-server MCP paths onto + /// `/mcp/{server}`. Empty (the default) = no rewriting. + /// + /// Env-only deployments (the chart injects config purely through + /// `AISIX_*` vars, which cannot express a structured list) set the + /// whole list as one JSON array: + /// `AISIX_PROXY__URL_REWRITES='[{"match":"^/x$","rewrite":"/y"}]'`. + #[serde(default, deserialize_with = "deserialize_url_rewrites")] + pub url_rewrites: Vec, } impl ProxyConfig { @@ -401,6 +416,118 @@ impl ProxyConfig { } } +/// One entry-level URL rewrite rule (see [`ProxyConfig::url_rewrites`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UrlRewriteRule { + /// Optional name, used in logs when the rule fires. + #[serde(default)] + pub name: Option, + /// Regex matched against the **raw, percent-encoded** request path + /// (never the query string) — no decoding, no normalization. Anchor + /// with `^`/`$` to match the whole path; an unanchored pattern matches + /// anywhere in it. Must not match the empty string. + #[serde(rename = "match")] + pub pattern: String, + /// Replacement for the matched portion of the path. Capture groups are + /// available as `$1`… / `${name}`; use `${1}x` (braced) when a literal + /// character follows a group reference (`$1x` reads as the group named + /// `1x`). The query string is preserved as sent, so the template must + /// not contain `?`, `#`, whitespace, or control characters. + #[serde(rename = "rewrite")] + pub replacement: String, +} + +/// Accept `url_rewrites` either as a structured sequence (config file) or +/// as a JSON array carried in one string — the only shape an env var can +/// hold, and env vars are the sole config channel in chart-driven +/// deployments. +fn deserialize_url_rewrites<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum SeqOrJsonString { + Seq(Vec), + JsonString(String), + } + match SeqOrJsonString::deserialize(deserializer)? { + SeqOrJsonString::Seq(rules) => Ok(rules), + SeqOrJsonString::JsonString(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str(trimmed) + .map_err(|e| serde::de::Error::custom(format!("url_rewrites JSON string: {e}"))) + } + } +} + +/// Reject a rewrite template that references capture groups its pattern +/// does not define — the regex engine expands unknown references to the +/// empty string, which would silently rewrite traffic to the wrong +/// endpoint. Mirrors the engine's replacement syntax: `$$` is a literal +/// `$`, `${name}` is a braced reference, and a bare `$name` reference +/// spans the longest run of `[0-9A-Za-z_]` (so `$1x` reads as a group +/// named `1x`, not group 1 followed by `x`). +fn validate_rewrite_template_refs(regex: ®ex::Regex, template: &str) -> Result<(), String> { + let names: std::collections::HashSet<&str> = regex.capture_names().flatten().collect(); + let group_count = regex.captures_len(); // includes group 0 (the whole match) + let ref_ok = |name: &str| { + if name.chars().all(|c| c.is_ascii_digit()) { + name.parse::().is_ok_and(|idx| idx < group_count) + } else { + names.contains(name) + } + }; + let bytes = template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] != b'$' { + i += 1; + continue; + } + if bytes.get(i + 1) == Some(&b'$') { + i += 2; + continue; + } + if bytes.get(i + 1) == Some(&b'{') { + let Some(end) = template[i + 2..].find('}') else { + return Err("rewrite has an unterminated `${…}` group reference".to_string()); + }; + let name = &template[i + 2..i + 2 + end]; + if name.is_empty() || !ref_ok(name) { + return Err(format!( + "rewrite references unknown capture group `${{{name}}}`" + )); + } + i += 2 + end + 1; + continue; + } + let start = i + 1; + let mut end = start; + while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') { + end += 1; + } + if end == start { + // A bare trailing `$`: the engine treats it as a literal. + i += 1; + continue; + } + let name = &template[start..end]; + if !ref_ok(name) { + return Err(format!( + "rewrite references unknown capture group `${name}` \ + (write `${{N}}text` to follow group N with literal text)" + )); + } + i = end; + } + Ok(()) +} + /// nginx `set_real_ip_from` + `real_ip_recursive` equivalent. Resolves /// the downstream client IP for usage logs (#492) from a forwarded /// header, trusting only addresses inside `trusted_proxies`. @@ -1094,6 +1221,54 @@ impl Config { } fn validate(&self) -> Result<(), BootstrapError> { + // Fail fast on an unusable rewrite rule: a broken rule would + // otherwise surface at runtime as silently mis-routed or 404ing + // legacy traffic, which is much harder to trace back to a typo in + // one line of config. + for (i, rule) in self.proxy.url_rewrites.iter().enumerate() { + let ctx = || { + rule.name + .clone() + .unwrap_or_else(|| format!("proxy.url_rewrites[{i}]")) + }; + let regex = match regex::Regex::new(&rule.pattern) { + Ok(regex) => regex, + Err(e) => { + return Err(BootstrapError::Config(format!( + "{}: invalid match regex: {e}", + ctx() + ))); + } + }; + // A pattern that matches the empty string would fire on every + // request (zero-width match at position 0) and prepend the + // template to every path. + if regex.find("").is_some() { + return Err(BootstrapError::Config(format!( + "{}: match must not match the empty string", + ctx() + ))); + } + // The template lands inside a URI path; a `?` would absorb the + // caller's query into itself and a `#` would truncate the path + // as a fragment — both silently. Reject them (and unprintables) + // up front; capture-group expansions are safe because a request + // path can never contain these characters raw. + if let Some(bad) = rule + .replacement + .chars() + .find(|c| matches!(c, '?' | '#') || c.is_whitespace() || c.is_control()) + { + return Err(BootstrapError::Config(format!( + "{}: rewrite must not contain {bad:?} (the template is a path; \ + the query string is preserved automatically)", + ctx() + ))); + } + if let Err(e) = validate_rewrite_template_refs(®ex, &rule.replacement) { + return Err(BootstrapError::Config(format!("{}: {e}", ctx()))); + } + } if let Some(path) = self.resources_file.as_deref() { // File source selected: exactly one resource source may be // active. A configured etcd endpoint list alongside the file @@ -1306,6 +1481,162 @@ admin: assert!(nets.iter().any(|n| n.to_string() == "10.0.0.0/8")); } + #[test] + fn loads_url_rewrites_and_rejects_an_invalid_regex() { + let f = write_yaml( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: + - name: per-server-mcp-compat + match: "^/mcp-servers/([^/]+)/mcp$" + rewrite: "/mcp/$1" +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let cfg = Config::load_from_path(Some(f.path())).unwrap(); + assert_eq!(cfg.proxy.url_rewrites.len(), 1); + assert_eq!(cfg.proxy.url_rewrites[0].replacement, "/mcp/$1"); + + let f = write_yaml( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: + - match: "^/mcp-servers/([^/+/mcp$" + rewrite: "/mcp/$1" +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let err = Config::load_from_path(Some(f.path())).unwrap_err(); + assert!( + format!("{err}").contains("url_rewrites"), + "error should name the bad rule: {err}" + ); + } + + #[test] + fn url_rewrites_accepts_a_json_string_for_env_only_deployments() { + // Chart-driven deployments inject config purely through AISIX_* env + // vars, which cannot express a structured list — the whole list + // rides in one JSON string. A YAML string scalar takes the same + // code path as the env source. + let f = write_yaml( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: '[{"name":"compat","match":"^/mcp-servers/([^/]+)/mcp$","rewrite":"/mcp/$1"}]' +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let cfg = Config::load_from_path(Some(f.path())).unwrap(); + assert_eq!(cfg.proxy.url_rewrites.len(), 1); + assert_eq!(cfg.proxy.url_rewrites[0].name.as_deref(), Some("compat")); + + let f = write_yaml( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: '[{"match": broken' +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + let err = Config::load_from_path(Some(f.path())).unwrap_err(); + assert!( + format!("{err}").contains("url_rewrites"), + "error should name the field: {err}" + ); + } + + #[test] + fn url_rewrites_rejects_silent_template_mistakes() { + let case = |rule_yaml: &str, expect: &str| { + let f = write_yaml(&format!( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: +{rule_yaml} +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"# + )); + let err = Config::load_from_path(Some(f.path())).unwrap_err(); + assert!( + format!("{err}").contains(expect), + "expected {expect:?} in: {err}" + ); + }; + + // An unknown group reference expands to the empty string at runtime + // — every legacy request would silently land on the wrong endpoint. + case( + " - match: \"^/mcp-servers/([^/]+)/mcp$\"\n rewrite: \"/mcp/$2\"", + "unknown capture group", + ); + case( + " - match: \"^/gw/(?P[^/]+)$\"\n rewrite: \"/mcp/${srv}\"", + "unknown capture group", + ); + // `?` would absorb the caller's query; `#` would truncate the path. + case( + " - match: \"^/a$\"\n rewrite: \"/v1/chat?model=x\"", + "must not contain", + ); + case( + " - match: \"^/a$\"\n rewrite: \"/v1/models#frag\"", + "must not contain", + ); + // A pattern matching the empty string fires on every request. + case( + " - match: \"(x)?\"\n rewrite: \"/y\"", + "empty string", + ); + + // The braced form with literal text after a group is the valid way + // to write what `$1x` cannot mean. + let f = write_yaml( + r#" +etcd: + endpoints: ["http://127.0.0.1:2379"] + prefix: "/aisix" +proxy: + addr: "0.0.0.0:3000" + url_rewrites: + - match: "^/gw/(?P[^/]+)/v(\\d+)$" + rewrite: "/mcp/${server}-v${2}" +admin: + addr: "127.0.0.1:3001" + admin_keys: ["k1"] +"#, + ); + Config::load_from_path(Some(f.path())).expect("braced references are valid"); + } + #[test] fn rejects_malformed_trusted_proxy_cidr() { let f = write_yaml( diff --git a/crates/aisix-core/src/lib.rs b/crates/aisix-core/src/lib.rs index a379c6b87..bc3fda26d 100644 --- a/crates/aisix-core/src/lib.rs +++ b/crates/aisix-core/src/lib.rs @@ -30,7 +30,7 @@ pub mod wildcard; pub use config::{ AdminConfig, CacheBackend, CacheConfig, ClientTypeRule, Config, EtcdConfig, EtcdTlsConfig, HistogramBucketsConfig, ManagedConfig, ObservabilityConfig, ProxyConfig, RateLimitBackend, - RateLimitConfig, RealIpConfig, RedisConnConfig, RedisMode, TlsConfig, + RateLimitConfig, RealIpConfig, RedisConnConfig, RedisMode, TlsConfig, UrlRewriteRule, }; pub use config_status::{ hash_bytes, hash_entries, AppliedSnapshot, ConfigMetricsView, ConfigState, ConfigStatus, diff --git a/crates/aisix-proxy/Cargo.toml b/crates/aisix-proxy/Cargo.toml index 946a22a33..8211ca817 100644 --- a/crates/aisix-proxy/Cargo.toml +++ b/crates/aisix-proxy/Cargo.toml @@ -42,6 +42,7 @@ tower.workspace = true tower-http.workspace = true http-body-util.workspace = true bytes.workspace = true +regex.workspace = true serde.workspace = true serde_json.workspace = true # Token-estimation fallback for usage telemetry (token_estimate.rs). diff --git a/crates/aisix-proxy/src/a2a.rs b/crates/aisix-proxy/src/a2a.rs index e722b36ab..3f95aaa3a 100644 --- a/crates/aisix-proxy/src/a2a.rs +++ b/crates/aisix-proxy/src/a2a.rs @@ -375,6 +375,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/audio.rs b/crates/aisix-proxy/src/audio.rs index bcc6fb343..d18aa7015 100644 --- a/crates/aisix-proxy/src/audio.rs +++ b/crates/aisix-proxy/src/audio.rs @@ -1439,6 +1439,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 10_485_760, // 10 MB for audio real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/completions.rs b/crates/aisix-proxy/src/completions.rs index 5ba0fa18c..2b788835b 100644 --- a/crates/aisix-proxy/src/completions.rs +++ b/crates/aisix-proxy/src/completions.rs @@ -744,6 +744,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/count_tokens.rs b/crates/aisix-proxy/src/count_tokens.rs index ea0f8bf8d..28bfc1101 100644 --- a/crates/aisix-proxy/src/count_tokens.rs +++ b/crates/aisix-proxy/src/count_tokens.rs @@ -544,6 +544,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/embeddings.rs b/crates/aisix-proxy/src/embeddings.rs index 738e4c828..612a6f695 100644 --- a/crates/aisix-proxy/src/embeddings.rs +++ b/crates/aisix-proxy/src/embeddings.rs @@ -669,6 +669,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/images.rs b/crates/aisix-proxy/src/images.rs index 5019831e4..5ec13b6b0 100644 --- a/crates/aisix-proxy/src/images.rs +++ b/crates/aisix-proxy/src/images.rs @@ -541,6 +541,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index 7519b3999..4e215ecd0 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -1802,6 +1802,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/lib.rs b/crates/aisix-proxy/src/lib.rs index c683c9e9e..ac2b2dd36 100644 --- a/crates/aisix-proxy/src/lib.rs +++ b/crates/aisix-proxy/src/lib.rs @@ -60,6 +60,7 @@ mod request_id; mod rerank; mod responses; mod responses_bridge; +mod rewrite; mod routing; mod semantic; pub mod sse_keepalive; @@ -100,7 +101,7 @@ static SERVER_HEADER_VALUE: std::sync::LazyLock = std::sync::LazyLo /// OpenAI-compatible proxy surface. pub fn build_router(state: ProxyState) -> Router { let body_limit = state.request_body_limit_bytes; - Router::new() + let router = Router::new() .route("/livez", get(livez)) .route("/readyz", get(readyz)) .route("/v1/models", get(models::list_models)) @@ -220,13 +221,35 @@ pub fn build_router(state: ProxyState) -> Router { header::SERVER, SERVER_HEADER_VALUE.clone(), )) - // Outermost: mint the request id into the request extensions - // before any handler/extractor runs, and stamp it onto every - // response (including the short-circuited 4xx from the layers - // above) so the whole proxy family carries `x-aisix-request-id` - // and it equals the telemetry request_id. See request_id.rs. - .layer(middleware::from_fn(request_id::ensure_request_id)) - .with_state(state) + .with_state(state.clone()); + + // Pre-routing URL rewriting (`proxy.url_rewrites`). `Router::layer` + // middleware runs AFTER route matching, so a URI rewritten there could + // never change which route matches. Wrapping the whole router as the + // fallback of an outer router — whose "routing" trivially resolves to + // that fallback — gives the rewrite layer a genuine pre-routing seat: + // it mutates the URI, then the inner router matches on the rewritten + // path. Built only when rules are configured, so the default path pays + // nothing. See rewrite.rs. + let router = if state.url_rewrites.is_empty() { + router + } else { + Router::new() + .fallback_service(router) + .layer(middleware::from_fn_with_state( + state, + rewrite::rewrite_request_uri, + )) + }; + + // Outermost: mint the request id into the request extensions + // before any handler/extractor runs — including the rewrite layer, + // whose fired/failed log lines must carry the request span — and + // stamp it onto every response (including the short-circuited 4xx + // from the layers above) so the whole proxy family carries + // `x-aisix-request-id` and it equals the telemetry request_id. See + // request_id.rs. + router.layer(middleware::from_fn(request_id::ensure_request_id)) } async fn record_in_flight_request( @@ -651,6 +674,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } @@ -1624,6 +1648,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: limit, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, }; ProxyState::new(handle, hub, &cfg).without_cache() diff --git a/crates/aisix-proxy/src/mcp.rs b/crates/aisix-proxy/src/mcp.rs index 460117363..6c1a7a9de 100644 --- a/crates/aisix-proxy/src/mcp.rs +++ b/crates/aisix-proxy/src/mcp.rs @@ -546,6 +546,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/messages.rs b/crates/aisix-proxy/src/messages.rs index b6caeee54..f9b73e58b 100644 --- a/crates/aisix-proxy/src/messages.rs +++ b/crates/aisix-proxy/src/messages.rs @@ -3578,6 +3578,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/models.rs b/crates/aisix-proxy/src/models.rs index 4583add07..d1868aad4 100644 --- a/crates/aisix-proxy/src/models.rs +++ b/crates/aisix-proxy/src/models.rs @@ -119,6 +119,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/passthrough.rs b/crates/aisix-proxy/src/passthrough.rs index 805449fed..1e8221374 100644 --- a/crates/aisix-proxy/src/passthrough.rs +++ b/crates/aisix-proxy/src/passthrough.rs @@ -894,6 +894,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/realtime.rs b/crates/aisix-proxy/src/realtime.rs index 3a975feb5..1c20953a6 100644 --- a/crates/aisix-proxy/src/realtime.rs +++ b/crates/aisix-proxy/src/realtime.rs @@ -799,6 +799,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/rerank.rs b/crates/aisix-proxy/src/rerank.rs index 4be1aef96..3c4128e1e 100644 --- a/crates/aisix-proxy/src/rerank.rs +++ b/crates/aisix-proxy/src/rerank.rs @@ -735,6 +735,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/responses.rs b/crates/aisix-proxy/src/responses.rs index be9543e6e..d0379151a 100644 --- a/crates/aisix-proxy/src/responses.rs +++ b/crates/aisix-proxy/src/responses.rs @@ -3065,6 +3065,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/crates/aisix-proxy/src/rewrite.rs b/crates/aisix-proxy/src/rewrite.rs new file mode 100644 index 000000000..d9cfbb446 --- /dev/null +++ b/crates/aisix-proxy/src/rewrite.rs @@ -0,0 +1,255 @@ +//! Entry-level URL rewriting (`proxy.url_rewrites`). +//! +//! Applied to every proxy-listener request **before** route matching (the +//! admin and metrics listeners never see this layer): the first rule whose +//! `match` regex matches the request path rewrites it — once, no cascading — +//! and the request then flows through the normal endpoint (auth, ACL, quota, +//! metrics labelling) as if the client had sent the rewritten path. A miss +//! leaves the request untouched. +//! +//! Replacement substitutes the **matched portion** of the path, with +//! `$1`/`${name}` expanding capture groups; the query string is preserved as +//! sent. Patterns are matched against the **raw, percent-encoded** request +//! path — no decoding, no normalization — unlike gateways that match a +//! decoded `$uri`. Rules were validated at config load (`Config::validate`: +//! regex syntax, template group references, forbidden template characters), +//! so compiling them here cannot fail on operator input. + +use std::borrow::Cow; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http; +use axum::middleware::Next; +use axum::response::Response; +use regex::Regex; + +use aisix_core::UrlRewriteRule; + +use crate::state::ProxyState; + +/// One boot-compiled rewrite rule. +pub struct CompiledRewrite { + name: Option, + pattern: Regex, + replacement: String, +} + +impl CompiledRewrite { + /// The rewritten path, or `None` when the rule does not match. + fn apply(&self, path: &str) -> Option { + match self.pattern.replace(path, self.replacement.as_str()) { + // `replace` hands the haystack back untouched on a miss. + Cow::Borrowed(_) => None, + Cow::Owned(rewritten) => Some(rewritten), + } + } + + fn label(&self) -> &str { + self.name.as_deref().unwrap_or("") + } +} + +/// Compile the configured rules in declaration order. +/// +/// Invariant: every pattern was syntax-checked by `Config::validate` at +/// load, so a panic here means a caller constructed a `ProxyConfig` with an +/// unvalidated pattern. +pub fn compile(rules: &[UrlRewriteRule]) -> Arc<[CompiledRewrite]> { + rules + .iter() + .map(|rule| CompiledRewrite { + name: rule.name.clone(), + pattern: Regex::new(&rule.pattern) + .expect("proxy.url_rewrites pattern is validated at config load"), + replacement: rule.replacement.clone(), + }) + .collect() +} + +/// Middleware: apply the first matching rewrite rule to the request path. +pub async fn rewrite_request_uri( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + let fired = state.url_rewrites.iter().find_map(|rule| { + let path = request.uri().path(); + rule.apply(path).map(|to| (rule, path.to_owned(), to)) + }); + if let Some((rule, from, to)) = fired { + match with_path(request.uri(), &to) { + Ok(uri) => { + tracing::debug!(rule = rule.label(), %from, %to, "url rewrite applied"); + *request.uri_mut() = uri; + } + Err(error) => { + // A template can assemble an invalid path out of matched + // input (e.g. an empty string). Serving the original path is + // the conservative outcome: the request 404s the same way it + // would have without the layer, and the warn names the rule. + tracing::warn!( + rule = rule.label(), + %from, + rewritten = %to, + %error, + "url rewrite produced an invalid path; leaving the request unrewritten" + ); + } + } + } + next.run(request).await +} + +/// `uri` with its path replaced by `new_path`, query preserved. +fn with_path(uri: &http::Uri, new_path: &str) -> Result { + let path_and_query = match uri.query() { + Some(query) => format!("{new_path}?{query}"), + None => new_path.to_owned(), + }; + let mut parts = uri.clone().into_parts(); + parts.path_and_query = Some(path_and_query.parse()?); + Ok(http::Uri::from_parts(parts)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(pattern: &str, replacement: &str) -> UrlRewriteRule { + UrlRewriteRule { + name: None, + pattern: pattern.to_string(), + replacement: replacement.to_string(), + } + } + + #[test] + fn apply_substitutes_capture_groups() { + let compiled = compile(&[rule("^/mcp-servers/([^/]+)/mcp$", "/mcp/$1")]); + assert_eq!( + compiled[0].apply("/mcp-servers/github/mcp").as_deref(), + Some("/mcp/github") + ); + assert_eq!(compiled[0].apply("/mcp-servers/github/sse"), None); + assert_eq!(compiled[0].apply("/v1/chat/completions"), None); + } + + #[test] + fn apply_replaces_only_the_matched_portion() { + // Unanchored pattern: the unmatched prefix survives, mirroring the + // replace-matched-portion semantics of mainstream gateways. + let compiled = compile(&[rule("/legacy$", "/current")]); + assert_eq!( + compiled[0].apply("/api/legacy").as_deref(), + Some("/api/current") + ); + } + + #[test] + fn apply_supports_named_groups_and_braced_references() { + let compiled = compile(&[rule( + "^/gw/(?P[^/]+)/v(\\d+)$", + "/mcp/${server}-v${2}", + )]); + assert_eq!( + compiled[0].apply("/gw/github/v2").as_deref(), + Some("/mcp/github-v2") + ); + } + + #[test] + fn with_path_preserves_the_query_string() { + let uri: http::Uri = "http://gw.example/mcp-servers/github/mcp?probe=1" + .parse() + .unwrap(); + let rewritten = with_path(&uri, "/mcp/github").unwrap(); + assert_eq!(rewritten.path(), "/mcp/github"); + assert_eq!(rewritten.query(), Some("probe=1")); + assert_eq!(rewritten.host(), Some("gw.example")); + } + + #[test] + fn with_path_rejects_an_invalid_path() { + let uri: http::Uri = "/x".parse().unwrap(); + // A template can assemble characters that are invalid in a request + // path; the middleware then keeps the original URI (warn + no-op). + assert!(with_path(&uri, "/a b").is_err()); + } + + fn router_with_rules(rules: Vec) -> axum::Router { + use aisix_core::snapshot::SnapshotHandle; + let cfg = aisix_core::ProxyConfig { + addr: "127.0.0.1:0".into(), + request_body_limit_bytes: 0, + tls: None, + real_ip: Default::default(), + url_rewrites: rules, + }; + let state = ProxyState::new( + SnapshotHandle::new(aisix_core::AisixSnapshot::new()), + Arc::new(aisix_gateway::Hub::new()), + &cfg, + ) + .without_cache(); + crate::build_router(state) + } + + async fn get(router: axum::Router, path: &str) -> axum::http::StatusCode { + use tower::ServiceExt; + let request = axum::http::Request::get(path) + .body(axum::body::Body::empty()) + .unwrap(); + router.oneshot(request).await.unwrap().status() + } + + #[tokio::test] + async fn router_serves_a_rewritten_legacy_path_and_first_rule_wins() { + // Two rules match the legacy path; the first rewrites onto a real + // endpoint, the second onto a 404. Declaration order must win. + let router = router_with_rules(vec![ + rule("^/legacy/health$", "/livez"), + rule("^/legacy/health$", "/nonexistent"), + ]); + assert_eq!(get(router.clone(), "/legacy/health").await, 200); + // A miss flows through unrewritten. + assert_eq!(get(router.clone(), "/legacy/other").await, 404); + // The canonical path keeps working alongside the legacy one. + assert_eq!(get(router, "/livez").await, 200); + } + + #[tokio::test] + async fn router_without_rules_is_untouched() { + let router = router_with_rules(Vec::new()); + assert_eq!(get(router.clone(), "/livez").await, 200); + assert_eq!(get(router, "/legacy/health").await, 404); + } + + #[tokio::test] + async fn runtime_invalid_rewrite_leaves_the_request_unrewritten() { + // Bypasses Config::validate on purpose (ProxyState::new takes the + // config directly): a template that assembles an invalid path must + // warn and serve the ORIGINAL path — here /livez still routes. + let router = router_with_rules(vec![rule("^/livez$", "/livez x")]); + assert_eq!(get(router, "/livez").await, 200); + } + + #[tokio::test] + async fn router_matches_the_raw_percent_encoded_path() { + // No decoding, no normalization: an encoded variant of a matching + // path does not match and passes through to a 404. + let router = router_with_rules(vec![rule("^/legacy/health$", "/livez")]); + assert_eq!(get(router.clone(), "/legacy/health").await, 200); + assert_eq!(get(router.clone(), "/legacy%2Fhealth").await, 404); + assert_eq!(get(router, "/legac%79/health").await, 404); + } + + #[test] + fn apply_replaces_only_the_first_occurrence() { + let compiled = compile(&[rule("/legacy", "/cur")]); + assert_eq!( + compiled[0].apply("/legacy/legacy").as_deref(), + Some("/cur/legacy") + ); + } +} diff --git a/crates/aisix-proxy/src/state.rs b/crates/aisix-proxy/src/state.rs index e278573ab..1f7b8f10f 100644 --- a/crates/aisix-proxy/src/state.rs +++ b/crates/aisix-proxy/src/state.rs @@ -145,6 +145,9 @@ pub struct ProxyStateInner { /// client IP on each request (#492). Default = trust nothing → the /// logged source IP is the immediate TCP peer. pub real_ip: Arc, + /// Boot-compiled `proxy.url_rewrites` rules, applied in order to every + /// request before routing (first match wins). Empty = layer no-ops. + pub url_rewrites: Arc<[crate::rewrite::CompiledRewrite]>, /// Optional config-freshness probe for `GET /readyz`: returns the time /// since the etcd watch last applied config (`None` = never applied). /// Wired from the watch supervisor in aisix-server; `None` here means @@ -208,6 +211,7 @@ impl ProxyState { otlp_fan_out: OtlpHttpFanOut::new(), request_body_limit_bytes: cfg.request_body_limit_bytes, real_ip: Arc::new(ResolvedRealIp::from_config(&cfg.real_ip)), + url_rewrites: crate::rewrite::compile(&cfg.url_rewrites), billed_batches: Arc::new(dashmap::DashSet::new()), client_classifier: Arc::new(ClientTypeClassifier::builtin()), default_retries: aisix_core::config::DEFAULT_UPSTREAM_RETRIES, @@ -244,6 +248,7 @@ impl ProxyState { otlp_fan_out: OtlpHttpFanOut::new(), request_body_limit_bytes: cfg.request_body_limit_bytes, real_ip: Arc::new(ResolvedRealIp::from_config(&cfg.real_ip)), + url_rewrites: crate::rewrite::compile(&cfg.url_rewrites), billed_batches: Arc::new(dashmap::DashSet::new()), client_classifier: Arc::new(ClientTypeClassifier::builtin()), default_retries: aisix_core::config::DEFAULT_UPSTREAM_RETRIES, @@ -290,6 +295,7 @@ impl ProxyState { otlp_fan_out: OtlpHttpFanOut::new(), request_body_limit_bytes: cfg.request_body_limit_bytes, real_ip: Arc::new(ResolvedRealIp::from_config(&cfg.real_ip)), + url_rewrites: crate::rewrite::compile(&cfg.url_rewrites), billed_batches: Arc::new(dashmap::DashSet::new()), client_classifier: Arc::new(ClientTypeClassifier::builtin()), default_retries: aisix_core::config::DEFAULT_UPSTREAM_RETRIES, @@ -388,6 +394,7 @@ mod tests { request_body_limit_bytes: 1_048_576, tls: None, real_ip: Default::default(), + url_rewrites: Vec::new(), }, ) } diff --git a/crates/aisix-proxy/src/videos.rs b/crates/aisix-proxy/src/videos.rs index ecb0968b2..a16339de8 100644 --- a/crates/aisix-proxy/src/videos.rs +++ b/crates/aisix-proxy/src/videos.rs @@ -2327,6 +2327,7 @@ mod tests { addr: "127.0.0.1:0".into(), request_body_limit_bytes: 1_048_576, real_ip: Default::default(), + url_rewrites: Vec::new(), tls: None, } } diff --git a/tests/e2e/src/cases/url-rewrite-e2e.test.ts b/tests/e2e/src/cases/url-rewrite-e2e.test.ts new file mode 100644 index 000000000..d46970e14 --- /dev/null +++ b/tests/e2e/src/cases/url-rewrite-e2e.test.ts @@ -0,0 +1,221 @@ +import { createHash, randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + startMcpUpstream, + waitConfigPropagation, + type McpUpstream, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: entry-level URL rewriting (`proxy.url_rewrites`) against a real +// gateway + etcd + a real MCP upstream. +// +// Pinned contract: +// - the first rule whose `match` regex matches the path rewrites it; the +// request then flows through the normal endpoint (auth, ACL, quota) as +// if the client had sent the rewritten path; +// - the flagship scenario: a client keeping its legacy per-server MCP URL +// (`/mcp-servers/{service}/mcp`) and ORIGINAL tool names works end to +// end through the rewritten `/mcp/{server}` endpoint; +// - rewriting is generic, not MCP-specific (any path → any endpoint); +// - a miss leaves the request untouched (canonical paths keep working, +// unmatched legacy shapes 404); +// - the query string survives the rewrite. + +const KEY = "sk-url-rewrite-e2e"; +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +interface RpcReply { + status: number; + json?: { + result?: { + serverInfo?: { name?: string }; + tools?: Array<{ name: string }>; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; + }; + error?: { code: number; message: string }; + }; +} + +describe("url rewrite e2e: proxy.url_rewrites", () => { + let app: SpawnedApp | undefined; + let alpha: McpUpstream | undefined; + let etcdReachable = false; + + const post = async ( + path: string, + body: unknown, + ): Promise => { + const res = await fetch(`${app!.proxyUrl}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${KEY}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let json: RpcReply["json"]; + try { + json = text ? JSON.parse(text) : undefined; + } catch { + json = undefined; + } + return { status: res.status, json }; + }; + + const initialize = async ( + path: string, + ): Promise<{ status: number; serverName?: string }> => { + const init = await post(path, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "url-rewrite-e2e", version: "0.1" }, + }, + }); + if (init.status !== 200) return { status: init.status }; + await post(path, { jsonrpc: "2.0", method: "notifications/initialized" }); + return { + status: init.status, + serverName: init.json?.result?.serverInfo?.name, + }; + }; + + const listToolNames = async ( + path: string, + ): Promise<{ status: number; names?: string[] }> => { + const { status } = await initialize(path); + if (status !== 200) return { status }; + const r = await post(path, { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {}, + }); + const tools = r.json?.result?.tools; + if (r.status !== 200 || !tools) return { status: r.status }; + return { status: r.status, names: tools.map((t) => t.name).sort() }; + }; + + const callTool = async ( + path: string, + name: string, + text: string, + ): Promise<{ ok: boolean; text?: string; error?: string }> => { + await initialize(path); + const r = await post(path, { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name, arguments: { text } }, + }); + if (r.json?.error) return { ok: false, error: r.json.error.message }; + const result = r.json?.result; + if (!result || result.isError) { + return { ok: false, error: JSON.stringify(r.json ?? r.status) }; + } + return { ok: true, text: result.content?.[0]?.text }; + }; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + + alpha = await startMcpUpstream("alpha"); + app = await spawnApp({ + urlRewrites: [ + { + name: "per-server-mcp-compat", + match: "^/mcp-servers/([^/]+)/mcp$", + rewrite: "/mcp/$1", + }, + // Generic, non-MCP mapping: proves the layer rewrites any path + // onto any endpoint. + { match: "^/compat/health$", rewrite: "/livez" }, + ], + }); + const seed = new SeedClient(new EtcdClient(), app.etcdPrefix); + + await seed.update("mcp_servers", randomUUID(), { + display_name: "alpha", + url: alpha.url, + enabled: true, + }); + await seed.createApiKey({ + key_hash: sha256(KEY), + allowed_models: [], + allowed_tools: ["*"], + }); + + await waitConfigPropagation(async () => { + const listed = await listToolNames("/mcp-servers/alpha/mcp"); + return ( + listed.status === 200 && + JSON.stringify(listed.names) === JSON.stringify(["echo", "reverse"]) + ); + }); + }, 60_000); + + afterAll(async () => { + await app?.exit(); + await alpha?.close(); + }); + + test("legacy per-server MCP URL + original tool names work end to end", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // The migration scenario in one flow: the client keeps its legacy URL + // and its original tool name; the rewrite maps the URL onto + // /mcp/{server}, which serves original names. + const init = await initialize("/mcp-servers/alpha/mcp"); + expect(init.status).toBe(200); + expect(init.serverName).toBe("alpha"); + + const listed = await listToolNames("/mcp-servers/alpha/mcp"); + expect(listed.names).toEqual(["echo", "reverse"]); + + const called = await callTool("/mcp-servers/alpha/mcp", "echo", "hi"); + expect(called).toEqual({ ok: true, text: "alpha:hi" }); + }); + + test("rewriting is generic: any path maps onto any endpoint", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + const res = await fetch(`${app.proxyUrl}/compat/health`); + expect(res.status).toBe(200); + + // A request carrying a query string still matches and routes (`match` + // never sees the query). Preservation of the query itself is pinned by + // the `with_path_preserves_the_query_string` unit test — no proxy + // endpoint echoes its query back for an e2e-level assertion. + const withQuery = await fetch(`${app.proxyUrl}/compat/health?probe=1`); + expect(withQuery.status).toBe(200); + }); + + test("a miss leaves the request untouched", async (ctx) => { + if (!etcdReachable || !app) return ctx.skip(); + + // Canonical paths keep working alongside the legacy shapes… + const canonical = await initialize("/mcp/alpha"); + expect(canonical.status).toBe(200); + const livez = await fetch(`${app.proxyUrl}/livez`); + expect(livez.status).toBe(200); + + // …and a legacy shape the rule does not match is NOT silently routed + // anywhere (`backend_path` other than /mcp stays unserved). + const unmatchedTail = await post("/mcp-servers/alpha/sse", {}); + expect(unmatchedTail.status).toBe(404); + const unknownShape = await fetch(`${app.proxyUrl}/compat/other`); + expect(unknownShape.status).toBe(404); + }); +}); diff --git a/tests/e2e/src/harness/app.ts b/tests/e2e/src/harness/app.ts index d5bc9ea88..e41510f26 100644 --- a/tests/e2e/src/harness/app.ts +++ b/tests/e2e/src/harness/app.ts @@ -42,6 +42,13 @@ export interface AppOverrides { recursive?: boolean; header?: string; }; + /** + * `proxy.url_rewrites` block. Merged into the base proxy config (like + * `realIp`) so the listener addr is preserved. Entry-level path + * rewriting: first matching rule wins, `rewrite` replaces the matched + * portion of the path. + */ + urlRewrites?: Array<{ name?: string; match: string; rewrite: string }>; /** * `proxy.request_body_limit_bytes`. A dedicated override (like * `realIp`) because `extra` replaces whole top-level blocks and the @@ -211,6 +218,7 @@ async function spawnAppOnce(overrides: AppOverrides = {}): Promise { addr: `127.0.0.1:${proxyPort}`, request_body_limit_bytes: overrides.requestBodyLimitBytes ?? 10485760, ...(overrides.realIp ? { real_ip: overrides.realIp } : {}), + ...(overrides.urlRewrites ? { url_rewrites: overrides.urlRewrites } : {}), }, admin: adminEnabled ? { addr: `127.0.0.1:${adminPort}`, admin_keys: [adminKey] }