diff --git a/apps/aether-gateway/src/execution_runtime/transport.rs b/apps/aether-gateway/src/execution_runtime/transport.rs index 8bc6bd113..411c69fcf 100644 --- a/apps/aether-gateway/src/execution_runtime/transport.rs +++ b/apps/aether-gateway/src/execution_runtime/transport.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::error::Error as _; use std::future::Future; use std::io::Read; @@ -63,6 +63,8 @@ use crate::upstream_admission::UpstreamTargetAdmissionPermit; use crate::{AppState, GatewayError}; const HUB_RELAY_CONTENT_TYPE: &str = "application/vnd.aether.tunnel-envelope"; +pub(crate) const EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY: &str = + aether_admin::system::EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY; const HUB_RELAY_ERROR_HEADER: &str = "x-aether-tunnel-error"; const MAX_SAFE_REDIRECTS: usize = 10; const MAX_UPSTREAM_ERROR_DETAIL_BYTES: usize = 2_048; @@ -479,28 +481,147 @@ const TRUSTED_EXECUTION_BENCHMARKING_DNS_EXACT_HOSTS: &[&str] = &[ "generativelanguage.googleapis.com", "grok.com", "open.bigmodel.cn", + "q.us-iso-east-1.c2s.ic.gov", + "q.us-isob-east-1.sc2s.sgov.gov", + "q.us-isof-east-1.csp.hci.ic.gov", + "q.us-isof-south-1.csp.hci.ic.gov", "server.codeium.com", ]; +const TRUSTED_EXECUTION_VERTEX_DNS_REGIONS: &[&str] = &[ + "africa-south1", + "asia-east1", + "asia-east2", + "asia-northeast1", + "asia-northeast2", + "asia-northeast3", + "asia-south1", + "asia-south2", + "asia-southeast1", + "asia-southeast2", + "australia-southeast1", + "australia-southeast2", + "europe-central2", + "europe-north1", + "europe-southwest1", + "europe-west1", + "europe-west2", + "europe-west3", + "europe-west4", + "europe-west6", + "europe-west8", + "europe-west9", + "europe-west10", + "europe-west12", + "me-central1", + "me-central2", + "me-west1", + "northamerica-northeast1", + "northamerica-northeast2", + "southamerica-east1", + "southamerica-west1", + "us-central1", + "us-east1", + "us-east4", + "us-east5", + "us-south1", + "us-west1", + "us-west2", + "us-west3", + "us-west4", +]; + +const TRUSTED_EXECUTION_AWS_DNS_REGIONS: &[&str] = &[ + "af-south-1", + "ap-east-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", + "ap-south-2", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", + "ca-central-1", + "ca-west-1", + "eu-central-1", + "eu-central-2", + "eu-north-1", + "eu-south-1", + "eu-south-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "il-central-1", + "me-central-1", + "me-south-1", + "mx-central-1", + "sa-east-1", + "us-east-1", + "us-east-2", + "us-gov-east-1", + "us-gov-west-1", + "us-west-1", + "us-west-2", +]; + +static EXECUTION_EXTRA_TRUSTED_DNS_HOSTS: LazyLock>> = + LazyLock::new(|| StdRwLock::new(BTreeSet::new())); + +pub(crate) fn refresh_execution_extra_trusted_dns_hosts(value: Option<&Value>) { + let hosts = value + .cloned() + .and_then(|value| { + aether_admin::system::normalize_execution_extra_trusted_dns_hosts_config_value(value) + .ok() + }) + .and_then(|value| { + value.as_array().map(|hosts| { + hosts + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + }) + .unwrap_or_default(); + + if let Ok(mut current) = EXECUTION_EXTRA_TRUSTED_DNS_HOSTS.write() { + *current = hosts; + } +} + /// Return whether `host` is one of the fixed provider origins for which a /// local RFC-2544 synthetic answer can be accepted. The resolver receives only /// a hostname (not the URL scheme/path), so all policy that can be expressed /// here is intentionally host based. URL validation still requires HTTPS for /// non-loopback upstreams before this resolver is used. fn execution_host_allows_benchmarking_dns_answer(host: &str) -> bool { + let extra_hosts = EXECUTION_EXTRA_TRUSTED_DNS_HOSTS + .read() + .map(|hosts| hosts.clone()) + .unwrap_or_default(); + execution_host_allows_benchmarking_dns_answer_with_extra_hosts(host, &extra_hosts) +} + +fn execution_host_allows_benchmarking_dns_answer_with_extra_hosts( + host: &str, + extra_hosts: &BTreeSet, +) -> bool { let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); - if TRUSTED_EXECUTION_BENCHMARKING_DNS_EXACT_HOSTS - .iter() - .any(|trusted| *trusted == host) + if extra_hosts.contains(&host) + || TRUSTED_EXECUTION_BENCHMARKING_DNS_EXACT_HOSTS + .iter() + .any(|trusted| *trusted == host) { return true; } // Vertex service-account requests use `-aiplatform.googleapis.com`. - // Keep the interpolated region to one DNS label and reuse the provider's - // existing conservative region syntax validator. + // Keep this compatibility exception limited to known provider regions. if let Some(region) = host.strip_suffix("-aiplatform.googleapis.com") { - return looks_like_cloud_region_label(region); + return TRUSTED_EXECUTION_VERTEX_DNS_REGIONS.contains(®ion); } // Kiro uses a small, fixed set of regional service origins. Match each @@ -511,9 +632,6 @@ fn execution_host_allows_benchmarking_dns_answer(host: &str) -> bool { || matches_regional_service_host(&host, "codewhisperer", ".amazonaws.com") || matches_regional_service_host(&host, "oidc", ".amazonaws.com") || matches_regional_service_host(&host, "prod", ".auth.desktop.kiro.dev") - || matches_regional_service_host(&host, "q", ".c2s.ic.gov") - || matches_regional_service_host(&host, "q", ".sc2s.sgov.gov") - || matches_regional_service_host(&host, "q", ".csp.hci.ic.gov") } fn matches_regional_service_host(host: &str, service: &str, suffix: &str) -> bool { @@ -524,21 +642,7 @@ fn matches_regional_service_host(host: &str, service: &str, suffix: &str) -> boo else { return false; }; - // A single region label is required. This rejects values such as - // `q.us-east-1.evil.amazonaws.com` and suffix lookalikes. - !region.contains('.') && looks_like_cloud_region_label(region) -} - -fn looks_like_cloud_region_label(value: &str) -> bool { - value.len() >= 3 - && value.len() <= 63 - && value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - && !value.starts_with('-') - && !value.ends_with('-') - && value.contains('-') - && value.bytes().any(|byte| byte.is_ascii_digit()) + TRUSTED_EXECUTION_AWS_DNS_REGIONS.contains(®ion) } fn dns_host_explicitly_allows_loopback(host: &str) -> bool { @@ -5569,6 +5673,7 @@ mod tests { "api.openai.com", "CHATGPT.COM.", "us-central1-aiplatform.googleapis.com", + "me-central2-aiplatform.googleapis.com", "q.us-east-1.amazonaws.com", "q-fips.us-gov-west-1.amazonaws.com", "codewhisperer.us-west-2.amazonaws.com", @@ -5591,6 +5696,16 @@ mod tests { "q.us-east-1.evil.amazonaws.com", "q.us-east-1.amazonaws.com.attacker.test", "q.localhost.amazonaws.com", + "evil-1-aiplatform.googleapis.com", + "q.evil-1.amazonaws.com", + "q-fips.evil-1.amazonaws.com", + "codewhisperer.evil-1.amazonaws.com", + "prod.evil-1.auth.desktop.kiro.dev", + "oidc.evil-1.amazonaws.com", + "q.us-central1.amazonaws.com", + "us-east-1-aiplatform.googleapis.com", + "q.us-east-1.c2s.ic.gov", + "q.us-iso-east-1.sc2s.sgov.gov", "q-fips.us-gov-west-1.evil.amazonaws.com", "codewhisperer.us-west-2.evil.amazonaws.com", "oidc.us-east-1.evil.amazonaws.com", @@ -5608,6 +5723,20 @@ mod tests { } } + #[test] + fn execution_dns_answers_allow_benchmarking_range_for_configured_exact_hosts() { + let fake = "198.18.75.234:443".parse().unwrap(); + super::refresh_execution_extra_trusted_dns_hosts(Some(&json!(["custom.example.com",]))); + + assert!(super::validate_execution_dns_answers("custom.example.com", vec![fake]).is_ok()); + assert!( + super::validate_execution_dns_answers("api.custom.example.com", vec![fake]).is_err() + ); + + super::refresh_execution_extra_trusted_dns_hosts(None); + assert!(super::validate_execution_dns_answers("custom.example.com", vec![fake]).is_err()); + } + #[test] fn execution_dns_answers_reject_mixed_private_results_and_strict_relay_policy() { let fake = "198.18.75.234:443".parse().unwrap(); diff --git a/apps/aether-gateway/src/main.rs b/apps/aether-gateway/src/main.rs index 46ae58ac3..571dee507 100644 --- a/apps/aether-gateway/src/main.rs +++ b/apps/aether-gateway/src/main.rs @@ -2434,6 +2434,10 @@ async fn run() -> Result<(), Box> { ); } } + match state.prewarm_execution_extra_trusted_dns_hosts().await { + Ok(_) => info!("prewarmed execution Fake-IP DNS allowlist"), + Err(err) => warn!(error = %err, "failed to prewarm execution Fake-IP DNS allowlist"), + } match prewarm_direct_h2c_sender_cache_from_env_for_startup().await { Ok(Some(report)) => { if report.failed_targets > 0 { diff --git a/apps/aether-gateway/src/state/core.rs b/apps/aether-gateway/src/state/core.rs index 0c455e544..236073550 100644 --- a/apps/aether-gateway/src/state/core.rs +++ b/apps/aether-gateway/src/state/core.rs @@ -154,6 +154,15 @@ impl AppState { .map_err(|err| format!("{err:?}")) } + pub async fn prewarm_execution_extra_trusted_dns_hosts(&self) -> Result<(), String> { + self.read_system_config_json_value( + aether_admin::system::EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY, + ) + .await + .map(|_| ()) + .map_err(|err| format!("{err:?}")) + } + fn usage_worker_queue_for( runtime_state: &Arc, ) -> Option> { @@ -769,6 +778,18 @@ impl AppState { .expect("admin monitoring error stats reset cache should lock") } + fn refresh_execution_extra_trusted_dns_hosts( + &self, + key: &str, + value: Option<&serde_json::Value>, + ) { + if key.eq_ignore_ascii_case( + aether_admin::system::EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY, + ) { + crate::execution_runtime::transport::refresh_execution_extra_trusted_dns_hosts(value); + } + } + pub(crate) fn mark_admin_monitoring_error_stats_reset(&self, now_unix_secs: u64) { let mut reset_at = self .admin_monitoring_error_stats_reset_at @@ -781,22 +802,28 @@ impl AppState { &self, key: &str, ) -> Result, GatewayError> { - self.read_system_config_json_value_with_cache_windows( - key, - SYSTEM_CONFIG_CACHE_TTL, - SYSTEM_CONFIG_CACHE_MAX_STALENESS, - ) - .await + let value = self + .read_system_config_json_value_with_cache_windows( + key, + SYSTEM_CONFIG_CACHE_TTL, + SYSTEM_CONFIG_CACHE_MAX_STALENESS, + ) + .await?; + self.refresh_execution_extra_trusted_dns_hosts(key, value.as_ref()); + Ok(value) } pub(crate) async fn read_system_config_json_value_strong( &self, key: &str, ) -> Result, GatewayError> { - self.data + let value = self + .data .find_system_config_value_strong(key) .await - .map_err(|err| GatewayError::Internal(err.to_string())) + .map_err(|err| GatewayError::Internal(err.to_string()))?; + self.refresh_execution_extra_trusted_dns_hosts(key, value.as_ref()); + Ok(value) } pub(crate) async fn compare_and_set_system_config_string_value( @@ -934,6 +961,7 @@ impl AppState { .map_err(|err| GatewayError::Internal(err.to_string()))?; self.system_config_cache .insert(key.to_string(), None, SYSTEM_CONFIG_CACHE_MAX_STALENESS); + self.refresh_execution_extra_trusted_dns_hosts(key, None); if deleted && system_config_key_affects_scheduler(key) { self.invalidate_scheduler_affinity_cache(); } @@ -1015,6 +1043,7 @@ impl AppState { } fn remember_system_config_write(&self, key: &str, value: Option) { + self.refresh_execution_extra_trusted_dns_hosts(key, value.as_ref()); self.system_config_cache .insert(key.to_string(), value, SYSTEM_CONFIG_CACHE_MAX_STALENESS); if system_config_key_affects_scheduler(key) { @@ -1062,6 +1091,7 @@ impl AppState { | aether_data::repository::system::AdminSystemPurgeTarget::Stats ) { self.system_config_cache.clear(); + crate::execution_runtime::transport::refresh_execution_extra_trusted_dns_hosts(None); self.invalidate_provider_routing_caches(); } Ok(summary) diff --git a/crates/aether-admin/src/system.rs b/crates/aether-admin/src/system.rs index 3e276f34b..18d04b7e6 100644 --- a/crates/aether-admin/src/system.rs +++ b/crates/aether-admin/src/system.rs @@ -67,6 +67,9 @@ pub fn admin_email_template_html_is_valid(value: &str) -> bool { pub const ADMIN_SYSTEM_CONFIG_EXPORT_VERSION: &str = "2.3"; pub const ADMIN_SYSTEM_CONFIG_SUPPORTED_VERSIONS: &[&str] = &["2.0", "2.1", "2.2", ADMIN_SYSTEM_CONFIG_EXPORT_VERSION]; +pub const EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY: &str = "execution_extra_trusted_dns_hosts"; +pub const EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_MAX_ENTRIES: usize = 128; +pub const EXECUTION_EXTRA_TRUSTED_DNS_HOST_MAX_BYTES: usize = 253; pub const ADMIN_SYSTEM_USERS_EXPORT_VERSION: &str = "1.6"; pub const ADMIN_SYSTEM_USERS_SUPPORTED_VERSIONS: &[&str] = &["1.3", "1.4", "1.5", ADMIN_SYSTEM_USERS_EXPORT_VERSION]; @@ -2278,6 +2281,7 @@ pub fn admin_system_config_default_value(key: &str) -> Option "email_suffix_mode" => Some(json!("none")), "email_suffix_list" => Some(json!([])), "enable_format_conversion" => Some(json!(false)), + EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY => Some(json!([])), "enable_model_directives" => Some(json!(false)), // Failover after a provider-side Cyber policy refusal is an explicit // opt-in. Keep the system-config fallback aligned with the routing @@ -2317,6 +2321,55 @@ pub fn admin_system_config_default_value(key: &str) -> Option } } +pub fn normalize_execution_extra_trusted_dns_hosts_config_value( + value: serde_json::Value, +) -> Result { + let values = match value { + Value::Null => Vec::new(), + Value::Array(values) => values, + _ => return Err(()), + }; + if values.len() > EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_MAX_ENTRIES { + return Err(()); + } + + let mut hosts = BTreeSet::new(); + for value in values { + let host = value.as_str().map(str::trim).ok_or(())?; + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if !execution_extra_trusted_dns_host_is_valid(&host) { + return Err(()); + } + hosts.insert(host); + } + + Ok(Value::Array(hosts.into_iter().map(Value::String).collect())) +} + +fn execution_extra_trusted_dns_host_is_valid(host: &str) -> bool { + if host.is_empty() + || host.len() > EXECUTION_EXTRA_TRUSTED_DNS_HOST_MAX_BYTES + || !host.is_ascii() + || host.parse::().is_ok() + { + return false; + } + + let labels = host.split('.').collect::>(); + if labels.len() < 2 { + return false; + } + labels.iter().all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + pub fn build_admin_system_configs_payload( entries: &[StoredSystemConfigEntry], ) -> serde_json::Value { @@ -2808,6 +2861,15 @@ pub fn parse_admin_system_config_update( ) })?; } + EXECUTION_EXTRA_TRUSTED_DNS_HOSTS_CONFIG_KEY => { + value = + normalize_execution_extra_trusted_dns_hosts_config_value(value).map_err(|_| { + ( + http::StatusCode::BAD_REQUEST, + json!({ "detail": "额外可信 Fake-IP 域名配置格式无效" }), + ) + })?; + } "module.important_notification.default_channel" => { value = normalize_notification_channel_value(value).map_err(|_| { ( @@ -4548,6 +4610,36 @@ mod tests { .is_err()); } + #[test] + fn extra_trusted_dns_hosts_update_normalizes_exact_hostnames() { + let update = parse_admin_system_config_update( + "execution_extra_trusted_dns_hosts", + br#"{"value":[" API.Example.COM. ","api.example.com"]}"#, + ) + .expect("valid extra trusted DNS hosts should parse"); + assert_eq!(update.value, json!(["api.example.com"])); + } + + #[test] + fn extra_trusted_dns_hosts_update_rejects_non_exact_hostnames() { + for value in [ + r#"["*.example.com"]"#, + r#"["example.com:443"]"#, + r#"["https://example.com/path"]"#, + r#"["10.0.0.1"]"#, + r#"["example..com"]"#, + r#"["localhost"]"#, + ] { + let body = format!(r#"{{"value":{value}}}"#); + let error = parse_admin_system_config_update( + "execution_extra_trusted_dns_hosts", + body.as_bytes(), + ) + .expect_err("non-exact hostname should be rejected"); + assert_eq!(error.0, http::StatusCode::BAD_REQUEST); + } + } + #[test] fn legacy_notification_email_config_key_normalizes_to_important_notification() { assert_eq!( diff --git a/frontend/src/views/admin/SystemSettings.vue b/frontend/src/views/admin/SystemSettings.vue index 4940022cb..515749539 100644 --- a/frontend/src/views/admin/SystemSettings.vue +++ b/frontend/src/views/admin/SystemSettings.vue @@ -50,12 +50,14 @@ @@ -351,6 +353,7 @@ const { hasCleanupConfigChanges, sensitiveHeadersStr, turnstileAllowedHostnamesStr, + extraTrustedDnsHostsStr, loadSystemConfig, loadSystemVersion, saveSiteInfo, diff --git a/frontend/src/views/admin/system-settings/ProxyConfigSection.vue b/frontend/src/views/admin/system-settings/ProxyConfigSection.vue index df8e22e62..29eb6a625 100644 --- a/frontend/src/views/admin/system-settings/ProxyConfigSection.vue +++ b/frontend/src/views/admin/system-settings/ProxyConfigSection.vue @@ -40,6 +40,25 @@ 对未单独配置代理的提供商生效,覆盖大模型 API 请求、余额查询、OAuth 刷新等。不影响系统内部接口。

+
+ +