feat(proxy): 重构 Proxy 节点管理与隧道系统

- 重构 proxy_nodes 管理端,支持节点注册、心跳、隧道生命周期管理
- 增强 tunnel 嵌入式 hub 和隧道协议
- 重构 aether-proxy 配置、隧道客户端、心跳和调度机制
- 调整 admin OAuth/配额/导入等处理器的参数传递
- 扩展数据迁移模块
- 补充 proxy nodes、OAuth、配额、系统导入等测试
- 更新前端 proxy nodes 视图和 API
This commit is contained in:
fawney19
2026-04-14 22:51:02 +08:00
parent fb31928e44
commit a4e7ac1df6
55 changed files with 3477 additions and 892 deletions

View File

@@ -31,3 +31,5 @@ pub(crate) use self::request::{
AdminAppState, AdminRequestContext, AdminRouteRequest, AdminRouteResponse, AdminRouteResult,
};
pub(crate) use self::routes::maybe_build_local_admin_response;
#[cfg(test)]
pub(crate) use self::system::override_proxy_connectivity_probe_url_for_tests;

View File

@@ -8,9 +8,11 @@ use crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider
use crate::handlers::admin::provider::oauth::provisioning::build_provider_oauth_auth_config_from_token_payload;
use crate::handlers::admin::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, provider_oauth_active_api_formats,
provider_oauth_key_proxy_value, update_existing_provider_oauth_catalog_key,
update_existing_provider_oauth_catalog_key,
};
use crate::handlers::admin::provider::oauth::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
use crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update;
use crate::handlers::admin::provider::oauth::state::{
admin_provider_oauth_template, exchange_admin_provider_oauth_refresh_token,
};
@@ -116,7 +118,18 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
.list_provider_catalog_endpoints_by_provider_ids(&[provider_id.to_string()])
.await?;
let api_formats = provider_oauth_active_api_formats(&endpoints);
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id);
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider(provider_type, &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
proxy_node_id,
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let mut results = Vec::with_capacity(entries.len());
let mut success = 0usize;
let mut failed = 0usize;
@@ -126,7 +139,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
state,
template,
entry.refresh_token.as_str(),
proxy_node_id,
request_proxy.clone(),
)
.await
{
@@ -189,7 +202,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
&existing_key,
&access_token,
&auth_config,
key_proxy.clone(),
None,
expires_at,
)
.await?
@@ -232,7 +245,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
&access_token,
&auth_config,
&api_formats,
key_proxy.clone(),
None,
expires_at,
)
.await?

View File

@@ -2,9 +2,11 @@ use super::parse::{AdminProviderOAuthBatchImportEntry, AdminProviderOAuthBatchIm
use crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider_oauth_key;
use crate::handlers::admin::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, provider_oauth_active_api_formats,
provider_oauth_key_proxy_value, update_existing_provider_oauth_catalog_key,
update_existing_provider_oauth_catalog_key,
};
use crate::handlers::admin::provider::oauth::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
use crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update;
use crate::handlers::admin::provider::oauth::state::decode_jwt_claims;
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::request::{AdminAppState, AdminKiroAuthConfig};
@@ -14,6 +16,7 @@ use aether_admin::provider::oauth::{
build_kiro_batch_import_key_name, coerce_admin_provider_oauth_import_str,
parse_admin_provider_oauth_kiro_batch_import_entries,
};
use aether_contracts::ProxySnapshot;
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -102,7 +105,7 @@ fn admin_provider_oauth_kiro_refresh_error_detail(
async fn refresh_admin_provider_oauth_kiro_auth_config(
state: &AdminAppState<'_>,
auth_config: &AdminKiroAuthConfig,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
social_refresh_base_url: Option<&str>,
idc_refresh_base_url: Option<&str>,
) -> Result<AdminKiroAuthConfig, String> {
@@ -164,7 +167,7 @@ async fn refresh_admin_provider_oauth_kiro_auth_config(
"grantType": "refresh_token",
})),
None,
proxy_node_id,
proxy.clone(),
)
.await
.map_err(|err| format!("IDC refresh 请求失败: {err}"))?;
@@ -268,7 +271,7 @@ async fn refresh_admin_provider_oauth_kiro_auth_config(
.unwrap_or_default(),
})),
None,
proxy_node_id,
proxy,
)
.await
.map_err(|err| format!("social refresh 请求失败: {err}"))?;
@@ -352,7 +355,18 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(&[provider_id.to_string()])
.await?;
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id);
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider("kiro", &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
proxy_node_id,
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let social_refresh_base_url =
admin_provider_oauth_kiro_refresh_base_url_override(state, "kiro_social_refresh");
let idc_refresh_base_url =
@@ -392,7 +406,7 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
refreshed_auth_config = match refresh_admin_provider_oauth_kiro_auth_config(
state,
&refreshed_auth_config,
proxy_node_id,
request_proxy.clone(),
social_refresh_base_url.as_deref(),
idc_refresh_base_url.as_deref(),
)
@@ -477,7 +491,7 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
&existing_key,
&access_token,
&auth_config,
key_proxy.clone(),
None,
refreshed_auth_config.expires_at,
)
.await?
@@ -511,7 +525,7 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
&access_token,
&auth_config,
&provider_oauth_active_api_formats(&endpoints),
key_proxy.clone(),
None,
refreshed_auth_config.expires_at,
)
.await?

View File

@@ -1,5 +1,6 @@
use super::super::super::errors::build_internal_control_error_response;
use super::super::super::quota::codex::refresh_codex_provider_quota_locally;
use super::super::super::runtime::provider_oauth_runtime_endpoint_for_provider;
use super::super::super::state::{
admin_provider_oauth_template, enrich_admin_provider_oauth_auth_config,
is_fixed_provider_type_for_provider_oauth, json_non_empty_string, json_u64_value,
@@ -123,6 +124,22 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
"该 Provider 不支持 OAuth 授权",
));
};
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider(&provider_type, &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
payload.proxy_node_id.as_deref(),
&[
key.proxy.as_ref(),
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let token_payload = match state
.exchange_admin_provider_oauth_code(
@@ -130,7 +147,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
&callback.code,
&callback.state_nonce,
state_data.pkce_verifier.as_deref(),
payload.proxy_node_id.as_deref(),
request_proxy,
)
.await
{

View File

@@ -2,10 +2,11 @@ use super::super::super::duplicates::find_duplicate_provider_oauth_key;
use super::super::super::errors::build_internal_control_error_response;
use super::super::super::provisioning::{
build_provider_oauth_auth_config_from_token_payload, create_provider_oauth_catalog_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
update_existing_provider_oauth_catalog_key,
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
};
use super::super::super::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
use super::super::super::runtime::refresh_provider_oauth_account_state_after_update;
use super::super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
is_fixed_provider_type_for_provider_oauth,
@@ -114,6 +115,21 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
"该 Provider 不支持 OAuth 授权",
));
};
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider(&provider_type, &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
payload.proxy_node_id.as_deref(),
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let token_payload = match state
.exchange_admin_provider_oauth_code(
@@ -121,7 +137,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
&callback.code,
&callback.state_nonce,
state_data.pkce_verifier.as_deref(),
payload.proxy_node_id.as_deref(),
request_proxy,
)
.await
{
@@ -138,11 +154,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
));
};
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let api_formats = provider_oauth_active_api_formats(&endpoints);
let key_proxy = provider_oauth_key_proxy_value(payload.proxy_node_id.as_deref());
let duplicate = match state
.find_duplicate_provider_oauth_key(&provider_id, &auth_config, None)
.await
@@ -163,7 +175,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
&existing_key,
&access_token,
&auth_config,
key_proxy.clone(),
None,
expires_at,
)
.await?
@@ -204,7 +216,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
&access_token,
&auth_config,
&api_formats,
key_proxy.clone(),
None,
expires_at,
)
.await?

View File

@@ -1,5 +1,6 @@
use super::session::AdminProviderOAuthDeviceAuthorizePayload;
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::runtime::provider_oauth_runtime_endpoint_for_provider;
use crate::handlers::admin::provider::oauth::state::{
build_admin_provider_oauth_backend_unavailable_response, current_unix_secs,
default_kiro_device_start_url, generate_provider_oauth_nonce, json_non_empty_string,
@@ -69,6 +70,21 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
"设备授权仅支持 Kiro provider",
));
}
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider("kiro", &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
payload.proxy_node_id.as_deref(),
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let region = normalize_kiro_device_region(Some(payload.region.as_str())).ok_or_else(|| {
build_internal_control_error_response(http::StatusCode::BAD_REQUEST, "region 格式无效")
@@ -85,11 +101,7 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
};
let client_registration = match state
.register_admin_kiro_device_oidc_client(
&region,
&start_url,
payload.proxy_node_id.as_deref(),
)
.register_admin_kiro_device_oidc_client(&region, &start_url, request_proxy.clone())
.await
{
Ok(payload) => payload,
@@ -114,7 +126,7 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
&client_id,
&client_secret,
&start_url,
payload.proxy_node_id.as_deref(),
request_proxy,
)
.await
{

View File

@@ -5,9 +5,11 @@ use crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, provider_oauth_active_api_formats,
provider_oauth_key_proxy_value, update_existing_provider_oauth_catalog_key,
update_existing_provider_oauth_catalog_key,
};
use crate::handlers::admin::provider::oauth::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
use crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update;
use crate::handlers::admin::provider::oauth::state::{
build_admin_provider_oauth_backend_unavailable_response, build_kiro_device_key_name,
current_unix_secs, decode_jwt_claims, json_non_empty_string, json_u64_value,
@@ -125,6 +127,21 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
"Provider 不存在",
));
};
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider("kiro", &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
session.proxy_node_id.as_deref(),
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let token_result = match state
.poll_admin_kiro_device_token(
@@ -132,7 +149,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
&session.client_id,
&session.client_secret,
&session.device_code,
session.proxy_node_id.as_deref(),
request_proxy,
)
.await
{
@@ -252,12 +269,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
}
};
let key_proxy = provider_oauth_key_proxy_value(session.proxy_node_id.as_deref());
let api_formats = provider_oauth_active_api_formats(
&state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?,
);
let api_formats = provider_oauth_active_api_formats(&endpoints);
let mut replaced = false;
let persisted_key = if let Some(existing_key) = duplicate {
replaced = true;
@@ -266,7 +278,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
&existing_key,
&access_token,
&auth_config,
key_proxy.clone(),
None,
Some(expires_at),
)
.await?
@@ -288,7 +300,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
&access_token,
&auth_config,
&api_formats,
key_proxy.clone(),
None,
Some(expires_at),
)
.await?

View File

@@ -2,10 +2,11 @@ use super::super::duplicates::find_duplicate_provider_oauth_key;
use super::super::errors::build_internal_control_error_response;
use super::super::provisioning::{
build_provider_oauth_auth_config_from_token_payload, create_provider_oauth_catalog_key,
provider_oauth_active_api_formats, provider_oauth_key_proxy_value,
update_existing_provider_oauth_catalog_key,
provider_oauth_active_api_formats, update_existing_provider_oauth_catalog_key,
};
use super::super::runtime::{
provider_oauth_runtime_endpoint_for_provider, refresh_provider_oauth_account_state_after_update,
};
use super::super::runtime::refresh_provider_oauth_account_state_after_update;
use super::super::state::{
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
@@ -96,13 +97,24 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
let Some(template) = admin_provider_oauth_template(&provider_type) else {
return Ok(build_admin_provider_oauth_backend_unavailable_response());
};
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let runtime_endpoint = provider_oauth_runtime_endpoint_for_provider(&provider_type, &endpoints);
let request_proxy = state
.resolve_admin_provider_oauth_operation_proxy_snapshot(
proxy_node_id.as_deref(),
&[
runtime_endpoint
.as_ref()
.and_then(|endpoint| endpoint.proxy.as_ref()),
provider.proxy.as_ref(),
],
)
.await;
let token_payload = match state
.exchange_admin_provider_oauth_refresh_token(
template,
refresh_token_input,
proxy_node_id.as_deref(),
)
.exchange_admin_provider_oauth_refresh_token(template, refresh_token_input, request_proxy)
.await
{
Ok(payload) => payload,
@@ -124,11 +136,7 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
}
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let api_formats = provider_oauth_active_api_formats(&endpoints);
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id.as_deref());
let duplicate = match state
.find_duplicate_provider_oauth_key(&provider_id, &auth_config, None)
.await
@@ -149,7 +157,7 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
&existing_key,
&access_token,
&auth_config,
key_proxy.clone(),
None,
expires_at,
)
.await?
@@ -189,7 +197,7 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
&access_token,
&auth_config,
&api_formats,
key_proxy.clone(),
None,
expires_at,
)
.await?

View File

@@ -79,7 +79,7 @@ pub(super) async fn parse_admin_provider_oauth_refresh_request(
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
let Some(endpoint) = provider_oauth_runtime_endpoint_for_provider(&provider_type, endpoints)
let Some(endpoint) = provider_oauth_runtime_endpoint_for_provider(&provider_type, &endpoints)
else {
return Ok(RefreshDispatch::Respond(response::control_error_response(
http::StatusCode::BAD_REQUEST,

View File

@@ -1,13 +1,14 @@
use super::shared::{
coerce_json_f64, coerce_json_string, execute_provider_quota_plan,
extract_execution_error_message, persist_provider_quota_refresh_state,
quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
coerce_json_f64, coerce_json_string, default_provider_quota_execution_timeouts,
execute_provider_quota_plan, extract_execution_error_message,
persist_provider_quota_refresh_state, quota_refresh_success_invalid_state,
ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::payloads::ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH;
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_admin::provider::quota::parse_antigravity_usage_response;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_contracts::{ExecutionPlan, RequestBody};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
@@ -31,6 +32,14 @@ async fn execute_antigravity_quota_plan(
.or_insert_with(|| "antigravity".to_string());
let body = json!({ "project": project_id });
let proxy = state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await;
let timeouts = state
.resolve_transport_execution_timeouts(transport)
.or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(),
)));
let plan = ExecutionPlan {
request_id: format!("antigravity-quota:{}", transport.key.id),
candidate_id: None,
@@ -56,20 +65,9 @@ async fn execute_antigravity_quota_plan(
client_api_format: "gemini:chat".to_string(),
provider_api_format: "antigravity:fetch_available_models".to_string(),
model_name: Some("fetchAvailableModels".to_string()),
proxy: state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await,
proxy,
tls_profile: state.resolve_transport_tls_profile(transport),
timeouts: state
.resolve_transport_execution_timeouts(transport)
.or(Some(ExecutionTimeouts {
connect_ms: Some(30_000),
read_ms: Some(30_000),
write_ms: Some(30_000),
pool_ms: Some(30_000),
total_ms: Some(30_000),
..ExecutionTimeouts::default()
})),
timeouts,
};
execute_provider_quota_plan(state, transport, plan, "antigravity").await

View File

@@ -1,9 +1,12 @@
use super::super::shared::{execute_provider_quota_plan, ProviderQuotaExecutionOutcome};
use super::super::shared::{
default_provider_quota_execution_timeouts, execute_provider_quota_plan,
ProviderQuotaExecutionOutcome,
};
use super::parse::normalize_codex_plan_type;
use crate::handlers::admin::provider::shared::payloads::CODEX_WHAM_USAGE_URL;
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_contracts::{ExecutionPlan, RequestBody};
use std::collections::BTreeMap;
pub(super) fn build_codex_refresh_headers(
@@ -58,6 +61,14 @@ pub(super) async fn execute_codex_quota_plan(
transport: &AdminGatewayProviderTransportSnapshot,
headers: BTreeMap<String, String>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let proxy = state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await;
let timeouts = state
.resolve_transport_execution_timeouts(transport)
.or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(),
)));
let plan = ExecutionPlan {
request_id: format!("codex-quota:{}", transport.key.id),
candidate_id: None,
@@ -79,20 +90,9 @@ pub(super) async fn execute_codex_quota_plan(
client_api_format: "openai:cli".to_string(),
provider_api_format: "openai:cli".to_string(),
model_name: Some("codex-wham-usage".to_string()),
proxy: state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await,
proxy,
tls_profile: state.resolve_transport_tls_profile(transport),
timeouts: state
.resolve_transport_execution_timeouts(transport)
.or(Some(ExecutionTimeouts {
connect_ms: Some(30_000),
read_ms: Some(30_000),
write_ms: Some(30_000),
pool_ms: Some(30_000),
total_ms: Some(30_000),
..ExecutionTimeouts::default()
})),
timeouts,
};
execute_provider_quota_plan(state, transport, plan, "codex").await
}

View File

@@ -1,3 +1,4 @@
use super::super::shared::default_provider_quota_execution_timeouts;
use super::super::shared::{execute_provider_quota_plan, ProviderQuotaExecutionOutcome};
use crate::handlers::admin::provider::shared::payloads::{
KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
@@ -6,7 +7,7 @@ use crate::handlers::admin::request::{
AdminAppState, AdminGatewayProviderTransportSnapshot, AdminKiroRequestAuth,
};
use crate::GatewayError;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_contracts::{ExecutionPlan, RequestBody};
use std::collections::BTreeMap;
use url::form_urlencoded;
use uuid::Uuid;
@@ -66,6 +67,14 @@ pub(super) async fn execute_kiro_quota_plan(
transport: &AdminGatewayProviderTransportSnapshot,
auth: &AdminKiroRequestAuth,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let proxy = state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await;
let timeouts = state
.resolve_transport_execution_timeouts(transport)
.or(Some(default_provider_quota_execution_timeouts(
proxy.as_ref(),
)));
let plan = ExecutionPlan {
request_id: format!("kiro-quota:{}", transport.key.id),
candidate_id: None,
@@ -87,20 +96,9 @@ pub(super) async fn execute_kiro_quota_plan(
client_api_format: "claude:cli".to_string(),
provider_api_format: "kiro:usage".to_string(),
model_name: Some("kiro-usage-limits".to_string()),
proxy: state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await,
proxy,
tls_profile: state.resolve_transport_tls_profile(transport),
timeouts: state
.resolve_transport_execution_timeouts(transport)
.or(Some(ExecutionTimeouts {
connect_ms: Some(30_000),
read_ms: Some(30_000),
write_ms: Some(30_000),
pool_ms: Some(30_000),
total_ms: Some(30_000),
..ExecutionTimeouts::default()
})),
timeouts,
};
execute_provider_quota_plan(state, transport, plan, "kiro").await

View File

@@ -4,16 +4,37 @@ use crate::handlers::admin::provider::shared::payloads::{
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_admin::provider::quota as admin_provider_quota_pure;
use aether_contracts::{ExecutionPlan, ExecutionResult};
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot};
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
const PROVIDER_QUOTA_DEFAULT_TIMEOUT_MS: u64 = 30_000;
const PROVIDER_QUOTA_PROXY_TIMEOUT_MS: u64 = 60_000;
pub(super) enum ProviderQuotaExecutionOutcome {
Response(ExecutionResult),
Failure(String),
}
pub(super) fn default_provider_quota_execution_timeouts(
proxy: Option<&ProxySnapshot>,
) -> ExecutionTimeouts {
let timeout_ms = if proxy.is_some() {
PROVIDER_QUOTA_PROXY_TIMEOUT_MS
} else {
PROVIDER_QUOTA_DEFAULT_TIMEOUT_MS
};
ExecutionTimeouts {
connect_ms: Some(timeout_ms),
read_ms: Some(timeout_ms),
write_ms: Some(timeout_ms),
pool_ms: Some(timeout_ms),
total_ms: Some(timeout_ms),
..ExecutionTimeouts::default()
}
}
pub(super) fn provider_auto_remove_banned_keys(config: Option<&serde_json::Value>) -> bool {
admin_provider_quota_pure::provider_auto_remove_banned_keys(config)
}
@@ -127,6 +148,9 @@ pub(super) async fn execute_provider_quota_plan(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let proxy_source = state
.resolve_transport_proxy_source_with_tunnel_affinity(transport)
.await;
let proxy_url_present = plan
.proxy
.as_ref()
@@ -138,6 +162,7 @@ pub(super) async fn execute_provider_quota_plan(
endpoint_id = %transport.endpoint.id,
url = %plan.url,
tls_profile = ?plan.tls_profile.as_deref(),
proxy_source = ?proxy_source,
proxy_node_id = ?proxy_node_id,
proxy_url_present,
error = %error,

View File

@@ -9,28 +9,34 @@ use aether_data_contracts::repository::provider_catalog::{
pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
provider_type: &str,
endpoints: Vec<StoredProviderCatalogEndpoint>,
endpoints: &[StoredProviderCatalogEndpoint],
) -> Option<StoredProviderCatalogEndpoint> {
let provider_type = provider_type.trim().to_ascii_lowercase();
match provider_type.as_str() {
"codex" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("openai:cli")
}),
"antigravity" => endpoints.into_iter().find(|endpoint| {
endpoint.is_active
&& (endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
"codex" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
}),
.eq_ignore_ascii_case("openai:cli")
})
.cloned(),
"antigravity" => endpoints
.iter()
.find(|endpoint| {
endpoint.is_active
&& (endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:chat")
|| endpoint
.api_format
.trim()
.eq_ignore_ascii_case("gemini:cli"))
})
.cloned(),
"kiro" => endpoints
.iter()
.find(|endpoint| {
@@ -41,8 +47,16 @@ pub(crate) fn provider_oauth_runtime_endpoint_for_provider(
.eq_ignore_ascii_case("claude:cli")
})
.cloned()
.or_else(|| endpoints.into_iter().find(|endpoint| endpoint.is_active)),
_ => endpoints.into_iter().find(|endpoint| endpoint.is_active),
.or_else(|| {
endpoints
.iter()
.find(|endpoint| endpoint.is_active)
.cloned()
}),
_ => endpoints
.iter()
.find(|endpoint| endpoint.is_active)
.cloned(),
}
}
@@ -59,7 +73,7 @@ pub(crate) async fn refresh_provider_oauth_account_state_after_update(
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
.await?;
let Some(endpoint) = provider_oauth_runtime_endpoint_for_provider(&provider_type, endpoints)
let Some(endpoint) = provider_oauth_runtime_endpoint_for_provider(&provider_type, &endpoints)
else {
return Ok((false, None));
};

View File

@@ -3,16 +3,25 @@ use super::super::errors::{
};
use super::json_non_empty_string;
use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
use aether_contracts::ProxySnapshot;
use axum::{body::Body, http, response::Response};
use url::form_urlencoded;
fn provider_oauth_transport_error_detail(prefix: &str, error: &str) -> String {
let error = error.trim();
if error.is_empty() {
return prefix.to_string();
}
format!("{prefix}: {error}")
}
pub(crate) async fn exchange_admin_provider_oauth_code(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
code: &str,
state_nonce: &str,
pkce_verifier: Option<&str>,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let response = if template.provider_type == "claude_code" {
@@ -63,7 +72,7 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy_node_id,
proxy.clone(),
)
.await
} else {
@@ -100,12 +109,15 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy_node_id,
proxy.clone(),
)
.await
}
.map_err(|_| {
build_internal_control_error_response(http::StatusCode::BAD_REQUEST, "token exchange 失败")
.map_err(|error| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
provider_oauth_transport_error_detail("token exchange 失败", &error),
)
})?;
if !response.status.is_success() {
@@ -134,7 +146,7 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
refresh_token: &str,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let scope = template.scopes.join(" ");
@@ -175,7 +187,7 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy_node_id,
proxy.clone(),
)
.await
} else {
@@ -211,14 +223,17 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy_node_id,
proxy.clone(),
)
.await
}
.map_err(|_| {
.map_err(|error| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token 验证失败: token exchange 失败",
provider_oauth_transport_error_detail(
"Refresh Token 验证失败: token exchange 失败",
&error,
),
)
})?;

View File

@@ -28,9 +28,6 @@ pub(crate) fn normalize_pool_advanced_config(
serde_json::Value::Null => Ok(None),
// `pool_advanced: {}` still means "enable pool mode with defaults".
serde_json::Value::Object(map) => Ok(Some(serde_json::Value::Object(map))),
// Backward compatibility for older boolean payloads.
serde_json::Value::Bool(true) => Ok(Some(serde_json::json!({}))),
serde_json::Value::Bool(false) => Ok(None),
_ => Err("pool_advanced 必须是 JSON 对象".to_string()),
}
}
@@ -78,14 +75,14 @@ mod tests {
}
#[test]
fn normalize_pool_advanced_accepts_legacy_booleans() {
fn normalize_pool_advanced_rejects_legacy_booleans() {
assert_eq!(
normalize_pool_advanced_config(Some(json!(true))).expect("true should normalize"),
Some(json!({}))
normalize_pool_advanced_config(Some(json!(true))).unwrap_err(),
"pool_advanced 必须是 JSON 对象"
);
assert_eq!(
normalize_pool_advanced_config(Some(json!(false))).expect("false should normalize"),
None
normalize_pool_advanced_config(Some(json!(false))).unwrap_err(),
"pool_advanced 必须是 JSON 对象"
);
}
}

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTimeouts, RequestBody,
ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot, RequestBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
};
use aether_data::repository::provider_oauth::{
@@ -21,6 +21,7 @@ use url::Url;
const KIRO_IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
const ADMIN_PROVIDER_OAUTH_TIMEOUT_MS: u64 = 30_000;
const ADMIN_PROVIDER_OAUTH_PROXY_TIMEOUT_MS: u64 = 60_000;
pub(crate) struct AdminProviderOAuthHttpResponse {
pub(crate) status: http::StatusCode,
@@ -132,7 +133,7 @@ impl<'a> AdminAppState<'a> {
code: &str,
state_nonce: &str,
pkce_verifier: Option<&str>,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
crate::handlers::admin::provider::oauth::state::exchange_admin_provider_oauth_code(
self,
@@ -140,7 +141,7 @@ impl<'a> AdminAppState<'a> {
code,
state_nonce,
pkce_verifier,
proxy_node_id,
proxy,
)
.await
}
@@ -149,13 +150,13 @@ impl<'a> AdminAppState<'a> {
&self,
template: AdminProviderOAuthTemplate,
refresh_token: &str,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
crate::handlers::admin::provider::oauth::state::exchange_admin_provider_oauth_refresh_token(
self,
template,
refresh_token,
proxy_node_id,
proxy,
)
.await
}
@@ -315,7 +316,7 @@ impl<'a> AdminAppState<'a> {
&self,
region: &str,
start_url: &str,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let payload = post_kiro_device_oidc_json(
self,
@@ -337,7 +338,7 @@ impl<'a> AdminAppState<'a> {
],
"issuerUrl": start_url,
}),
proxy_node_id,
proxy,
)
.await?;
if payload
@@ -364,7 +365,7 @@ impl<'a> AdminAppState<'a> {
client_id: &str,
client_secret: &str,
start_url: &str,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let payload = post_kiro_device_oidc_json(
self,
@@ -375,7 +376,7 @@ impl<'a> AdminAppState<'a> {
"clientSecret": client_secret,
"startUrl": start_url,
}),
proxy_node_id,
proxy,
)
.await?;
if payload
@@ -402,7 +403,7 @@ impl<'a> AdminAppState<'a> {
client_id: &str,
client_secret: &str,
device_code: &str,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
post_kiro_device_oidc_json(
self,
@@ -414,11 +415,35 @@ impl<'a> AdminAppState<'a> {
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
"deviceCode": device_code,
}),
proxy_node_id,
proxy,
)
.await
}
pub(crate) async fn resolve_admin_provider_oauth_operation_proxy_snapshot(
&self,
temporary_proxy_node_id: Option<&str>,
configured_proxies: &[Option<&serde_json::Value>],
) -> Option<ProxySnapshot> {
if let Some(snapshot) = self
.resolve_admin_proxy_node_snapshot(temporary_proxy_node_id)
.await
{
return Some(snapshot);
}
for proxy in configured_proxies {
if let Some(snapshot) = self
.app
.resolve_configured_proxy_snapshot_with_tunnel_affinity(*proxy)
.await
{
return Some(snapshot);
}
}
self.app.resolve_system_proxy_snapshot().await
}
pub(crate) async fn find_duplicate_provider_oauth_key(
&self,
provider_id: &str,
@@ -504,7 +529,7 @@ async fn post_kiro_device_oidc_json(
endpoint_key: &str,
default_url: String,
body: serde_json::Value,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<serde_json::Value, Response<Body>> {
let url = state.provider_oauth_token_url(endpoint_key, &default_url);
let host = Url::parse(&url)
@@ -539,7 +564,7 @@ async fn post_kiro_device_oidc_json(
Some("application/json"),
Some(body),
None,
proxy_node_id,
proxy,
)
.await
.map_err(|_| {
@@ -570,7 +595,7 @@ impl<'a> AdminAppState<'a> {
content_type: Option<&str>,
json_body: Option<serde_json::Value>,
body_bytes: Option<Vec<u8>>,
proxy_node_id: Option<&str>,
proxy: Option<ProxySnapshot>,
) -> Result<AdminProviderOAuthHttpResponse, String> {
let body = if let Some(json_body) = json_body {
RequestBody::from_json(json_body)
@@ -581,6 +606,7 @@ impl<'a> AdminAppState<'a> {
body_ref: None,
}
};
let timeout_ms = admin_provider_oauth_timeout_ms(proxy.as_ref());
let plan = ExecutionPlan {
request_id: request_id.to_string(),
candidate_id: None,
@@ -601,21 +627,14 @@ impl<'a> AdminAppState<'a> {
client_api_format: "provider_oauth:exchange".to_string(),
provider_api_format: "provider_oauth:exchange".to_string(),
model_name: Some("oauth-exchange".to_string()),
proxy: if proxy_node_id
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
self.resolve_admin_proxy_node_snapshot(proxy_node_id).await
} else {
self.app.resolve_system_proxy_snapshot().await
},
proxy,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
read_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
write_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
pool_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
total_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
connect_ms: Some(timeout_ms),
read_ms: Some(timeout_ms),
write_ms: Some(timeout_ms),
pool_ms: Some(timeout_ms),
total_ms: Some(timeout_ms),
..ExecutionTimeouts::default()
}),
};
@@ -632,6 +651,14 @@ impl<'a> AdminAppState<'a> {
}
}
fn admin_provider_oauth_timeout_ms(proxy: Option<&ProxySnapshot>) -> u64 {
if proxy.is_some() {
ADMIN_PROVIDER_OAUTH_PROXY_TIMEOUT_MS
} else {
ADMIN_PROVIDER_OAUTH_TIMEOUT_MS
}
}
fn maybe_insert_host_header(
mut headers: reqwest::header::HeaderMap,
host: &str,

View File

@@ -82,6 +82,15 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn resolve_transport_proxy_source_with_tunnel_affinity(
&self,
transport: &AdminGatewayProviderTransportSnapshot,
) -> Option<&'static str> {
self.app
.resolve_transport_proxy_source_with_tunnel_affinity(transport)
.await
}
pub(crate) fn fixed_provider_template(
&self,
provider_type: &str,
@@ -115,15 +124,14 @@ impl<'a> AdminAppState<'a> {
return Some(snapshot);
}
if explicit_node_id.is_none() {
if let Some(snapshot) = self.app.resolve_system_proxy_snapshot().await {
return Some(snapshot);
}
let proxy = connector_config
.and_then(|config| config.get("proxy"))
.and_then(admin_provider_transport_proxy_snapshot);
if proxy.is_some() {
return proxy;
}
connector_config
.and_then(|config| config.get("proxy"))
.and_then(admin_provider_transport_legacy_proxy_snapshot)
self.app.resolve_system_proxy_snapshot().await
}
pub(crate) async fn resolve_admin_proxy_node_snapshot(
@@ -256,65 +264,46 @@ impl<'a> AdminAppState<'a> {
}
}
fn admin_provider_transport_legacy_proxy_snapshot(value: &Value) -> Option<ProxySnapshot> {
match value {
Value::String(proxy_url) => {
let proxy_url = proxy_url.trim();
if proxy_url.is_empty() {
return None;
}
Some(ProxySnapshot {
enabled: Some(true),
mode: admin_provider_transport_proxy_mode(Some(proxy_url)),
node_id: None,
label: None,
url: Some(proxy_url.to_string()),
extra: None,
})
}
Value::Object(object) => {
if object.get("enabled").and_then(Value::as_bool) == Some(false) {
return None;
}
let proxy_url = object
.get("url")
.or_else(|| object.get("proxy_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let username = object
.get("username")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let password = object
.get("password")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
Some(ProxySnapshot {
enabled: Some(true),
mode: object
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| admin_provider_transport_proxy_mode(Some(proxy_url))),
node_id: None,
label: object
.get("label")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
url: admin_provider_transport_inject_proxy_auth(proxy_url, username, password)
.or_else(|| Some(proxy_url.to_string())),
extra: None,
})
}
_ => None,
fn admin_provider_transport_proxy_snapshot(value: &Value) -> Option<ProxySnapshot> {
let object = value.as_object()?;
if object.get("enabled").and_then(Value::as_bool) == Some(false) {
return None;
}
let proxy_url = object
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let username = object
.get("username")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let password = object
.get("password")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
Some(ProxySnapshot {
enabled: Some(true),
mode: object
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| admin_provider_transport_proxy_mode(Some(proxy_url))),
node_id: None,
label: object
.get("label")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
url: admin_provider_transport_inject_proxy_auth(proxy_url, username, password)
.or_else(|| Some(proxy_url.to_string())),
extra: None,
})
}
fn admin_provider_transport_inject_proxy_auth(
@@ -354,3 +343,50 @@ fn admin_provider_transport_string_field(config: &Map<String, Value>, key: &str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use aether_contracts::ProxySnapshot;
use serde_json::json;
use super::admin_provider_transport_proxy_snapshot;
#[test]
fn connector_proxy_snapshot_requires_object_value() {
assert_eq!(
admin_provider_transport_proxy_snapshot(&json!("http://proxy.example:8080")),
None
);
}
#[test]
fn connector_proxy_snapshot_requires_url_field() {
assert_eq!(
admin_provider_transport_proxy_snapshot(&json!({
"proxy_url": "http://proxy.example:8080"
})),
None
);
}
#[test]
fn connector_proxy_snapshot_keeps_current_object_shape() {
assert_eq!(
admin_provider_transport_proxy_snapshot(&json!({
"url": "http://proxy.example:8080",
"username": "alice",
"password": "secret",
"mode": "http",
"label": "manual"
})),
Some(ProxySnapshot {
enabled: Some(true),
mode: Some("http".to_string()),
node_id: None,
label: Some("manual".to_string()),
url: Some("http://alice:secret@proxy.example:8080/".to_string()),
extra: None,
})
);
}
}

View File

@@ -46,6 +46,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const ADMIN_SYSTEM_IMPORT_MAX_SIZE_BYTES: usize = 10 * 1024 * 1024;
const MIN_ADMIN_SYSTEM_IMPORT_VERSION: (u32, u32) = (2, 2);
fn invalid_request(detail: impl Into<String>) -> (http::StatusCode, Value) {
(
@@ -342,19 +343,33 @@ struct ImportedWalletTarget {
updated_at_unix_secs: Option<u64>,
}
fn imported_users_export_is_legacy(version: Option<&Value>) -> bool {
fn imported_system_export_version(version: Option<&Value>) -> Result<(u32, u32), String> {
let Some(Value::String(version)) = version else {
return true;
return Err("version 必须是 x.y 字符串".to_string());
};
let version = version.trim();
if version.is_empty() {
return Err("version 必须是 x.y 字符串".to_string());
}
let mut parts = version.split('.');
let Some(major) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return true;
return Err("version 必须是 x.y 字符串".to_string());
};
let Some(minor) = parts.next().and_then(|value| value.parse::<u32>().ok()) else {
return true;
return Err("version 必须是 x.y 字符串".to_string());
};
(major, minor) < (1, 3)
Ok((major, minor))
}
fn validate_imported_system_export_version(version: Option<&Value>) -> Result<(), String> {
let parsed = imported_system_export_version(version)?;
if parsed < MIN_ADMIN_SYSTEM_IMPORT_VERSION {
return Err(format!(
"version {}.{} 已不再支持;仅支持 2.2+ 导出格式",
parsed.0, parsed.1
));
}
Ok(())
}
fn imported_object_field<'a>(
@@ -385,11 +400,6 @@ fn imported_optional_bool(value: Option<&Value>) -> Result<Option<bool>, String>
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(value)) => Ok(Some(*value)),
Some(Value::String(raw)) => match raw.trim().to_ascii_lowercase().as_str() {
"true" => Ok(Some(true)),
"false" => Ok(Some(false)),
_ => Err("字段必须是布尔值".to_string()),
},
_ => Err("字段必须是布尔值".to_string()),
}
}
@@ -402,11 +412,6 @@ fn imported_optional_i32(value: Option<&Value>, field_name: &str) -> Result<Opti
.ok_or_else(|| format!("{field_name} 必须是整数"))
.and_then(|value| i32::try_from(value).map_err(|_| format!("{field_name} 超出范围")))
.map(Some),
Some(Value::String(raw)) => raw
.trim()
.parse::<i32>()
.map(Some)
.map_err(|_| format!("{field_name} 必须是整数")),
_ => Err(format!("{field_name} 必须是整数")),
}
}
@@ -418,11 +423,6 @@ fn imported_optional_u64(value: Option<&Value>, field_name: &str) -> Result<Opti
.as_u64()
.ok_or_else(|| format!("{field_name} 必须是非负整数"))
.map(Some),
Some(Value::String(raw)) => raw
.trim()
.parse::<u64>()
.map(Some)
.map_err(|_| format!("{field_name} 必须是非负整数")),
_ => Err(format!("{field_name} 必须是非负整数")),
}
}
@@ -435,13 +435,6 @@ fn imported_optional_f64(value: Option<&Value>, field_name: &str) -> Result<Opti
.filter(|value| value.is_finite())
.ok_or_else(|| format!("{field_name} 必须是有限数值"))
.map(Some),
Some(Value::String(raw)) => raw
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.ok_or_else(|| format!("{field_name} 必须是有限数值"))
.map(Some),
_ => Err(format!("{field_name} 必须是有限数值")),
}
}
@@ -491,16 +484,6 @@ fn imported_string_list_from_value(
.map(ToOwned::to_owned)
.collect(),
)),
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") {
return Ok(None);
}
if let Ok(decoded) = serde_json::from_str::<Value>(trimmed) {
return imported_string_list_from_value(Some(&decoded), field_name);
}
Ok(Some(vec![trimmed.to_string()]))
}
_ => Err(format!("{field_name} 必须是字符串列表")),
}
}
@@ -1584,7 +1567,6 @@ impl<'a> AdminAppState<'a> {
)))
}
};
let legacy_export = imported_users_export_is_legacy(root.get("version"));
let empty = Vec::new();
let users = match root.get("users") {
Some(Value::Array(items)) => items,
@@ -1614,6 +1596,8 @@ impl<'a> AdminAppState<'a> {
};
}
invalid_value!(validate_imported_system_export_version(root.get("version")));
let mut stats = AdminSystemUsersImportStats::default();
for (index, raw_user) in users.iter().enumerate() {
@@ -1883,7 +1867,7 @@ impl<'a> AdminAppState<'a> {
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(if legacy_export { 0 } else { 0 });
.unwrap_or(0);
let concurrent_limit = invalid_value!(imported_optional_i32(
key.get("concurrent_limit"),
"concurrent_limit"
@@ -2063,7 +2047,7 @@ impl<'a> AdminAppState<'a> {
invalid_value!(normalize_imported_user_string_list(key, "allowed_models"));
let rate_limit =
invalid_value!(imported_optional_i32(key.get("rate_limit"), "rate_limit"))
.unwrap_or(if legacy_export { 0 } else { 0 });
.unwrap_or(0);
let concurrent_limit = invalid_value!(imported_optional_i32(
key.get("concurrent_limit"),
"concurrent_limit"
@@ -2321,3 +2305,64 @@ enum WalletOwner<'a> {
User(&'a str),
ApiKey(&'a str),
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
imported_optional_bool, imported_optional_f64, imported_optional_i32,
imported_optional_u64, imported_string_list_from_value,
validate_imported_system_export_version,
};
#[test]
fn import_requires_supported_export_version() {
assert!(validate_imported_system_export_version(Some(&json!("2.2"))).is_ok());
assert_eq!(
validate_imported_system_export_version(Some(&json!("2.1"))).unwrap_err(),
"version 2.1 已不再支持;仅支持 2.2+ 导出格式"
);
assert_eq!(
validate_imported_system_export_version(Some(&json!(null))).unwrap_err(),
"version 必须是 x.y 字符串"
);
}
#[test]
fn import_rejects_legacy_string_scalars() {
assert_eq!(
imported_optional_bool(Some(&json!("true"))).unwrap_err(),
"字段必须是布尔值"
);
assert_eq!(
imported_optional_i32(Some(&json!("5")), "rate_limit").unwrap_err(),
"rate_limit 必须是整数"
);
assert_eq!(
imported_optional_u64(Some(&json!("5")), "total_requests").unwrap_err(),
"total_requests 必须是非负整数"
);
assert_eq!(
imported_optional_f64(Some(&json!("1.25")), "total_cost_usd").unwrap_err(),
"total_cost_usd 必须是有限数值"
);
}
#[test]
fn import_rejects_legacy_string_lists() {
assert_eq!(
imported_string_list_from_value(Some(&json!("openai")), "allowed_providers")
.unwrap_err(),
"allowed_providers 必须是字符串列表"
);
assert_eq!(
imported_string_list_from_value(
Some(&json!("[\"openai:chat\"]")),
"allowed_api_formats"
)
.unwrap_err(),
"allowed_api_formats 必须是字符串列表"
);
}
}

View File

@@ -6,4 +6,6 @@ mod proxy_nodes;
mod routes;
pub(super) mod shared;
#[cfg(test)]
pub(crate) use self::proxy_nodes::override_proxy_connectivity_probe_url_for_tests;
pub(super) use self::routes::maybe_build_local_admin_system_response;

View File

@@ -1,5 +1,6 @@
use std::time::{Duration, Instant};
use crate::execution_runtime::transport::format_upstream_request_error;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value;
use crate::maintenance::{
@@ -12,6 +13,9 @@ use aether_admin::system::{
admin_proxy_node_event_node_id_from_path, build_admin_proxy_node_payload,
build_admin_proxy_nodes_data_unavailable_response, build_admin_proxy_nodes_not_found_response,
};
use aether_contracts::tunnel::{
TUNNEL_RELAY_FORWARDED_BY_HEADER, TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
};
use axum::{
body::{Body, Bytes},
http,
@@ -21,7 +25,6 @@ use axum::{
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::{net::TcpStream, time::timeout};
#[derive(Debug, Deserialize)]
struct ProxyNodeRegisterRequest {
@@ -131,6 +134,61 @@ const JSON_OBJECT_REQUIRED_DETAIL: &str = "请求体必须是合法的 JSON 对
const DEFAULT_PROXY_UPGRADE_BATCH_SIZE: usize = 1;
const DEFAULT_PROXY_UPGRADE_COOLDOWN_SECS: u64 = 60;
const DEFAULT_PROXY_UPGRADE_PROBE_TIMEOUT_SECS: u64 = 10;
const DEFAULT_PROXY_CONNECTIVITY_PROBE_URL: &str = "https://www.cloudflare.com/cdn-cgi/trace";
const PROXY_CONNECTIVITY_TIMEOUT_SECS: u64 = 10;
const TUNNEL_RELAY_ENVELOPE_CONTENT_TYPE: &str = "application/vnd.aether.tunnel-envelope";
const MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES: usize = 64 * 1024;
#[cfg(test)]
fn manual_proxy_connectivity_probe_url_override() -> &'static std::sync::RwLock<Option<String>> {
static OVERRIDE: std::sync::OnceLock<std::sync::RwLock<Option<String>>> =
std::sync::OnceLock::new();
OVERRIDE.get_or_init(|| std::sync::RwLock::new(None))
}
#[cfg(test)]
fn manual_proxy_connectivity_probe_url_override_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
fn proxy_connectivity_probe_url() -> String {
#[cfg(test)]
if let Some(url) = manual_proxy_connectivity_probe_url_override()
.read()
.expect("probe url override lock should read")
.clone()
{
return url;
}
DEFAULT_PROXY_CONNECTIVITY_PROBE_URL.to_string()
}
#[cfg(test)]
pub(crate) struct ProxyConnectivityProbeUrlOverrideGuard(std::sync::MutexGuard<'static, ()>);
#[cfg(test)]
pub(crate) fn override_proxy_connectivity_probe_url_for_tests(
url: impl Into<String>,
) -> ProxyConnectivityProbeUrlOverrideGuard {
let guard = manual_proxy_connectivity_probe_url_override_lock()
.lock()
.expect("probe url override lock should acquire");
*manual_proxy_connectivity_probe_url_override()
.write()
.expect("probe url override lock should write") = Some(url.into());
ProxyConnectivityProbeUrlOverrideGuard(guard)
}
#[cfg(test)]
impl Drop for ProxyConnectivityProbeUrlOverrideGuard {
fn drop(&mut self) {
*manual_proxy_connectivity_probe_url_override()
.write()
.expect("probe url override lock should write") = None;
}
}
pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
state: &AdminAppState<'_>,
@@ -187,6 +245,26 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
));
}
if decision.route_kind.as_deref() == Some("get_node")
&& request_context.method() == http::Method::GET
{
if !state.has_proxy_node_reader() {
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let Some(node_id) = admin_proxy_node_node_id_from_path(request_context.path()) else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
let Some(node) = state.find_proxy_node(&node_id).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
return Ok(Some(
Json(json!({
"node": build_admin_proxy_node_detail_payload(&node),
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("register_node")
&& request_context.method() == http::Method::POST
{
@@ -367,7 +445,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
return Ok(Some(
Json(test_proxy_node_connectivity(&node).await).into_response(),
Json(test_proxy_node_connectivity(state, &node).await).into_response(),
));
}
@@ -383,15 +461,7 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
Err(response) => return Ok(Some(response)),
};
return Ok(Some(
Json(
test_manual_proxy_connectivity(
&normalized.proxy_url,
normalized.host.as_str(),
normalized.port,
)
.await,
)
.into_response(),
Json(test_manual_proxy_connectivity(&normalized).await).into_response(),
));
}
@@ -716,6 +786,18 @@ struct DeletedProxyNodeCleanup {
cleared_keys: usize,
}
fn build_admin_proxy_node_detail_payload(
node: &aether_data::repository::proxy_nodes::StoredProxyNode,
) -> Value {
let mut payload = build_admin_proxy_node_payload(node);
if node.is_manual {
if let Value::Object(object) = &mut payload {
object.insert("proxy_password".to_string(), json!(node.proxy_password));
}
}
payload
}
#[derive(Debug, Clone)]
struct NormalizedManualProxyEndpoint {
proxy_url: String,
@@ -832,6 +914,7 @@ fn proxy_reference_matches_node_id(value: Option<&Value>, node_id: &str) -> bool
}
async fn test_proxy_node_connectivity(
state: &AdminAppState<'_>,
node: &aether_data::repository::proxy_nodes::StoredProxyNode,
) -> Value {
if node.is_manual {
@@ -854,12 +937,13 @@ async fn test_proxy_node_connectivity(
});
}
};
return test_manual_proxy_connectivity(
let proxy_url = proxy_url_with_auth(
&endpoint.proxy_url,
endpoint.host.as_str(),
endpoint.port,
node.proxy_username.as_deref(),
node.proxy_password.as_deref(),
)
.await;
.unwrap_or(endpoint.proxy_url);
return test_manual_proxy_connectivity(&proxy_url).await;
}
if !node.tunnel_mode {
@@ -880,39 +964,269 @@ async fn test_proxy_node_connectivity(
});
}
let probe_url = proxy_connectivity_probe_url();
match probe_tunnel_proxy_connectivity(state.app(), &node.id, &probe_url).await {
Ok(result) => {
if let Ok(status) = reqwest::StatusCode::from_u16(result.status) {
if status.is_success() {
return json!({
"success": true,
"latency_ms": result.latency_ms,
"exit_ip": parse_proxy_probe_exit_ip(&result.body),
"error": null,
});
}
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_proxy_probe_status_error(status, &result.body)),
});
}
json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": format!("代理探测返回非法状态码: {}", result.status),
})
}
Err(error) => json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&error),
}),
}
}
async fn test_manual_proxy_connectivity(proxy_url: &str) -> Value {
let probe_url = proxy_connectivity_probe_url();
test_manual_proxy_connectivity_with_probe_url(proxy_url, &probe_url).await
}
async fn test_manual_proxy_connectivity_with_probe_url(proxy_url: &str, probe_url: &str) -> Value {
let started_at = Instant::now();
let proxy = match reqwest::Proxy::all(proxy_url) {
Ok(proxy) => proxy,
Err(error) => {
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_upstream_request_error(&error)),
});
}
};
let mut builder = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(PROXY_CONNECTIVITY_TIMEOUT_SECS))
.proxy(proxy)
.user_agent("aether-gateway/proxy-connectivity");
if proxy_url
.trim()
.to_ascii_lowercase()
.starts_with("https://")
{
builder = builder.danger_accept_invalid_certs(true);
}
let client = match builder.build() {
Ok(client) => client,
Err(error) => {
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_upstream_request_error(&error)),
});
}
};
let response = match client.get(probe_url).send().await {
Ok(response) => response,
Err(error) => {
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_upstream_request_error(&error)),
});
}
};
let status = response.status();
let body = match response.text().await {
Ok(body) => body,
Err(error) => {
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_upstream_request_error(&error)),
});
}
};
if !status.is_success() {
return json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&format_proxy_probe_status_error(status, &body)),
});
}
json!({
"success": true,
"latency_ms": node.avg_latency_ms.map(|value| value.max(0.0).round() as u64),
"exit_ip": null,
"latency_ms": started_at.elapsed().as_millis() as u64,
"exit_ip": parse_proxy_probe_exit_ip(&body),
"error": null,
})
}
async fn test_manual_proxy_connectivity(_proxy_url: &str, host: &str, port: u16) -> Value {
let started_at = Instant::now();
match timeout(Duration::from_secs(5), TcpStream::connect((host, port))).await {
Ok(Ok(stream)) => {
drop(stream);
json!({
"success": true,
"latency_ms": started_at.elapsed().as_millis() as u64,
"exit_ip": null,
"error": null,
})
}
Ok(Err(error)) => json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": sanitize_proxy_error(&error.to_string()),
}),
Err(_) => json!({
"success": false,
"latency_ms": null,
"exit_ip": null,
"error": "连接超时",
}),
struct TunnelConnectivityProbeResult {
status: u16,
body: String,
latency_ms: u64,
}
async fn probe_tunnel_proxy_connectivity(
state: &crate::AppState,
node_id: &str,
probe_url: &str,
) -> Result<TunnelConnectivityProbeResult, String> {
let trimmed_node_id = node_id.trim();
if trimmed_node_id.is_empty() {
return Err("proxy node id is empty".to_string());
}
if state.tunnel.has_local_proxy(trimmed_node_id) {
return probe_tunnel_proxy_connectivity_locally(state, trimmed_node_id, probe_url).await;
}
if let Some(owner) = state
.tunnel
.lookup_attachment_owner(state.data.as_ref(), trimmed_node_id)
.await
.map_err(|err| format!("lookup tunnel attachment owner failed: {err}"))?
{
if owner.gateway_instance_id != state.tunnel.local_instance_id() {
return probe_tunnel_proxy_connectivity_via_owner(
state,
trimmed_node_id,
probe_url,
&owner.relay_base_url,
&owner.gateway_instance_id,
)
.await;
}
state
.tunnel
.clear_local_attachment_if_stale(state.data.as_ref(), trimmed_node_id)
.await
.map_err(|err| format!("clear stale local tunnel attachment failed: {err}"))?;
}
probe_tunnel_proxy_connectivity_locally(state, trimmed_node_id, probe_url).await
}
async fn probe_tunnel_proxy_connectivity_locally(
state: &crate::AppState,
node_id: &str,
probe_url: &str,
) -> Result<TunnelConnectivityProbeResult, String> {
let started_at = Instant::now();
let result = state
.tunnel
.probe_node_url_with_response(node_id, probe_url, PROXY_CONNECTIVITY_TIMEOUT_SECS)
.await?;
Ok(TunnelConnectivityProbeResult {
status: result.status,
body: result.body,
latency_ms: started_at.elapsed().as_millis() as u64,
})
}
async fn probe_tunnel_proxy_connectivity_via_owner(
state: &crate::AppState,
node_id: &str,
probe_url: &str,
relay_base_url: &str,
owner_instance_id: &str,
) -> Result<TunnelConnectivityProbeResult, String> {
let owner_url = build_tunnel_owner_relay_url(relay_base_url, node_id)?;
let started_at = Instant::now();
let response = state
.client
.post(owner_url)
.header(
http::header::CONTENT_TYPE,
TUNNEL_RELAY_ENVELOPE_CONTENT_TYPE,
)
.header(
TUNNEL_RELAY_FORWARDED_BY_HEADER,
state.tunnel.local_instance_id(),
)
.header(TUNNEL_RELAY_OWNER_INSTANCE_HEADER, owner_instance_id)
.timeout(Duration::from_secs(PROXY_CONNECTIVITY_TIMEOUT_SECS))
.body(build_tunnel_probe_relay_envelope(probe_url)?)
.send()
.await
.map_err(|error| format!("owner tunnel relay probe failed: {error}"))?;
let status = response.status();
let body = response
.bytes()
.await
.map_err(|error| format!("failed to read owner tunnel relay probe body: {error}"))?;
if body.len() > MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES {
return Err(format!(
"owner tunnel relay probe body exceeds {} bytes",
MAX_PROXY_CONNECTIVITY_RESPONSE_BYTES
));
}
Ok(TunnelConnectivityProbeResult {
status: status.as_u16(),
body: String::from_utf8_lossy(&body).to_string(),
latency_ms: started_at.elapsed().as_millis() as u64,
})
}
fn build_tunnel_probe_relay_envelope(probe_url: &str) -> Result<Vec<u8>, String> {
let meta = crate::tunnel::tunnel_protocol::RequestMeta {
method: "GET".to_string(),
url: probe_url.trim().to_string(),
headers: std::collections::HashMap::new(),
timeout: PROXY_CONNECTIVITY_TIMEOUT_SECS,
follow_redirects: Some(false),
http1_only: false,
};
let meta_bytes = serde_json::to_vec(&meta)
.map_err(|error| format!("encode tunnel probe metadata failed: {error}"))?;
let mut envelope = Vec::with_capacity(4 + meta_bytes.len());
envelope.extend_from_slice(&(meta_bytes.len() as u32).to_be_bytes());
envelope.extend_from_slice(&meta_bytes);
Ok(envelope)
}
fn build_tunnel_owner_relay_url(relay_base_url: &str, node_id: &str) -> Result<String, String> {
let mut url = url::Url::parse(relay_base_url)
.map_err(|error| format!("invalid owner relay base url: {error}"))?;
{
let mut segments = url
.path_segments_mut()
.map_err(|_| "owner relay base url cannot be a base-less URL".to_string())?;
segments.pop_if_empty();
segments.push("api");
segments.push("internal");
segments.push("tunnel");
segments.push("relay");
segments.push(node_id.trim());
}
Ok(url.to_string())
}
fn validate_register_request(
@@ -1039,10 +1353,16 @@ fn validate_manual_update_request(
fn validate_proxy_test_url_request(
input: ProxyNodeTestUrlRequest,
) -> Result<NormalizedManualProxyEndpoint, Response<Body>> {
let _ = normalize_optional_string(input.username.as_deref(), "username", 255)?;
let _ = normalize_optional_string(input.password.as_deref(), "password", 500)?;
normalize_manual_proxy_endpoint(&input.proxy_url)
) -> Result<String, Response<Body>> {
let username = normalize_optional_string(input.username.as_deref(), "username", 255)?;
let password = normalize_optional_string(input.password.as_deref(), "password", 500)?;
let endpoint = normalize_manual_proxy_endpoint(&input.proxy_url)?;
Ok(proxy_url_with_auth(
&endpoint.proxy_url,
username.as_deref(),
password.as_deref(),
)
.unwrap_or(endpoint.proxy_url))
}
fn admin_proxy_node_upgrade_action_node_id_from_path(path: &str, suffix: &str) -> Option<String> {
@@ -1425,6 +1745,53 @@ fn sanitize_proxy_error(detail: &str) -> String {
}
}
fn proxy_url_with_auth(
proxy_url: &str,
username: Option<&str>,
password: Option<&str>,
) -> Option<String> {
let username = username.map(str::trim).filter(|value| !value.is_empty())?;
let mut parsed = url::Url::parse(proxy_url).ok()?;
if parsed.set_username(username).is_err() {
return None;
}
let password = password.map(str::trim).filter(|value| !value.is_empty());
if parsed.set_password(password).is_err() {
return None;
}
Some(parsed.to_string())
}
fn parse_proxy_probe_exit_ip(body: &str) -> Option<String> {
body.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
if key.trim() != "ip" {
return None;
}
let value = value.trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
})
}
fn format_proxy_probe_status_error(status: reqwest::StatusCode, body: &str) -> String {
let body = body.trim();
if body.is_empty() {
return format!("代理探测返回 HTTP {}", status.as_u16());
}
let truncated = if body.chars().count() > 200 {
let shortened: String = body.chars().take(200).collect();
format!("{shortened}...")
} else {
body.to_string()
};
format!("代理探测返回 HTTP {}: {truncated}", status.as_u16())
}
fn validate_optional_counter(value: Option<i64>, field: &str) -> Result<(), Response<Body>> {
if value.is_some_and(|value| value < 0) {
return Err(bad_request_response(format!("{field} 必须是非负整数")));

View File

@@ -351,12 +351,10 @@ pub(crate) fn gateway_error_message(error: GatewayError) -> String {
pub(crate) fn build_internal_tunnel_heartbeat_ack(
node: &StoredProxyNode,
heartbeat_id: Option<u64>,
heartbeat_id: u64,
) -> serde_json::Value {
let mut payload = serde_json::Map::new();
if let Some(heartbeat_id) = heartbeat_id {
payload.insert("heartbeat_id".to_string(), json!(heartbeat_id));
}
payload.insert("heartbeat_id".to_string(), json!(heartbeat_id));
if let Some(remote_config) = node.remote_config.as_ref() {
payload.insert("remote_config".to_string(), remote_config.clone());
payload.insert("config_version".to_string(), json!(node.config_version));
@@ -385,7 +383,7 @@ pub(crate) fn parse_internal_tunnel_heartbeat_request(
})?;
let node_id = payload.node_id.trim();
if node_id.is_empty() || node_id.len() > 36 {
if node_id.is_empty() || node_id.len() > 36 || payload.heartbeat_id == 0 {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"invalid heartbeat payload",

View File

@@ -4,8 +4,7 @@ use std::collections::BTreeMap;
#[derive(Debug, Deserialize)]
pub(crate) struct InternalTunnelHeartbeatRequest {
pub(crate) node_id: String,
#[serde(default)]
pub(crate) heartbeat_id: Option<u64>,
pub(crate) heartbeat_id: u64,
#[serde(default)]
pub(crate) heartbeat_interval: Option<i32>,
#[serde(default)]