mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(proxy): 重构 Proxy 节点管理与隧道系统
- 重构 proxy_nodes 管理端,支持节点注册、心跳、隧道生命周期管理 - 增强 tunnel 嵌入式 hub 和隧道协议 - 重构 aether-proxy 配置、隧道客户端、心跳和调度机制 - 调整 admin OAuth/配额/导入等处理器的参数传递 - 扩展数据迁移模块 - 补充 proxy nodes、OAuth、配额、系统导入等测试 - 更新前端 proxy nodes 视图和 API
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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(
|
||||
®ion,
|
||||
&start_url,
|
||||
payload.proxy_node_id.as_deref(),
|
||||
)
|
||||
.register_admin_kiro_device_oidc_client(®ion, &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
|
||||
{
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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 对象"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 必须是字符串列表"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} 必须是非负整数")));
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user