fix(gateway): restore HTTP and WS upstream support

This commit is contained in:
elky
2026-09-07 22:15:05 +08:00
parent 7847ae98c6
commit 7aa0c89244
5 changed files with 105 additions and 49 deletions
@@ -21,10 +21,7 @@ use aether_contracts::{
TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
};
use aether_data::repository::proxy_nodes::ProxyNodeTrafficMutation;
use aether_http::{
apply_http_client_config, is_https_or_loopback_http_url, is_private_or_reserved_ip,
HttpClientConfig,
};
use aether_http::{apply_http_client_config, is_private_or_reserved_ip, HttpClientConfig};
use aether_runtime::{MetricKind, MetricSample};
use axum::body::Bytes;
use base64::Engine as _;
@@ -5173,11 +5170,6 @@ fn validate_execution_upstream_url(
"upstream URL must not include a fragment".to_string(),
));
}
if !is_https_or_loopback_http_url(&url) {
return Err(ExecutionRuntimeTransportError::UpstreamRequest(
"remote upstream URL must use HTTPS".to_string(),
));
}
let literal_ip = match url.host() {
Some(url::Host::Ipv4(address)) => Some(IpAddr::V4(address)),
Some(url::Host::Ipv6(address)) => Some(IpAddr::V6(address)),
@@ -5389,9 +5381,13 @@ mod tests {
const RELAY_TEST_SECRET: &str = "relay-test-secret-at-least-32-bytes";
#[test]
fn execution_upstream_url_requires_https_or_literal_loopback_http() {
fn execution_upstream_url_accepts_http_and_https_with_safe_targets() {
for allowed in [
"https://api.example.test/v1/responses?api-version=1",
"http://api.example.test:8080/v1/responses?api-version=1",
"http://8.8.8.8:8080/v1/responses",
"https://8.8.8.8/v1/responses",
"http://[2606:4700:4700::1111]:8080/v1/responses",
"http://localhost:8080/v1/responses",
"http://127.42.0.1:8080/v1/responses",
"http://[::1]:8080/v1/responses",
@@ -5403,7 +5399,6 @@ mod tests {
}
for rejected in [
"http://api.example.test/v1/responses",
"http://10.0.0.1/v1/responses",
"http://0.0.0.0:8080/v1/responses",
"http://[::ffff:127.0.0.1]:8080/v1/responses",
@@ -5411,6 +5406,8 @@ mod tests {
"https://10.0.0.1:8443/v1/responses",
"https://[email protected]/v1/responses",
"https://example.test/v1/responses#secret",
"http://[email protected]/v1/responses",
"http://example.test/v1/responses#secret",
"ftp://localhost/resource",
] {
assert!(
@@ -157,16 +157,22 @@ pub(crate) fn websocket_upstream_url(
return Err(invalid_code);
}
let websocket_scheme = match url.scheme() {
"https" => "wss",
"http" => "ws",
"wss" => return Ok(url),
"ws" if aether_http::url_has_literal_loopback_host(&url) => return Ok(url),
"ws" => return Err(invalid_code),
"https" | "wss" => "wss",
"http" | "ws" => "ws",
_ => return Err(invalid_code),
};
url.set_scheme(websocket_scheme).map_err(|_| invalid_code)?;
if url.scheme() == "ws" && !aether_http::url_has_literal_loopback_host(&url) {
return Err(invalid_code);
if url.scheme() == "ws" {
let literal_ip = match url.host() {
Some(url::Host::Ipv4(address)) => Some(std::net::IpAddr::V4(address)),
Some(url::Host::Ipv6(address)) => Some(std::net::IpAddr::V6(address)),
_ => None,
};
if literal_ip.is_some_and(|address| {
aether_http::is_private_or_reserved_ip(address) && !address.is_loopback()
}) {
return Err(invalid_code);
}
}
Ok(url)
}
@@ -844,15 +850,17 @@ mod tests {
#[test]
fn maps_http_url_to_websocket_url_without_losing_path_or_query() {
let url = websocket_upstream_url(
"https://example.test/backend-api/codex/responses?x=1",
"invalid",
)
.expect("URL should be converted");
assert_eq!(
url.as_str(),
"wss://example.test/backend-api/codex/responses?x=1"
);
for (http_scheme, websocket_scheme) in [("https", "wss"), ("http", "ws")] {
let url = websocket_upstream_url(
&format!("{http_scheme}://example.test:8080/backend-api/codex/responses?x=1"),
"invalid",
)
.expect("URL should be converted");
assert_eq!(
url.as_str(),
format!("{websocket_scheme}://example.test:8080/backend-api/codex/responses?x=1")
);
}
}
#[test]
@@ -861,10 +869,14 @@ mod tests {
}
#[test]
fn remote_websocket_requires_wss_but_loopback_ws_is_allowed() {
fn websocket_upstream_url_accepts_ws_and_wss_with_safe_targets() {
for allowed in [
"wss://example.test/v1/responses",
"https://example.test/v1/responses",
"ws://example.test:8080/v1/responses",
"http://example.test:8080/v1/responses",
"http://8.8.8.8:8080/v1/responses",
"ws://[2606:4700:4700::1111]:8080/v1/responses",
"ws://localhost:8080/v1/responses",
"http://127.42.0.1:8080/v1/responses",
"ws://[::1]:8080/v1/responses",
@@ -875,11 +887,14 @@ mod tests {
);
}
for rejected in [
"ws://example.test/v1/responses",
"http://10.0.0.1/v1/responses",
"ws://0.0.0.0:8080/v1/responses",
"ws://[::ffff:127.0.0.1]:8080/v1/responses",
"wss://example.test/v1/responses#secret",
"ws://example.test/v1/responses#secret",
"http://[email protected]/v1/responses",
"ws://[email protected]/v1/responses",
"ftp://example.test/v1/responses",
] {
assert!(
websocket_upstream_url(rejected, "invalid").is_err(),
@@ -129,9 +129,6 @@ pub(crate) fn normalize_admin_base_url(base_url: &str) -> Result<String, String>
if parsed.host_str().is_none() {
return Err("base_url 必须包含有效主机".to_string());
}
if !aether_http::is_https_or_loopback_http_url(&parsed) {
return Err("base_url 必须使用 HTTPS;HTTP 仅允许字面量 loopback 主机".to_string());
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err("base_url 不允许包含用户名或密码".to_string());
}
@@ -154,16 +151,42 @@ mod normalize_admin_base_url_tests {
"https://user:[email protected]/v1",
"https://api.example.test/v1?key=secret",
"https://api.example.test/v1#secret",
"http://api.example.test/v1",
"http://10.0.0.1/v1",
"http://[::ffff:127.0.0.1]/v1",
"http://user:password@api.example.test/v1",
"http://api.example.test/v1?key=secret",
"http://api.example.test/v1#secret",
"ftp://api.example.test/v1",
"file:///v1",
"api.example.test/v1",
"",
"https://",
"http://",
"https://api.example.test:invalid/v1",
] {
assert!(normalize_admin_base_url(value).is_err(), "accepted {value}");
}
}
#[test]
fn endpoint_base_url_accepts_remote_http_hosts() {
for (raw_url, expected) in [
(
" HTTP://API.EXAMPLE.TEST:8080/v1/ ",
"http://api.example.test:8080/v1",
),
("http://8.8.8.8:8080/v1/", "http://8.8.8.8:8080/v1"),
("http://10.0.0.1:8080/v1/", "http://10.0.0.1:8080/v1"),
(
"http://[2606:4700:4700::1111]:8080/v1/",
"http://[2606:4700:4700::1111]:8080/v1",
),
] {
assert_eq!(
normalize_admin_base_url(raw_url).expect("HTTP base URL should be accepted"),
expected,
);
}
}
#[test]
fn endpoint_base_url_is_parsed_and_normalized() {
assert_eq!(
@@ -53,9 +53,6 @@ async fn resolve_test_connection_target(
{
return Err("provider endpoint must be an HTTP(S) URL without credentials or fragment");
}
if url.scheme() == "http" && !(allow_private_targets && literal_loopback) {
return Err("provider endpoint must use HTTPS");
}
let host = url
.host_str()
.ok_or("provider endpoint is missing a host")?
@@ -574,10 +571,11 @@ mod tests {
async fn test_connection_target_rejects_private_addresses_in_production_mode() {
for raw_url in [
"http://127.0.0.1:8080/v1/chat/completions",
"http://10.0.0.1/v1/chat/completions",
"http://169.254.169.254/v1/chat/completions",
"https://10.0.0.1/v1/chat/completions",
"https://[::1]/v1/chat/completions",
"https://localhost/v1/chat/completions",
"http://8.8.8.8/v1/chat/completions",
] {
assert!(
resolve_test_connection_target(raw_url, false)
@@ -588,6 +586,26 @@ mod tests {
}
}
#[tokio::test]
async fn test_connection_target_accepts_public_http_and_https_addresses() {
for allow_private_targets in [false, true] {
for (raw_url, expected_port) in [
("http://8.8.8.8/v1/chat", 80),
("http://8.8.8.8:8080/v1/chat", 8080),
("https://8.8.8.8/v1/chat", 443),
] {
let target = resolve_test_connection_target(raw_url, allow_private_targets)
.await
.expect("public HTTP(S) provider target should resolve");
assert_eq!(target.url.as_str(), raw_url);
assert_eq!(target.host, "8.8.8.8");
assert_eq!(target.addresses.len(), 1);
assert_eq!(target.addresses[0].ip().to_string(), "8.8.8.8");
assert_eq!(target.addresses[0].port(), expected_port);
}
}
}
#[tokio::test]
async fn test_connection_target_allows_loopback_only_for_test_fixtures() {
let target = resolve_test_connection_target("http://127.0.0.1:8080/v1/chat", true)
@@ -596,10 +614,10 @@ mod tests {
assert_eq!(target.host, "127.0.0.1");
assert_eq!(target.addresses.len(), 1);
assert!(
resolve_test_connection_target("http://8.8.8.8/v1/chat", true)
resolve_test_connection_target("http://10.0.0.1/v1/chat", true)
.await
.is_err(),
"test mode must not make cleartext public endpoints acceptable"
"test mode must not make private non-loopback HTTP endpoints acceptable"
);
assert!(
resolve_test_connection_target("https://10.0.0.1/v1/chat", true)
@@ -620,6 +638,9 @@ mod tests {
for raw_url in [
"https://user:[email protected]/v1/chat",
"https://example.com/v1/chat#fragment",
"http://user:[email protected]/v1/chat",
"http://example.com/v1/chat#fragment",
"ftp://example.com/v1/chat",
] {
assert!(
resolve_test_connection_target(raw_url, false)
@@ -479,7 +479,7 @@ async fn gateway_returns_service_unavailable_for_admin_provider_endpoint_create_
}
#[tokio::test]
async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_principal() {
async fn gateway_creates_admin_http_provider_endpoint_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
@@ -522,7 +522,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin
.json(&json!({
"provider_id": "provider-openai",
"api_format": "openai:chat",
"base_url": "https://api.openai.example/",
"base_url": "http://api.openai.example:8080/",
"custom_path": "/v1/chat/completions",
"max_retries": 5,
"config": {"foo": "bar"},
@@ -537,7 +537,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin
assert_eq!(payload["provider_id"], "provider-openai");
assert_eq!(payload["provider_name"], "openai");
assert_eq!(payload["api_format"], "openai:chat");
assert_eq!(payload["base_url"], "https://api.openai.example");
assert_eq!(payload["base_url"], "http://api.openai.example:8080");
assert_eq!(payload["custom_path"], "/v1/chat/completions");
assert_eq!(payload["max_retries"], 5);
assert_eq!(payload["total_keys"], 0);
@@ -553,7 +553,7 @@ async fn gateway_creates_admin_provider_endpoint_locally_with_trusted_admin_prin
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].provider_id, "provider-openai");
assert_eq!(endpoints[0].api_format, "openai:chat");
assert_eq!(endpoints[0].base_url, "https://api.openai.example");
assert_eq!(endpoints[0].base_url, "http://api.openai.example:8080");
assert_eq!(endpoints[0].max_retries, Some(5));
gateway_handle.abort();
@@ -658,7 +658,7 @@ async fn gateway_rejects_streaming_policy_for_search_endpoint_before_catalog_wri
}
#[tokio::test]
async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_principal() {
async fn gateway_updates_admin_http_provider_endpoint_locally_with_trusted_admin_principal() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
@@ -720,7 +720,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&json!({
"base_url": "https://updated.openai.example/",
"base_url": "http://updated.openai.example:8080/",
"custom_path": "/v1/responses",
"max_retries": 5,
"is_active": false,
@@ -736,7 +736,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin
assert_eq!(payload["id"], "endpoint-openai-chat");
assert_eq!(payload["provider_id"], "provider-openai");
assert_eq!(payload["api_format"], "openai:chat");
assert_eq!(payload["base_url"], "https://updated.openai.example");
assert_eq!(payload["base_url"], "http://updated.openai.example:8080");
assert_eq!(payload["custom_path"], "/v1/responses");
assert_eq!(payload["max_retries"], 5);
assert_eq!(payload["is_active"], false);
@@ -751,7 +751,7 @@ async fn gateway_updates_admin_provider_endpoint_locally_with_trusted_admin_prin
.await
.expect("endpoints should read");
assert_eq!(endpoints.len(), 1);
assert_eq!(endpoints[0].base_url, "https://updated.openai.example");
assert_eq!(endpoints[0].base_url, "http://updated.openai.example:8080");
assert_eq!(endpoints[0].custom_path.as_deref(), Some("/v1/responses"));
assert_eq!(endpoints[0].max_retries, Some(5));
assert!(!endpoints[0].is_active);