diff --git a/Cargo.lock b/Cargo.lock index b89e4d40..b6fec76c 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 c66ca767..5f5b1c6b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -51,6 +51,18 @@ 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. `rewrite` replaces the matched portion of the path + # ($1/${name} expand capture groups); the query string is preserved. An + # invalid regex fails startup. 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 c3cf4deb..5e35e877 100644 --- a/config.managed.yaml +++ b/config.managed.yaml @@ -44,6 +44,14 @@ 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; `rewrite` + # replaces the matched portion of the path, $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. + # 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 cc0e7043..3477f6a8 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 717095b9..d513a2c1 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 a4a97315..27a28fa8 100644 --- a/crates/aisix-core/src/config.rs +++ b/crates/aisix-core/src/config.rs @@ -393,6 +393,16 @@ 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. + #[serde(default)] + pub url_rewrites: Vec, } impl ProxyConfig { @@ -401,6 +411,26 @@ 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 request path (never the query string). + /// Anchor with `^`/`$` to match the whole path; an unanchored pattern + /// matches anywhere in it. + #[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. The query string is preserved + /// as sent. + #[serde(rename = "rewrite")] + pub replacement: String, +} + /// 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 +1124,20 @@ impl Config { } fn validate(&self) -> Result<(), BootstrapError> { + // Fail fast on a rewrite rule that can never compile: it would + // otherwise surface as every legacy-path request 404ing, which is + // much harder to trace back to a typo in one regex. + for (i, rule) in self.proxy.url_rewrites.iter().enumerate() { + if let Err(e) = regex::Regex::new(&rule.pattern) { + let ctx = rule + .name + .clone() + .unwrap_or_else(|| format!("proxy.url_rewrites[{i}]")); + return Err(BootstrapError::Config(format!( + "{ctx}: invalid match regex: {e}" + ))); + } + } 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 +1350,50 @@ 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 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 a379c6b8..bc3fda26 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 946a22a3..8211ca81 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 e722b36a..3f95aaa3 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 bcc6fb34..d18aa701 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 5ba0fa18..2b788835 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 ea0f8bf8..28bfc110 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 738e4c82..612a6f69 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 5019831e..5ec13b6b 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 7519b399..4e215ecd 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 c683c9e9..dbdff111 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,32 @@ pub fn build_router(state: ProxyState) -> Router { header::SERVER, SERVER_HEADER_VALUE.clone(), )) + // Rewrite the request path per `proxy.url_rewrites` before route // 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()); + + if state.url_rewrites.is_empty() { + return router; + } + // 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. + Router::new() + .fallback_service(router) + .layer(middleware::from_fn_with_state( + state, + rewrite::rewrite_request_uri, + )) } async fn record_in_flight_request( @@ -651,6 +671,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 +1645,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 46011736..6c1a7a9d 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 b6caeee5..f9b73e58 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 4583add0..d1868aad 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 805449fe..1e822137 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 3a975feb..1c20953a 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 4be1aef9..3c4128e1 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 be9543e6..d0379151 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 00000000..462eccee --- /dev/null +++ b/crates/aisix-proxy/src/rewrite.rs @@ -0,0 +1,227 @@ +//! 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. Rules were syntax-checked at config load (`Config::validate`), 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 { + if state.url_rewrites.is_empty() { + return next.run(request).await; + } + 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); + } +} diff --git a/crates/aisix-proxy/src/state.rs b/crates/aisix-proxy/src/state.rs index e278573a..1f7b8f10 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 ecb0968b..a16339de 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 00000000..874790fa --- /dev/null +++ b/tests/e2e/src/cases/url-rewrite-e2e.test.ts @@ -0,0 +1,219 @@ +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); + + // The query string survives the rewrite (the endpoint ignores it, but + // a broken rewrite would 404 or drop it before routing). + 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 d5bc9ea8..e41510f2 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] }