mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(admin): 完善代理节点与 OAuth 授权管理
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
use tracing::warn;
|
||||
|
||||
use crate::ai_pipeline::transport::resolve_transport_proxy_snapshot;
|
||||
use crate::ai_pipeline::{
|
||||
GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot, PlannerAppState,
|
||||
};
|
||||
@@ -232,7 +231,11 @@ async fn resolve_tunnel_owner_affinity_from_transport(
|
||||
state: PlannerAppState<'_>,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> TunnelOwnerAffinityBucket {
|
||||
let Some(proxy) = resolve_transport_proxy_snapshot(transport) else {
|
||||
let Some(proxy) = state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||
.await
|
||||
else {
|
||||
return TunnelOwnerAffinityBucket::Neutral;
|
||||
};
|
||||
if proxy.enabled == Some(false) {
|
||||
|
||||
@@ -12,8 +12,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_same_format_provider_spec_metadata;
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
|
||||
use crate::{
|
||||
@@ -56,8 +55,9 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &resolved.transport).await;
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if resolved.is_kiro {
|
||||
|
||||
@@ -8,8 +8,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_gemini_files_spec_metadata;
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, PlannerAppState};
|
||||
use crate::{AppState, GatewayControlSyncDecisionResponse};
|
||||
@@ -53,9 +52,10 @@ pub(super) async fn maybe_build_local_gemini_files_decision_payload_for_candidat
|
||||
} = attempt;
|
||||
let candidate = eligible.candidate;
|
||||
let transport = resolved.transport;
|
||||
let proxy =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(planner_state.app(), &transport)
|
||||
.await;
|
||||
let proxy = planner_state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
extra_fields.insert("file_key_id".to_string(), json!(candidate.key_id));
|
||||
|
||||
@@ -6,8 +6,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_video_create_spec_metadata;
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, PlannerAppState};
|
||||
use crate::{AppState, GatewayControlSyncDecisionResponse};
|
||||
@@ -38,9 +37,10 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
||||
} = attempt;
|
||||
let candidate = eligible.candidate;
|
||||
let transport = resolved.transport;
|
||||
let proxy =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(planner_state.app(), &transport)
|
||||
.await;
|
||||
let proxy = planner_state
|
||||
.app()
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&transport);
|
||||
|
||||
Some(build_local_execution_decision_response(
|
||||
|
||||
@@ -10,8 +10,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_standard_spec_metadata;
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy};
|
||||
use crate::{
|
||||
@@ -69,11 +68,9 @@ pub(super) async fn maybe_build_local_standard_decision_payload_for_candidate(
|
||||
provider_request_body: Some(resolved.provider_request_body.clone()),
|
||||
provider_request_body_base64: None,
|
||||
content_type: Some("application/json".to_string()),
|
||||
proxy: resolve_transport_proxy_snapshot_with_tunnel_affinity(
|
||||
state,
|
||||
&resolved.transport,
|
||||
)
|
||||
.await,
|
||||
proxy: state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await,
|
||||
tls_profile: resolve_transport_tls_profile(&resolved.transport),
|
||||
timeouts: resolve_transport_execution_timeouts(&resolved.transport),
|
||||
upstream_is_stream: resolved.upstream_is_stream,
|
||||
|
||||
@@ -5,8 +5,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
build_local_execution_report_context, LocalExecutionReportContextParts,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value, AppState,
|
||||
@@ -56,8 +55,9 @@ pub(crate) async fn maybe_build_local_openai_chat_decision_payload_for_candidate
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &resolved.transport).await;
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ use crate::ai_pipeline::planner::report_context::{
|
||||
};
|
||||
use crate::ai_pipeline::planner::spec_metadata::local_openai_cli_spec_metadata;
|
||||
use crate::ai_pipeline::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_proxy_snapshot_with_tunnel_affinity,
|
||||
resolve_transport_tls_profile,
|
||||
resolve_transport_execution_timeouts, resolve_transport_tls_profile,
|
||||
};
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, append_local_failover_policy_to_value, AppState,
|
||||
@@ -57,8 +56,9 @@ pub(crate) async fn maybe_build_local_openai_cli_decision_payload_for_candidate(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let proxy =
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(state, &resolved.transport).await;
|
||||
let proxy = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&resolved.transport)
|
||||
.await;
|
||||
let tls_profile = resolve_transport_tls_profile(&resolved.transport);
|
||||
let timeouts = resolve_transport_execution_timeouts(&resolved.transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
|
||||
@@ -2,6 +2,21 @@ use http::Uri;
|
||||
|
||||
use super::{classify_control_route, headers};
|
||||
|
||||
fn assert_proxy_nodes_admin_route(method: http::Method, path: &str, route_kind: &str) {
|
||||
let headers = headers(&[]);
|
||||
let uri: Uri = path.parse().expect("uri should parse");
|
||||
let decision = classify_control_route(&method, &uri, &headers).expect("route should classify");
|
||||
|
||||
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
|
||||
assert_eq!(decision.route_family.as_deref(), Some("proxy_nodes_manage"));
|
||||
assert_eq!(decision.route_kind.as_deref(), Some(route_kind));
|
||||
assert_eq!(
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
Some("admin:proxy_nodes")
|
||||
);
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_list_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
@@ -40,6 +55,87 @@ fn classifies_admin_proxy_nodes_register_as_admin_proxy_route() {
|
||||
assert!(!decision.is_execution_runtime_candidate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_heartbeat_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/heartbeat",
|
||||
"heartbeat_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_unregister_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/unregister",
|
||||
"unregister_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_manual_create_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/manual",
|
||||
"create_manual_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_manual_update_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::PATCH,
|
||||
"/api/admin/proxy-nodes/node-1",
|
||||
"update_manual_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_delete_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::DELETE,
|
||||
"/api/admin/proxy-nodes/node-1",
|
||||
"delete_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_test_node_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/node-1/test",
|
||||
"test_node",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_test_url_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/test-url",
|
||||
"test_proxy_url",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_update_config_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::PUT,
|
||||
"/api/admin/proxy-nodes/node-1/config",
|
||||
"update_node_config",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_upgrade_start_as_admin_proxy_route() {
|
||||
assert_proxy_nodes_admin_route(
|
||||
http::Method::POST,
|
||||
"/api/admin/proxy-nodes/upgrade",
|
||||
"batch_upgrade_nodes",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_admin_proxy_nodes_events_as_admin_proxy_route() {
|
||||
let headers = headers(&[]);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::{
|
||||
AuthApiKeyLookupKey, CreateManagementTokenRecord, DataLayerError, GatewayAuthApiKeySnapshot,
|
||||
GatewayDataState, ManagementTokenListQuery, ProxyNodeHeartbeatMutation,
|
||||
ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation, ProxyNodeTunnelStatusMutation,
|
||||
RegenerateManagementTokenSecret, StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot,
|
||||
StoredLdapModuleConfig, StoredManagementToken, StoredManagementTokenListPage,
|
||||
StoredManagementTokenWithUser, StoredOAuthProviderConfig, StoredOAuthProviderModuleConfig,
|
||||
StoredProxyNode, StoredProxyNodeEvent, StoredUserAuthRecord, StoredUserPreferenceRecord,
|
||||
ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation, ProxyNodeRegistrationMutation,
|
||||
ProxyNodeRemoteConfigMutation, ProxyNodeTunnelStatusMutation, RegenerateManagementTokenSecret,
|
||||
StoredAuthApiKeyExportRecord, StoredAuthApiKeySnapshot, StoredLdapModuleConfig,
|
||||
StoredManagementToken, StoredManagementTokenListPage, StoredManagementTokenWithUser,
|
||||
StoredOAuthProviderConfig, StoredOAuthProviderModuleConfig, StoredProxyNode,
|
||||
StoredProxyNodeEvent, StoredUserAuthRecord, StoredUserPreferenceRecord,
|
||||
StoredUserSessionRecord, StoredWalletSnapshot, UpdateManagementTokenRecord,
|
||||
UpsertOAuthProviderConfigRecord,
|
||||
};
|
||||
@@ -2270,6 +2271,26 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &ProxyNodeManualCreateMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
match &self.proxy_node_writer {
|
||||
Some(repository) => repository.create_manual_node(mutation).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn update_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &ProxyNodeManualUpdateMutation,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
match &self.proxy_node_writer {
|
||||
Some(repository) => repository.update_manual_node(mutation).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn reset_stale_proxy_node_tunnel_statuses(
|
||||
&self,
|
||||
) -> Result<usize, DataLayerError> {
|
||||
@@ -2309,6 +2330,16 @@ impl GatewayDataState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<StoredProxyNode>, DataLayerError> {
|
||||
match &self.proxy_node_writer {
|
||||
Some(repository) => repository.delete_node(node_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn update_proxy_node_remote_config(
|
||||
&self,
|
||||
mutation: &ProxyNodeRemoteConfigMutation,
|
||||
|
||||
@@ -40,9 +40,9 @@ use aether_data::repository::oauth_providers::{
|
||||
UpsertOAuthProviderConfigRecord,
|
||||
};
|
||||
use aether_data::repository::proxy_nodes::{
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeReadRepository, ProxyNodeRegistrationMutation,
|
||||
ProxyNodeRemoteConfigMutation, ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository,
|
||||
StoredProxyNode, StoredProxyNodeEvent,
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation,
|
||||
ProxyNodeReadRepository, ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation,
|
||||
ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
pub(crate) use aether_data::repository::system::{AdminSystemStats, StoredSystemConfigEntry};
|
||||
use aether_data::repository::users::{
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::handlers::admin::users::{
|
||||
normalize_admin_user_string_list,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::system::serialize_admin_system_users_export_wallet;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -22,12 +23,48 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn parse_standalone_api_key_expires_at(value: Option<&str>) -> Result<Option<u64>, String> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
|
||||
let Some(expires_at) = date.and_hms_opt(23, 59, 59) else {
|
||||
return Err("expires_at 超出有效时间范围".to_string());
|
||||
};
|
||||
return u64::try_from(expires_at.and_utc().timestamp())
|
||||
.map(Some)
|
||||
.map_err(|_| "expires_at 超出有效时间范围".to_string());
|
||||
}
|
||||
let parsed = chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| "expires_at 必须是 YYYY-MM-DD 或 RFC3339 时间".to_string())?;
|
||||
u64::try_from(parsed.timestamp())
|
||||
.map(Some)
|
||||
.map_err(|_| "expires_at 超出有效时间范围".to_string())
|
||||
}
|
||||
|
||||
fn normalize_standalone_initial_balance(
|
||||
initial_balance_usd: Option<f64>,
|
||||
unlimited_balance: Option<bool>,
|
||||
) -> Result<(f64, bool), String> {
|
||||
let unlimited = unlimited_balance.unwrap_or(initial_balance_usd.is_none());
|
||||
if unlimited {
|
||||
return Ok((0.0, true));
|
||||
}
|
||||
let Some(initial_balance_usd) = initial_balance_usd else {
|
||||
return Err("initial_balance_usd 必须大于 0".to_string());
|
||||
};
|
||||
if !initial_balance_usd.is_finite() || initial_balance_usd <= 0.0 {
|
||||
return Err("initial_balance_usd 必须大于 0".to_string());
|
||||
}
|
||||
Ok((initial_balance_usd, false))
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_create_api_key_response(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
request_body: Option<&axum::body::Bytes>,
|
||||
) -> Result<Response<Body>, GatewayError> {
|
||||
if !state.has_auth_api_key_writer() {
|
||||
if !state.has_auth_api_key_writer() || !state.has_auth_wallet_write_capability() {
|
||||
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||
}
|
||||
|
||||
@@ -47,14 +84,9 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
));
|
||||
}
|
||||
};
|
||||
if payload.initial_balance_usd.is_some()
|
||||
|| payload.unlimited_balance.is_some()
|
||||
|| payload.expire_days.is_some()
|
||||
|| payload.expires_at.is_some()
|
||||
|| payload.auto_delete_on_expiry.is_some()
|
||||
{
|
||||
if payload.expire_days.is_some() {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"当前仅支持 name、rate_limit、allowed_providers、allowed_api_formats、allowed_models 字段",
|
||||
"expire_days 暂不支持,请改用 expires_at",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -77,12 +109,29 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
let rate_limit = payload.rate_limit.unwrap_or(0);
|
||||
if rate_limit < 0 {
|
||||
if payload.rate_limit.is_some_and(|value| value < 0) {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"rate_limit 必须大于等于 0",
|
||||
));
|
||||
}
|
||||
let (initial_balance_usd, unlimited_balance) = match normalize_standalone_initial_balance(
|
||||
payload.initial_balance_usd,
|
||||
payload.unlimited_balance,
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
let expires_at_unix_secs =
|
||||
match parse_standalone_api_key_expires_at(payload.expires_at.as_deref()) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
};
|
||||
let auto_delete_on_expiry = payload.auto_delete_on_expiry.unwrap_or(false);
|
||||
if auto_delete_on_expiry && expires_at_unix_secs.is_none() {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"设置 auto_delete_on_expiry 前必须提供 expires_at",
|
||||
));
|
||||
}
|
||||
|
||||
let plaintext_key = generate_admin_user_api_key_plaintext();
|
||||
let Some(key_encrypted) = state.encrypt_catalog_secret_with_fallbacks(&plaintext_key) else {
|
||||
@@ -104,12 +153,12 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
rate_limit: payload.rate_limit,
|
||||
concurrent_limit: 5,
|
||||
force_capabilities: None,
|
||||
is_active: true,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry: false,
|
||||
expires_at_unix_secs,
|
||||
auto_delete_on_expiry,
|
||||
total_requests: 0,
|
||||
total_cost_usd: 0.0,
|
||||
},
|
||||
@@ -118,6 +167,13 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
else {
|
||||
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||
};
|
||||
let wallet = match state
|
||||
.initialize_auth_api_key_wallet(&created.api_key_id, initial_balance_usd, unlimited_balance)
|
||||
.await?
|
||||
{
|
||||
Some(wallet) => wallet,
|
||||
None => return Ok(build_admin_api_keys_data_unavailable_response()),
|
||||
};
|
||||
|
||||
Ok(attach_admin_audit_response(
|
||||
Json(json!({
|
||||
@@ -132,7 +188,8 @@ pub(super) async fn build_admin_create_api_key_response(
|
||||
"allowed_api_formats": created.allowed_api_formats,
|
||||
"allowed_models": created.allowed_models,
|
||||
"expires_at": format_optional_unix_secs_iso8601(created.expires_at_unix_secs),
|
||||
"wallet": serde_json::Value::Null,
|
||||
"auto_delete_on_expiry": created.auto_delete_on_expiry,
|
||||
"wallet": serialize_admin_system_users_export_wallet(Some(&wallet)),
|
||||
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
|
||||
}))
|
||||
.into_response(),
|
||||
@@ -176,17 +233,33 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
));
|
||||
}
|
||||
};
|
||||
let null_unlimited_balance =
|
||||
patch.contains("unlimited_balance") && patch.is_null("unlimited_balance");
|
||||
let null_auto_delete_on_expiry =
|
||||
patch.contains("auto_delete_on_expiry") && patch.is_null("auto_delete_on_expiry");
|
||||
let (field_presence, payload) = patch.into_parts();
|
||||
if payload.initial_balance_usd.is_some()
|
||||
|| payload.unlimited_balance.is_some()
|
||||
|| payload.expire_days.is_some()
|
||||
|| payload.expires_at.is_some()
|
||||
|| payload.auto_delete_on_expiry.is_some()
|
||||
{
|
||||
if null_unlimited_balance {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"当前仅支持 name、rate_limit、allowed_providers、allowed_api_formats、allowed_models 字段",
|
||||
"unlimited_balance 必须是布尔值",
|
||||
));
|
||||
}
|
||||
if null_auto_delete_on_expiry {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"auto_delete_on_expiry 必须是布尔值",
|
||||
));
|
||||
}
|
||||
if payload.expire_days.is_some() {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"expire_days 暂不支持,请改用 expires_at",
|
||||
));
|
||||
}
|
||||
|
||||
let Some(existing) = state
|
||||
.find_auth_api_key_export_standalone_record_by_id(&api_key_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(build_admin_api_keys_not_found_response());
|
||||
};
|
||||
|
||||
let name = match normalize_admin_optional_api_key_name(payload.name) {
|
||||
Ok(value) => value,
|
||||
@@ -221,28 +294,93 @@ pub(super) async fn build_admin_update_api_key_response(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let effective_expires_at_unix_secs = if field_presence.contains("expires_at") {
|
||||
match parse_standalone_api_key_expires_at(payload.expires_at.as_deref()) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => return Ok(build_admin_api_keys_bad_request_response(detail)),
|
||||
}
|
||||
} else {
|
||||
existing.expires_at_unix_secs
|
||||
};
|
||||
let effective_auto_delete_on_expiry = if field_presence.contains("auto_delete_on_expiry") {
|
||||
payload.auto_delete_on_expiry.unwrap_or(false)
|
||||
} else {
|
||||
existing.auto_delete_on_expiry
|
||||
};
|
||||
if effective_auto_delete_on_expiry && effective_expires_at_unix_secs.is_none() {
|
||||
return Ok(build_admin_api_keys_bad_request_response(
|
||||
"设置 auto_delete_on_expiry 前必须提供 expires_at",
|
||||
));
|
||||
}
|
||||
|
||||
let mut wallet = state
|
||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::ApiKeyId(
|
||||
&api_key_id,
|
||||
))
|
||||
.await?;
|
||||
if field_presence.contains("unlimited_balance") {
|
||||
if !state.has_auth_wallet_write_capability() {
|
||||
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||
}
|
||||
let desired_unlimited = payload.unlimited_balance.unwrap_or(false);
|
||||
let desired_limit_mode = if desired_unlimited {
|
||||
"unlimited"
|
||||
} else {
|
||||
"finite"
|
||||
};
|
||||
wallet = match wallet {
|
||||
Some(existing_wallet)
|
||||
if existing_wallet
|
||||
.limit_mode
|
||||
.eq_ignore_ascii_case(desired_limit_mode) =>
|
||||
{
|
||||
Some(existing_wallet)
|
||||
}
|
||||
Some(_) => {
|
||||
state
|
||||
.update_auth_api_key_wallet_limit_mode(&api_key_id, desired_limit_mode)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
state
|
||||
.initialize_auth_api_key_wallet(&api_key_id, 0.0, desired_unlimited)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let Some(updated) = state
|
||||
.update_standalone_api_key_basic(
|
||||
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
|
||||
api_key_id: api_key_id.clone(),
|
||||
name,
|
||||
rate_limit_present: field_presence.contains("rate_limit"),
|
||||
rate_limit: payload.rate_limit,
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
expires_at_present: field_presence.contains("expires_at"),
|
||||
expires_at_unix_secs: if field_presence.contains("expires_at") {
|
||||
effective_expires_at_unix_secs
|
||||
} else {
|
||||
None
|
||||
},
|
||||
auto_delete_on_expiry_present: field_presence.contains("auto_delete_on_expiry"),
|
||||
auto_delete_on_expiry: effective_auto_delete_on_expiry,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(build_admin_api_keys_not_found_response());
|
||||
return Ok(build_admin_api_keys_data_unavailable_response());
|
||||
};
|
||||
|
||||
let wallet = state
|
||||
.list_wallet_snapshots_by_api_key_ids(std::slice::from_ref(&api_key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|wallet| wallet.api_key_id.as_deref() == Some(api_key_id.as_str()));
|
||||
if wallet.is_none() {
|
||||
wallet = state
|
||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::ApiKeyId(
|
||||
&api_key_id,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
let total_tokens_by_api_key_id =
|
||||
admin_api_key_total_tokens_by_ids(state, std::slice::from_ref(&api_key_id)).await?;
|
||||
let total_tokens = total_tokens_by_api_key_id
|
||||
|
||||
@@ -180,6 +180,7 @@ pub(super) fn build_admin_api_key_detail_payload(
|
||||
"expires_at": format_optional_unix_secs_iso8601(record.expires_at_unix_secs),
|
||||
"created_at": serde_json::Value::Null,
|
||||
"updated_at": serde_json::Value::Null,
|
||||
"auto_delete_on_expiry": record.auto_delete_on_expiry,
|
||||
"wallet": serialize_admin_system_users_export_wallet(wallet),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -601,7 +601,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: self.resolve_admin_proxy_node_snapshot(proxy_node_id).await,
|
||||
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
|
||||
},
|
||||
tls_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
use super::*;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_data::repository::proxy_nodes::{proxy_node_accepts_new_tunnels, StoredProxyNode};
|
||||
use aether_provider_transport::TransportTunnelAffinityLookup;
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{Map, Value};
|
||||
use url::Url;
|
||||
|
||||
const TUNNEL_BASE_URL_EXTRA_KEY: &str = "tunnel_base_url";
|
||||
const TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY: &str = "tunnel_owner_instance_id";
|
||||
const TUNNEL_OWNER_OBSERVED_AT_EXTRA_KEY: &str = "tunnel_owner_observed_at_unix_secs";
|
||||
|
||||
impl<'a> AdminAppState<'a> {
|
||||
pub(crate) async fn read_provider_transport_snapshot(
|
||||
&self,
|
||||
@@ -83,10 +77,9 @@ impl<'a> AdminAppState<'a> {
|
||||
&self,
|
||||
transport: &AdminGatewayProviderTransportSnapshot,
|
||||
) -> Option<aether_contracts::ProxySnapshot> {
|
||||
crate::provider_transport::resolve_transport_proxy_snapshot_with_tunnel_affinity(
|
||||
self.app, transport,
|
||||
)
|
||||
.await
|
||||
self.app
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn fixed_provider_template(
|
||||
@@ -123,17 +116,7 @@ impl<'a> AdminAppState<'a> {
|
||||
}
|
||||
|
||||
if explicit_node_id.is_none() {
|
||||
let system_node_id = self
|
||||
.read_system_config_json_value("system_proxy_node_id")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.as_str().map(str::trim).map(ToOwned::to_owned))
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(snapshot) = self
|
||||
.resolve_admin_proxy_node_snapshot(system_node_id.as_deref())
|
||||
.await
|
||||
{
|
||||
if let Some(snapshot) = self.app.resolve_system_proxy_snapshot().await {
|
||||
return Some(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -147,60 +130,7 @@ impl<'a> AdminAppState<'a> {
|
||||
&self,
|
||||
node_id: Option<&str>,
|
||||
) -> Option<ProxySnapshot> {
|
||||
let node_id = node_id.map(str::trim).filter(|value| !value.is_empty())?;
|
||||
let node = self.find_proxy_node(node_id).await.ok().flatten()?;
|
||||
if node.status.trim() != "online" {
|
||||
return None;
|
||||
}
|
||||
if !proxy_node_accepts_new_tunnels(&node) {
|
||||
return None;
|
||||
}
|
||||
if node.tunnel_mode && node.tunnel_connected {
|
||||
let mut extra = Map::new();
|
||||
if let Ok(Some(owner)) = self.app().lookup_tunnel_attachment_owner(node_id).await {
|
||||
extra.insert(
|
||||
TUNNEL_BASE_URL_EXTRA_KEY.to_string(),
|
||||
Value::String(owner.relay_base_url),
|
||||
);
|
||||
extra.insert(
|
||||
TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY.to_string(),
|
||||
Value::String(owner.gateway_instance_id),
|
||||
);
|
||||
extra.insert(
|
||||
TUNNEL_OWNER_OBSERVED_AT_EXTRA_KEY.to_string(),
|
||||
json!(owner.observed_at_unix_secs),
|
||||
);
|
||||
}
|
||||
return Some(ProxySnapshot {
|
||||
enabled: Some(true),
|
||||
mode: Some("tunnel".to_string()),
|
||||
node_id: Some(node_id.to_string()),
|
||||
label: Some(node.name),
|
||||
url: None,
|
||||
extra: if extra.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(extra))
|
||||
},
|
||||
});
|
||||
}
|
||||
if !node.is_manual {
|
||||
return None;
|
||||
}
|
||||
let proxy_url = node
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(ProxySnapshot {
|
||||
enabled: Some(true),
|
||||
mode: admin_provider_transport_proxy_mode(Some(proxy_url)),
|
||||
node_id: Some(node.id.clone()),
|
||||
label: Some(node.name.clone()),
|
||||
url: admin_provider_transport_proxy_url_with_node_auth(&node)
|
||||
.or_else(|| Some(proxy_url.to_string())),
|
||||
extra: None,
|
||||
})
|
||||
self.app.resolve_proxy_node_snapshot(node_id).await
|
||||
}
|
||||
|
||||
pub(crate) fn supports_local_gemini_transport_with_network(
|
||||
@@ -387,25 +317,6 @@ fn admin_provider_transport_legacy_proxy_snapshot(value: &Value) -> Option<Proxy
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_provider_transport_proxy_url_with_node_auth(node: &StoredProxyNode) -> Option<String> {
|
||||
let proxy_url = node
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
let username = node
|
||||
.proxy_username
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let password = node
|
||||
.proxy_password
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
admin_provider_transport_inject_proxy_auth(proxy_url, username, password)
|
||||
}
|
||||
|
||||
fn admin_provider_transport_inject_proxy_auth(
|
||||
proxy_url: &str,
|
||||
username: Option<&str>,
|
||||
|
||||
@@ -2113,10 +2113,15 @@ impl<'a> AdminAppState<'a> {
|
||||
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
|
||||
api_key_id: existing_key.api_key_id.clone(),
|
||||
name: name.clone(),
|
||||
rate_limit_present: true,
|
||||
rate_limit: Some(rate_limit),
|
||||
allowed_providers: Some(allowed_providers.clone()),
|
||||
allowed_api_formats: Some(allowed_api_formats.clone()),
|
||||
allowed_models: Some(allowed_models.clone()),
|
||||
expires_at_present: false,
|
||||
expires_at_unix_secs: None,
|
||||
auto_delete_on_expiry_present: false,
|
||||
auto_delete_on_expiry: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -2165,7 +2170,7 @@ impl<'a> AdminAppState<'a> {
|
||||
allowed_providers,
|
||||
allowed_api_formats,
|
||||
allowed_models,
|
||||
rate_limit,
|
||||
rate_limit: Some(rate_limit),
|
||||
concurrent_limit,
|
||||
force_capabilities,
|
||||
is_active,
|
||||
|
||||
@@ -11,6 +11,20 @@ use aether_admin::system::{
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
impl<'a> AdminAppState<'a> {
|
||||
pub(crate) async fn create_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &aether_data::repository::proxy_nodes::ProxyNodeManualCreateMutation,
|
||||
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
|
||||
self.app.create_manual_proxy_node(mutation).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &aether_data::repository::proxy_nodes::ProxyNodeManualUpdateMutation,
|
||||
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
|
||||
self.app.update_manual_proxy_node(mutation).await
|
||||
}
|
||||
|
||||
pub(crate) async fn register_proxy_node(
|
||||
&self,
|
||||
mutation: &aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation,
|
||||
@@ -94,6 +108,13 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.unregister_proxy_node(node_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
|
||||
self.app.delete_proxy_node(node_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_proxy_node_remote_config(
|
||||
&self,
|
||||
mutation: &aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::shared::query_param_value;
|
||||
use crate::maintenance::{
|
||||
@@ -19,6 +21,7 @@ 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 {
|
||||
@@ -76,6 +79,41 @@ struct ProxyNodeUnregisterRequest {
|
||||
node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualProxyNodeCreateRequest {
|
||||
name: String,
|
||||
proxy_url: String,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualProxyNodeUpdateRequest {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProxyNodeTestUrlRequest {
|
||||
proxy_url: String,
|
||||
#[serde(default)]
|
||||
username: Option<String>,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProxyNodeBatchUpgradeRequest {
|
||||
version: String,
|
||||
@@ -235,6 +273,128 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("create_manual_node")
|
||||
&& request_context.method() == http::Method::POST
|
||||
{
|
||||
if !state.has_proxy_node_writer() {
|
||||
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
|
||||
}
|
||||
let input = match parse_json_body::<ManualProxyNodeCreateRequest>(request_body) {
|
||||
Ok(input) => input,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let mutation = match validate_manual_create_request(input, request_context) {
|
||||
Ok(mutation) => mutation,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let Some(node) = state.create_manual_proxy_node(&mutation).await? else {
|
||||
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
|
||||
};
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"node_id": node.id,
|
||||
"node": build_admin_proxy_node_payload(&node),
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("update_manual_node")
|
||||
&& request_context.method() == http::Method::PATCH
|
||||
{
|
||||
if !state.has_proxy_node_writer() {
|
||||
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 input = match parse_json_body::<ManualProxyNodeUpdateRequest>(request_body) {
|
||||
Ok(input) => input,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let mutation = match validate_manual_update_request(node_id, input) {
|
||||
Ok(mutation) => mutation,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let Some(node) = state.update_manual_proxy_node(&mutation).await? else {
|
||||
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
|
||||
};
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"node_id": node.id,
|
||||
"node": build_admin_proxy_node_payload(&node),
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("delete_node")
|
||||
&& request_context.method() == http::Method::DELETE
|
||||
{
|
||||
if !state.has_proxy_node_writer() {
|
||||
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(_deleted_node) = state.delete_proxy_node(&node_id).await? else {
|
||||
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
|
||||
};
|
||||
let cleanup = clear_deleted_proxy_node_references(state, &node_id).await?;
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": build_delete_proxy_node_message(&cleanup),
|
||||
"node_id": node_id,
|
||||
"cleared_system_proxy": cleanup.cleared_system_proxy,
|
||||
"cleared_providers": cleanup.cleared_providers,
|
||||
"cleared_endpoints": cleanup.cleared_endpoints,
|
||||
"cleared_keys": cleanup.cleared_keys,
|
||||
}))
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("test_node")
|
||||
&& request_context.method() == http::Method::POST
|
||||
{
|
||||
if !state.has_proxy_node_reader() {
|
||||
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
|
||||
}
|
||||
let Some(node_id) = admin_proxy_node_test_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(test_proxy_node_connectivity(&node).await).into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("test_proxy_url")
|
||||
&& request_context.method() == http::Method::POST
|
||||
{
|
||||
let input = match parse_json_body::<ProxyNodeTestUrlRequest>(request_body) {
|
||||
Ok(input) => input,
|
||||
Err(response) => return Ok(Some(response)),
|
||||
};
|
||||
let normalized = match validate_proxy_test_url_request(input) {
|
||||
Ok(normalized) => normalized,
|
||||
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(),
|
||||
));
|
||||
}
|
||||
|
||||
if decision.route_kind.as_deref() == Some("update_node_config")
|
||||
&& request_context.method() == http::Method::PUT
|
||||
{
|
||||
@@ -548,6 +708,213 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
||||
Ok(Some(build_admin_proxy_nodes_data_unavailable_response()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DeletedProxyNodeCleanup {
|
||||
cleared_system_proxy: bool,
|
||||
cleared_providers: usize,
|
||||
cleared_endpoints: usize,
|
||||
cleared_keys: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NormalizedManualProxyEndpoint {
|
||||
proxy_url: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
node_ip: String,
|
||||
node_port: i32,
|
||||
}
|
||||
|
||||
async fn clear_deleted_proxy_node_references(
|
||||
state: &AdminAppState<'_>,
|
||||
node_id: &str,
|
||||
) -> Result<DeletedProxyNodeCleanup, GatewayError> {
|
||||
let mut cleanup = DeletedProxyNodeCleanup::default();
|
||||
|
||||
if state.app().data.has_system_config_store() {
|
||||
let is_system_proxy = state
|
||||
.read_system_config_json_value("system_proxy_node_id")
|
||||
.await?
|
||||
.and_then(|value| value.as_str().map(str::trim).map(ToOwned::to_owned))
|
||||
.is_some_and(|value| value == node_id);
|
||||
if is_system_proxy {
|
||||
state
|
||||
.upsert_system_config_json_value(
|
||||
"system_proxy_node_id",
|
||||
&serde_json::Value::Null,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
cleanup.cleared_system_proxy = true;
|
||||
}
|
||||
}
|
||||
|
||||
if state.app().has_provider_catalog_data_reader()
|
||||
&& state.app().has_provider_catalog_data_writer()
|
||||
{
|
||||
let providers = state.list_provider_catalog_providers(false).await?;
|
||||
let provider_ids = providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for mut provider in providers {
|
||||
if !proxy_reference_matches_node_id(provider.proxy.as_ref(), node_id) {
|
||||
continue;
|
||||
}
|
||||
provider.proxy = None;
|
||||
if state
|
||||
.update_provider_catalog_provider(&provider)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
cleanup.cleared_providers = cleanup.cleared_providers.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
if !provider_ids.is_empty() {
|
||||
let endpoints = state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
for mut endpoint in endpoints {
|
||||
if !proxy_reference_matches_node_id(endpoint.proxy.as_ref(), node_id) {
|
||||
continue;
|
||||
}
|
||||
endpoint.proxy = None;
|
||||
if state
|
||||
.update_provider_catalog_endpoint(&endpoint)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
cleanup.cleared_endpoints = cleanup.cleared_endpoints.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
let keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
for mut key in keys {
|
||||
if !proxy_reference_matches_node_id(key.proxy.as_ref(), node_id) {
|
||||
continue;
|
||||
}
|
||||
key.proxy = None;
|
||||
if state.update_provider_catalog_key(&key).await?.is_some() {
|
||||
cleanup.cleared_keys = cleanup.cleared_keys.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleanup)
|
||||
}
|
||||
|
||||
fn build_delete_proxy_node_message(cleanup: &DeletedProxyNodeCleanup) -> String {
|
||||
let mut parts = vec!["deleted".to_string()];
|
||||
if cleanup.cleared_system_proxy {
|
||||
parts.push("system default proxy cleared".to_string());
|
||||
}
|
||||
if cleanup.cleared_providers > 0 || cleanup.cleared_endpoints > 0 || cleanup.cleared_keys > 0 {
|
||||
parts.push(format!(
|
||||
"cleared proxy refs from {} provider(s), {} endpoint(s), {} key(s)",
|
||||
cleanup.cleared_providers, cleanup.cleared_endpoints, cleanup.cleared_keys
|
||||
));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
fn proxy_reference_matches_node_id(value: Option<&Value>, node_id: &str) -> bool {
|
||||
value
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("node_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value == node_id)
|
||||
}
|
||||
|
||||
async fn test_proxy_node_connectivity(
|
||||
node: &aether_data::repository::proxy_nodes::StoredProxyNode,
|
||||
) -> Value {
|
||||
if node.is_manual {
|
||||
let Some(proxy_url) = node.proxy_url.as_deref() else {
|
||||
return json!({
|
||||
"success": false,
|
||||
"latency_ms": null,
|
||||
"exit_ip": null,
|
||||
"error": "手动节点缺少 proxy_url",
|
||||
});
|
||||
};
|
||||
let endpoint = match parse_manual_proxy_endpoint(proxy_url, "proxy_url") {
|
||||
Ok(endpoint) => endpoint,
|
||||
Err(detail) => {
|
||||
return json!({
|
||||
"success": false,
|
||||
"latency_ms": null,
|
||||
"exit_ip": null,
|
||||
"error": detail,
|
||||
});
|
||||
}
|
||||
};
|
||||
return test_manual_proxy_connectivity(
|
||||
&endpoint.proxy_url,
|
||||
endpoint.host.as_str(),
|
||||
endpoint.port,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if !node.tunnel_mode {
|
||||
return json!({
|
||||
"success": false,
|
||||
"latency_ms": null,
|
||||
"exit_ip": null,
|
||||
"error": "non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode",
|
||||
});
|
||||
}
|
||||
|
||||
if !node.status.eq_ignore_ascii_case("online") || !node.tunnel_connected {
|
||||
return json!({
|
||||
"success": false,
|
||||
"latency_ms": null,
|
||||
"exit_ip": null,
|
||||
"error": "tunnel 未连接",
|
||||
});
|
||||
}
|
||||
|
||||
json!({
|
||||
"success": true,
|
||||
"latency_ms": node.avg_latency_ms.map(|value| value.max(0.0).round() as u64),
|
||||
"exit_ip": null,
|
||||
"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": "连接超时",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_register_request(
|
||||
input: ProxyNodeRegisterRequest,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -610,6 +977,74 @@ fn validate_register_request(
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_manual_create_request(
|
||||
input: ManualProxyNodeCreateRequest,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
) -> Result<aether_data::repository::proxy_nodes::ProxyNodeManualCreateMutation, Response<Body>> {
|
||||
let endpoint = normalize_manual_proxy_endpoint(&input.proxy_url)?;
|
||||
let registered_by = request_context
|
||||
.decision()
|
||||
.and_then(|decision| decision.admin_principal.as_ref())
|
||||
.map(|principal| principal.user_id.clone());
|
||||
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeManualCreateMutation {
|
||||
name: normalize_required_string(&input.name, "name", 100)?,
|
||||
ip: endpoint.node_ip,
|
||||
port: endpoint.node_port,
|
||||
region: normalize_optional_string(input.region.as_deref(), "region", 100)?,
|
||||
proxy_url: endpoint.proxy_url,
|
||||
proxy_username: normalize_optional_string(input.username.as_deref(), "username", 255)?,
|
||||
proxy_password: normalize_optional_string(input.password.as_deref(), "password", 500)?,
|
||||
registered_by,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_manual_update_request(
|
||||
node_id: String,
|
||||
input: ManualProxyNodeUpdateRequest,
|
||||
) -> Result<aether_data::repository::proxy_nodes::ProxyNodeManualUpdateMutation, Response<Body>> {
|
||||
let endpoint = match input.proxy_url.as_deref() {
|
||||
Some(proxy_url) => Some(normalize_manual_proxy_endpoint(proxy_url)?),
|
||||
None => None,
|
||||
};
|
||||
let name = normalize_optional_string(input.name.as_deref(), "name", 100)?;
|
||||
let region = normalize_optional_string(input.region.as_deref(), "region", 100)?;
|
||||
let proxy_username = normalize_optional_string(input.username.as_deref(), "username", 255)?;
|
||||
let proxy_password = normalize_optional_string(input.password.as_deref(), "password", 500)?;
|
||||
|
||||
if name.is_none()
|
||||
&& region.is_none()
|
||||
&& proxy_username.is_none()
|
||||
&& proxy_password.is_none()
|
||||
&& endpoint.is_none()
|
||||
{
|
||||
return Err(bad_request_response("至少提供一个可更新字段"));
|
||||
}
|
||||
|
||||
Ok(
|
||||
aether_data::repository::proxy_nodes::ProxyNodeManualUpdateMutation {
|
||||
node_id,
|
||||
name,
|
||||
ip: endpoint.as_ref().map(|value| value.node_ip.clone()),
|
||||
port: endpoint.as_ref().map(|value| value.node_port),
|
||||
region,
|
||||
proxy_url: endpoint.map(|value| value.proxy_url),
|
||||
proxy_username,
|
||||
proxy_password,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn admin_proxy_node_upgrade_action_node_id_from_path(path: &str, suffix: &str) -> Option<String> {
|
||||
let normalized = path.trim_end_matches('/');
|
||||
let node_id = normalized.strip_prefix("/api/admin/proxy-nodes/")?;
|
||||
@@ -621,6 +1056,27 @@ fn admin_proxy_node_upgrade_action_node_id_from_path(path: &str, suffix: &str) -
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_proxy_node_node_id_from_path(path: &str) -> Option<String> {
|
||||
let normalized = path.trim_end_matches('/');
|
||||
let node_id = normalized.strip_prefix("/api/admin/proxy-nodes/")?;
|
||||
if node_id.is_empty() || node_id.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(node_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_proxy_node_test_node_id_from_path(path: &str) -> Option<String> {
|
||||
let normalized = path.trim_end_matches('/');
|
||||
let node_id = normalized.strip_prefix("/api/admin/proxy-nodes/")?;
|
||||
let node_id = node_id.strip_suffix("/test")?;
|
||||
if node_id.is_empty() || node_id.contains('/') {
|
||||
None
|
||||
} else {
|
||||
Some(node_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_batch_size(batch_size: Option<usize>) -> Result<usize, Response<Body>> {
|
||||
let batch_size = batch_size.unwrap_or(DEFAULT_PROXY_UPGRADE_BATCH_SIZE);
|
||||
if (1..=100).contains(&batch_size) {
|
||||
@@ -850,6 +1306,62 @@ fn parse_json_object_body(
|
||||
.ok_or_else(|| bad_request_response(JSON_OBJECT_REQUIRED_DETAIL))
|
||||
}
|
||||
|
||||
fn normalize_manual_proxy_endpoint(
|
||||
proxy_url: &str,
|
||||
) -> Result<NormalizedManualProxyEndpoint, Response<Body>> {
|
||||
parse_manual_proxy_endpoint(proxy_url, "proxy_url").map_err(bad_request_response)
|
||||
}
|
||||
|
||||
fn parse_manual_proxy_endpoint(
|
||||
proxy_url: &str,
|
||||
field: &str,
|
||||
) -> Result<NormalizedManualProxyEndpoint, String> {
|
||||
let proxy_url = proxy_url.trim();
|
||||
if proxy_url.is_empty() {
|
||||
return Err(format!("{field} 不能为空"));
|
||||
}
|
||||
if proxy_url.chars().count() > 500 {
|
||||
return Err(format!("{field} 长度不能超过 500"));
|
||||
}
|
||||
|
||||
let parsed =
|
||||
reqwest::Url::parse(proxy_url).map_err(|_| format!("{field} 必须是合法的代理 URL"))?;
|
||||
let scheme = parsed.scheme().trim().to_ascii_lowercase();
|
||||
if !matches!(scheme.as_str(), "http" | "https" | "socks5" | "socks5h") {
|
||||
return Err(format!("{field} 仅支持 http/https/socks5/socks5h 协议"));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(format!("{field} 不应包含用户名或密码,请使用独立字段"));
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("{field} 缺少主机地址"))?
|
||||
.to_string();
|
||||
let port = parsed.port().unwrap_or(match scheme.as_str() {
|
||||
"https" => 443,
|
||||
"socks5" | "socks5h" => 1080,
|
||||
_ => 80,
|
||||
});
|
||||
let node_ip = if scheme == "http" {
|
||||
host.clone()
|
||||
} else {
|
||||
format!("{scheme}://{host}")
|
||||
};
|
||||
if node_ip.chars().count() > 255 {
|
||||
return Err("代理主机标识长度不能超过 255".to_string());
|
||||
}
|
||||
|
||||
Ok(NormalizedManualProxyEndpoint {
|
||||
proxy_url: proxy_url.to_string(),
|
||||
host,
|
||||
port,
|
||||
node_ip,
|
||||
node_port: i32::from(port),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_node_id(value: &str) -> Result<String, Response<Body>> {
|
||||
normalize_required_string(value, "node_id", 36)
|
||||
}
|
||||
@@ -903,6 +1415,16 @@ fn normalize_ip_address(value: &str) -> Result<String, Response<Body>> {
|
||||
.map_err(|_| bad_request_response("ip 必须是合法的 IPv4/IPv6 地址"))
|
||||
}
|
||||
|
||||
fn sanitize_proxy_error(detail: &str) -> String {
|
||||
match detail.split_once("://") {
|
||||
Some((scheme, rest)) => match rest.split_once('@') {
|
||||
Some((_, tail)) => format!("{scheme}://***@{tail}"),
|
||||
None => detail.to_string(),
|
||||
},
|
||||
None => detail.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
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} 必须是非负整数")));
|
||||
|
||||
@@ -308,7 +308,9 @@ async fn maybe_forward_public_request_to_tunnel_owner(
|
||||
}
|
||||
};
|
||||
|
||||
let Some(proxy) = crate::provider_transport::resolve_transport_proxy_snapshot(&transport)
|
||||
let Some(proxy) = state
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(&transport)
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -306,10 +306,13 @@ pub(crate) fn admin_proxy_local_requires_buffered_body(
|
||||
| (Some("api_keys_manage"), http::Method::PUT, Some("update_api_key"))
|
||||
| (Some("api_keys_manage"), http::Method::PATCH, Some("toggle_api_key"))
|
||||
| (Some("adaptive_manage"), http::Method::PATCH, Some("toggle_mode"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("create_manual_node"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("register_node"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("heartbeat_node"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("unregister_node"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("test_proxy_url"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::POST, Some("batch_upgrade_nodes"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::PATCH, Some("update_manual_node"))
|
||||
| (Some("proxy_nodes_manage"), http::Method::PUT, Some("update_node_config"))
|
||||
| (Some("security_manage"), http::Method::POST, Some("blacklist_add"))
|
||||
| (Some("security_manage"), http::Method::POST, Some("whitelist_add"))
|
||||
|
||||
@@ -855,14 +855,10 @@ fn build_rollout_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
for tracked in tracked_by_node_id.into_values() {
|
||||
if skipped_node_ids.contains(tracked.node_id.as_str()) {
|
||||
snapshot.skipped.push(tracked.node_id);
|
||||
for _tracked in tracked_by_node_id.into_values() {
|
||||
if skipped_node_ids.contains(_tracked.node_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
snapshot.remaining_total = snapshot.remaining_total.saturating_add(1);
|
||||
snapshot.pending.push(tracked.node_id.clone());
|
||||
snapshot.tracked_nodes.push(tracked);
|
||||
}
|
||||
|
||||
snapshot.skipped.sort();
|
||||
|
||||
@@ -4,8 +4,8 @@ use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data::repository::proxy_nodes::{
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeTunnelStatusMutation, StoredProxyNode,
|
||||
StoredProxyNodeEvent,
|
||||
ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation,
|
||||
ProxyNodeTunnelStatusMutation, StoredProxyNode, StoredProxyNodeEvent,
|
||||
};
|
||||
use aether_http::{build_http_client, HttpClientConfig};
|
||||
use aether_runtime::{
|
||||
@@ -468,6 +468,26 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn create_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &ProxyNodeManualCreateMutation,
|
||||
) -> Result<Option<StoredProxyNode>, GatewayError> {
|
||||
self.data
|
||||
.create_manual_proxy_node(mutation)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_manual_proxy_node(
|
||||
&self,
|
||||
mutation: &ProxyNodeManualUpdateMutation,
|
||||
) -> Result<Option<StoredProxyNode>, GatewayError> {
|
||||
self.data
|
||||
.update_manual_proxy_node(mutation)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub async fn reset_stale_proxy_node_tunnel_statuses(&self) -> std::io::Result<usize> {
|
||||
self.data
|
||||
.reset_stale_proxy_node_tunnel_statuses()
|
||||
@@ -495,6 +515,16 @@ impl AppState {
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_proxy_node(
|
||||
&self,
|
||||
node_id: &str,
|
||||
) -> Result<Option<StoredProxyNode>, GatewayError> {
|
||||
self.data
|
||||
.delete_proxy_node(node_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_proxy_node_remote_config(
|
||||
&self,
|
||||
mutation: &aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation,
|
||||
|
||||
@@ -23,10 +23,7 @@ use tracing::debug;
|
||||
|
||||
use super::{AppState, GatewayError};
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::provider_transport::{
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, GatewayProviderTransportSnapshot,
|
||||
LocalResolvedOAuthRequestAuth,
|
||||
};
|
||||
use crate::provider_transport::{GatewayProviderTransportSnapshot, LocalResolvedOAuthRequestAuth};
|
||||
use crate::request_candidate_runtime::{
|
||||
RequestCandidateRuntimeCapabilityReader, RequestCandidateRuntimeReader,
|
||||
RequestCandidateRuntimeWriter,
|
||||
@@ -90,7 +87,8 @@ impl ModelFetchTransportRuntime for AppState {
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ProxySnapshot> {
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity(self, transport).await
|
||||
self.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_model_fetch_execution_plan(
|
||||
|
||||
@@ -10,6 +10,7 @@ mod core;
|
||||
mod cors;
|
||||
mod integrations;
|
||||
mod oauth;
|
||||
mod proxy;
|
||||
mod runtime;
|
||||
#[cfg(test)]
|
||||
mod testing;
|
||||
|
||||
@@ -3,12 +3,41 @@ use super::{
|
||||
GatewayError, ProviderTransportSnapshotCacheKey, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES,
|
||||
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
|
||||
};
|
||||
use crate::provider_transport::LocalOAuthHttpExecutor;
|
||||
|
||||
use super::super::provider_transport;
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use flate2::read::{DeflateDecoder, GzDecoder};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_crypto::encrypt_python_fernet_plaintext;
|
||||
|
||||
const LOCAL_OAUTH_HTTP_TIMEOUT_MS: u64 = 30_000;
|
||||
|
||||
struct GatewayLocalOAuthHttpExecutor<'a> {
|
||||
state: &'a AppState,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<'a> provider_transport::LocalOAuthHttpExecutor for GatewayLocalOAuthHttpExecutor<'a> {
|
||||
async fn execute(
|
||||
&self,
|
||||
provider_type: &'static str,
|
||||
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||
request: &provider_transport::LocalOAuthHttpRequest,
|
||||
) -> Result<
|
||||
provider_transport::LocalOAuthHttpResponse,
|
||||
provider_transport::LocalOAuthRefreshError,
|
||||
> {
|
||||
self.state
|
||||
.execute_local_oauth_http_request(provider_type, transport, request)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub(crate) fn clear_provider_transport_snapshot_cache(&self) {
|
||||
self.provider_transport_snapshot_cache
|
||||
@@ -368,12 +397,13 @@ impl AppState {
|
||||
let distributed_lock = self.data.oauth_refresh_lock_runner();
|
||||
let lock_owner = format!("aether-gateway-{}", std::process::id());
|
||||
let mut current_transport = transport.clone();
|
||||
let executor = GatewayLocalOAuthHttpExecutor { state: self };
|
||||
|
||||
for _ in 0..2 {
|
||||
let resolution = self
|
||||
.oauth_refresh
|
||||
.resolve_with_result(
|
||||
&self.client,
|
||||
&executor,
|
||||
¤t_transport,
|
||||
distributed_lock.as_ref(),
|
||||
Some(lock_owner.as_str()),
|
||||
@@ -429,12 +459,13 @@ impl AppState {
|
||||
let lock_owner = format!("aether-gateway-admin-{}", std::process::id());
|
||||
let mut current_transport = transport.clone();
|
||||
current_transport.key.decrypted_api_key = "__placeholder__".to_string();
|
||||
let executor = GatewayLocalOAuthHttpExecutor { state: self };
|
||||
|
||||
for _ in 0..2 {
|
||||
let resolution = self
|
||||
.oauth_refresh
|
||||
.resolve_with_result(
|
||||
&self.client,
|
||||
&executor,
|
||||
¤t_transport,
|
||||
distributed_lock.as_ref(),
|
||||
Some(lock_owner.as_str()),
|
||||
@@ -534,6 +565,88 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_local_oauth_http_request(
|
||||
&self,
|
||||
provider_type: &'static str,
|
||||
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||
request: &provider_transport::LocalOAuthHttpRequest,
|
||||
) -> Result<
|
||||
provider_transport::LocalOAuthHttpResponse,
|
||||
provider_transport::LocalOAuthRefreshError,
|
||||
> {
|
||||
if local_oauth_request_uses_direct_client(request.url.as_str()) {
|
||||
let executor =
|
||||
provider_transport::ReqwestLocalOAuthHttpExecutor::new(self.client.clone());
|
||||
return executor.execute(provider_type, transport, request).await;
|
||||
}
|
||||
|
||||
let body = if let Some(json_body) = request.json_body.clone() {
|
||||
RequestBody::from_json(json_body)
|
||||
} else {
|
||||
RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: request
|
||||
.body_bytes
|
||||
.as_ref()
|
||||
.map(|bytes| STANDARD.encode(bytes)),
|
||||
body_ref: None,
|
||||
}
|
||||
};
|
||||
let plan = ExecutionPlan {
|
||||
request_id: request.request_id.to_string(),
|
||||
candidate_id: None,
|
||||
provider_name: Some(transport.provider.name.clone()),
|
||||
provider_id: transport.provider.id.clone(),
|
||||
endpoint_id: transport.endpoint.id.clone(),
|
||||
key_id: transport.key.id.clone(),
|
||||
method: request.method.as_str().to_string(),
|
||||
url: request.url.clone(),
|
||||
headers: request.headers.clone(),
|
||||
content_type: request
|
||||
.headers
|
||||
.get("content-type")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned),
|
||||
content_encoding: None,
|
||||
body,
|
||||
stream: false,
|
||||
client_api_format: "provider_oauth:local_refresh".to_string(),
|
||||
provider_api_format: "provider_oauth:local_refresh".to_string(),
|
||||
model_name: Some(provider_type.to_string()),
|
||||
proxy: self
|
||||
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||
.await,
|
||||
tls_profile: None,
|
||||
timeouts: Some(ExecutionTimeouts {
|
||||
connect_ms: Some(LOCAL_OAUTH_HTTP_TIMEOUT_MS),
|
||||
read_ms: Some(LOCAL_OAUTH_HTTP_TIMEOUT_MS),
|
||||
write_ms: Some(LOCAL_OAUTH_HTTP_TIMEOUT_MS),
|
||||
pool_ms: Some(LOCAL_OAUTH_HTTP_TIMEOUT_MS),
|
||||
total_ms: Some(LOCAL_OAUTH_HTTP_TIMEOUT_MS),
|
||||
..ExecutionTimeouts::default()
|
||||
}),
|
||||
};
|
||||
let result =
|
||||
crate::execution_runtime::execute_execution_runtime_sync_plan(self, None, &plan)
|
||||
.await
|
||||
.map_err(
|
||||
|err| provider_transport::LocalOAuthRefreshError::InvalidResponse {
|
||||
provider_type,
|
||||
message: match err {
|
||||
GatewayError::UpstreamUnavailable { message, .. }
|
||||
| GatewayError::ControlUnavailable { message, .. }
|
||||
| GatewayError::Internal(message) => message,
|
||||
},
|
||||
},
|
||||
)?;
|
||||
Ok(provider_transport::LocalOAuthHttpResponse {
|
||||
status_code: result.status_code,
|
||||
body_text: local_oauth_execution_body_text(&result),
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_remote_oauth_refresh(
|
||||
&self,
|
||||
transport: &provider_transport::GatewayProviderTransportSnapshot,
|
||||
@@ -564,3 +677,63 @@ impl AppState {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_oauth_execution_body_text(result: &aether_contracts::ExecutionResult) -> String {
|
||||
result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| local_oauth_execution_body_bytes(&result.headers, body))
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
|
||||
.or_else(|| {
|
||||
result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
.and_then(|value| serde_json::to_string(value).ok())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn local_oauth_execution_body_bytes(
|
||||
headers: &BTreeMap<String, String>,
|
||||
body: &aether_contracts::ResponseBody,
|
||||
) -> Option<Vec<u8>> {
|
||||
let bytes = body
|
||||
.body_bytes_b64
|
||||
.as_deref()
|
||||
.and_then(|value| STANDARD.decode(value).ok())?;
|
||||
let encoding = headers
|
||||
.get("content-encoding")
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_ascii_lowercase());
|
||||
match encoding.as_deref() {
|
||||
Some("gzip") => {
|
||||
let mut decoder = GzDecoder::new(bytes.as_slice());
|
||||
let mut out = Vec::new();
|
||||
decoder.read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
Some("deflate") => {
|
||||
let mut decoder = DeflateDecoder::new(bytes.as_slice());
|
||||
let mut out = Vec::new();
|
||||
decoder.read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
_ => Some(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_oauth_request_uses_direct_client(url: &str) -> bool {
|
||||
reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|parsed| parsed.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.map(|addr| addr.is_loopback())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
217
apps/aether-gateway/src/state/proxy.rs
Normal file
217
apps/aether-gateway/src/state/proxy.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_data::repository::proxy_nodes::proxy_node_accepts_new_tunnels;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::AppState;
|
||||
use crate::provider_transport::{GatewayProviderTransportSnapshot, TransportTunnelAffinityLookup};
|
||||
|
||||
const TUNNEL_BASE_URL_EXTRA_KEY: &str = "tunnel_base_url";
|
||||
const TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY: &str = "tunnel_owner_instance_id";
|
||||
const TUNNEL_OWNER_OBSERVED_AT_EXTRA_KEY: &str = "tunnel_owner_observed_at_unix_secs";
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn read_system_proxy_node_id(&self) -> Option<String> {
|
||||
self.read_system_config_json_value("system_proxy_node_id")
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|value| value.as_str().map(str::trim).map(ToOwned::to_owned))
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_proxy_node_snapshot(
|
||||
&self,
|
||||
node_id: Option<&str>,
|
||||
) -> Option<ProxySnapshot> {
|
||||
let node_id = node_id.map(str::trim).filter(|value| !value.is_empty())?;
|
||||
let node = self.find_proxy_node(node_id).await.ok().flatten()?;
|
||||
if node.status.trim() != "online" {
|
||||
return None;
|
||||
}
|
||||
if !proxy_node_accepts_new_tunnels(&node) {
|
||||
return None;
|
||||
}
|
||||
if node.tunnel_mode && node.tunnel_connected {
|
||||
let mut extra = Map::new();
|
||||
if let Ok(Some(owner)) = self.lookup_tunnel_attachment_owner(node_id).await {
|
||||
extra.insert(
|
||||
TUNNEL_BASE_URL_EXTRA_KEY.to_string(),
|
||||
Value::String(owner.relay_base_url),
|
||||
);
|
||||
extra.insert(
|
||||
TUNNEL_OWNER_INSTANCE_ID_EXTRA_KEY.to_string(),
|
||||
Value::String(owner.gateway_instance_id),
|
||||
);
|
||||
extra.insert(
|
||||
TUNNEL_OWNER_OBSERVED_AT_EXTRA_KEY.to_string(),
|
||||
json!(owner.observed_at_unix_secs),
|
||||
);
|
||||
}
|
||||
return Some(ProxySnapshot {
|
||||
enabled: Some(true),
|
||||
mode: Some("tunnel".to_string()),
|
||||
node_id: Some(node.id),
|
||||
label: Some(node.name),
|
||||
url: None,
|
||||
extra: if extra.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(extra))
|
||||
},
|
||||
});
|
||||
}
|
||||
if !node.is_manual {
|
||||
return None;
|
||||
}
|
||||
let proxy_url = node
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
Some(ProxySnapshot {
|
||||
enabled: Some(true),
|
||||
mode: proxy_mode_from_url(Some(proxy_url)),
|
||||
node_id: Some(node.id),
|
||||
label: Some(node.name),
|
||||
url: proxy_url_with_node_auth(
|
||||
proxy_url,
|
||||
node.proxy_username.as_deref(),
|
||||
node.proxy_password.as_deref(),
|
||||
)
|
||||
.or_else(|| Some(proxy_url.to_string())),
|
||||
extra: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_system_proxy_snapshot(&self) -> Option<ProxySnapshot> {
|
||||
let node_id = self.read_system_proxy_node_id().await;
|
||||
self.resolve_proxy_node_snapshot(node_id.as_deref()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_transport_proxy_snapshot_with_tunnel_affinity(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ProxySnapshot> {
|
||||
if let Some(snapshot) = self
|
||||
.resolve_proxy_snapshot_from_config(transport.key.proxy.as_ref())
|
||||
.await
|
||||
{
|
||||
return Some(snapshot);
|
||||
}
|
||||
if let Some(snapshot) = self
|
||||
.resolve_proxy_snapshot_from_config(transport.provider.proxy.as_ref())
|
||||
.await
|
||||
{
|
||||
return Some(snapshot);
|
||||
}
|
||||
if let Some(snapshot) = self.resolve_system_proxy_snapshot().await {
|
||||
return Some(snapshot);
|
||||
}
|
||||
self.resolve_proxy_snapshot_from_config(transport.endpoint.proxy.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn resolve_proxy_snapshot_from_config(
|
||||
&self,
|
||||
raw: Option<&Value>,
|
||||
) -> Option<ProxySnapshot> {
|
||||
let object = raw?.as_object()?;
|
||||
if !proxy_enabled(object) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let node_id = json_string_field(object, "node_id");
|
||||
if let Some(snapshot) = self.resolve_proxy_node_snapshot(node_id.as_deref()).await {
|
||||
return Some(snapshot);
|
||||
}
|
||||
|
||||
proxy_snapshot_from_object(object)
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_enabled(object: &Map<String, Value>) -> bool {
|
||||
object
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn proxy_snapshot_from_object(object: &Map<String, Value>) -> Option<ProxySnapshot> {
|
||||
let mode = json_string_field(object, "mode");
|
||||
let node_id = json_string_field(object, "node_id");
|
||||
let label = json_string_field(object, "label");
|
||||
let url = json_string_field(object, "url").or_else(|| json_string_field(object, "proxy_url"));
|
||||
|
||||
if node_id.is_none() && url.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut extra = Map::new();
|
||||
for (key, value) in object {
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"enabled" | "mode" | "node_id" | "label" | "url" | "proxy_url"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
extra.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
Some(ProxySnapshot {
|
||||
enabled: object.get("enabled").and_then(Value::as_bool),
|
||||
mode,
|
||||
node_id,
|
||||
label,
|
||||
url,
|
||||
extra: if extra.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(extra))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn json_string_field(object: &Map<String, Value>, key: &str) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn proxy_mode_from_url(proxy_url: Option<&str>) -> Option<String> {
|
||||
let proxy_url = proxy_url?.trim();
|
||||
if proxy_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let scheme = url::Url::parse(proxy_url)
|
||||
.ok()
|
||||
.map(|value| value.scheme().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if scheme.starts_with("socks") {
|
||||
Some("socks".to_string())
|
||||
} else {
|
||||
Some("http".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_url_with_node_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())
|
||||
.unwrap_or_default();
|
||||
if parsed.set_password(Some(password)).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(parsed.to_string())
|
||||
}
|
||||
@@ -430,10 +430,13 @@ async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_princi
|
||||
admin_request(reqwest::Client::new().post(format!("{gateway_url}/api/admin/api-keys")))
|
||||
.json(&json!({
|
||||
"name": "standalone-key",
|
||||
"rate_limit": 180,
|
||||
"rate_limit": null,
|
||||
"allowed_providers": ["openai"],
|
||||
"allowed_api_formats": ["openai:chat"],
|
||||
"allowed_models": ["gpt-4.1"],
|
||||
"initial_balance_usd": 12.5,
|
||||
"expires_at": "2030-01-02",
|
||||
"auto_delete_on_expiry": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -443,11 +446,18 @@ async fn gateway_handles_admin_api_keys_create_locally_with_trusted_admin_princi
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["name"], json!("standalone-key"));
|
||||
assert_eq!(payload["is_standalone"], json!(true));
|
||||
assert_eq!(payload["rate_limit"], json!(180));
|
||||
assert_eq!(payload["rate_limit"], serde_json::Value::Null);
|
||||
assert_eq!(payload["allowed_providers"], json!(["openai"]));
|
||||
assert_eq!(payload["allowed_api_formats"], json!(["openai:chat"]));
|
||||
assert_eq!(payload["allowed_models"], json!(["gpt-4.1"]));
|
||||
assert_eq!(payload["wallet"], serde_json::Value::Null);
|
||||
assert_eq!(payload["auto_delete_on_expiry"], json!(true));
|
||||
assert_eq!(payload["wallet"]["balance"], json!(12.5));
|
||||
assert_eq!(payload["wallet"]["limit_mode"], json!("finite"));
|
||||
assert_eq!(payload["wallet"]["unlimited"], json!(false));
|
||||
assert!(payload["expires_at"]
|
||||
.as_str()
|
||||
.expect("expires_at should exist")
|
||||
.starts_with("2030-01-02"));
|
||||
let plaintext = payload["key"]
|
||||
.as_str()
|
||||
.expect("plaintext key should exist")
|
||||
@@ -489,6 +499,7 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_wallets_for_tests([sample_standalone_wallet("key-123")])
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_auth_api_key_repository_for_tests(Arc::clone(
|
||||
&repository,
|
||||
@@ -503,10 +514,13 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi
|
||||
)
|
||||
.json(&json!({
|
||||
"name": "renamed-key",
|
||||
"rate_limit": 240,
|
||||
"rate_limit": null,
|
||||
"allowed_providers": ["gemini"],
|
||||
"allowed_api_formats": ["gemini:chat"],
|
||||
"allowed_models": ["gemini-2.5-pro"],
|
||||
"expires_at": "2030-03-04",
|
||||
"auto_delete_on_expiry": true,
|
||||
"unlimited_balance": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -516,10 +530,17 @@ async fn gateway_handles_admin_api_keys_update_locally_with_trusted_admin_princi
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], json!("key-123"));
|
||||
assert_eq!(payload["name"], json!("renamed-key"));
|
||||
assert_eq!(payload["rate_limit"], json!(240));
|
||||
assert_eq!(payload["rate_limit"], serde_json::Value::Null);
|
||||
assert_eq!(payload["allowed_providers"], json!(["gemini"]));
|
||||
assert_eq!(payload["allowed_api_formats"], json!(["gemini:chat"]));
|
||||
assert_eq!(payload["allowed_models"], json!(["gemini-2.5-pro"]));
|
||||
assert_eq!(payload["auto_delete_on_expiry"], json!(true));
|
||||
assert_eq!(payload["wallet"]["limit_mode"], json!("unlimited"));
|
||||
assert_eq!(payload["wallet"]["unlimited"], json!(true));
|
||||
assert!(payload["expires_at"]
|
||||
.as_str()
|
||||
.expect("expires_at should exist")
|
||||
.starts_with("2030-03-04"));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
|
||||
@@ -1932,6 +1932,118 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtim
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_provider_oauth_refresh_token_via_execution_runtime_system_proxy() {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
let execution_plans_clone = Arc::clone(&execution_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
||||
async move {
|
||||
execution_plans_inner
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.push(plan.clone());
|
||||
let proxy = plan.proxy.as_ref().expect("proxy snapshot should exist");
|
||||
assert_eq!(proxy.node_id.as_deref(), Some("proxy-node-codex-system"));
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"access_token": "imported-codex-access-token",
|
||||
"refresh_token": "imported-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
"scope": "openid email profile offline_access",
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus"
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-chat",
|
||||
"provider-codex",
|
||||
"openai:chat",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
let mut manual_node = sample_proxy_node("proxy-node-codex-system");
|
||||
manual_node.status = "online".to_string();
|
||||
manual_node.is_manual = true;
|
||||
manual_node.tunnel_mode = false;
|
||||
manual_node.tunnel_connected = false;
|
||||
manual_node.proxy_url = Some("http://proxy.example:8080".to_string());
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"system_proxy_node_id".to_string(),
|
||||
json!("proxy-node-codex-system"),
|
||||
)])
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_provider_oauth_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"refresh_token": "provider-import-refresh-token",
|
||||
"name": "codex-import"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 1);
|
||||
assert_eq!(
|
||||
plans[0]
|
||||
.proxy
|
||||
.as_ref()
|
||||
.and_then(|proxy| proxy.node_id.as_deref()),
|
||||
Some("proxy-node-codex-system")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_batch_imports_admin_provider_oauth_kiro_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -2541,6 +2653,168 @@ async fn gateway_refreshes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refreshes_admin_provider_oauth_key_locally_via_execution_runtime_key_proxy_before_system_proxy(
|
||||
) {
|
||||
let execution_plans = Arc::new(Mutex::new(Vec::<ExecutionPlan>::new()));
|
||||
let execution_plans_clone = Arc::clone(&execution_plans);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_plans_inner = Arc::clone(&execution_plans_clone);
|
||||
async move {
|
||||
execution_plans_inner
|
||||
.lock()
|
||||
.expect("mutex should lock")
|
||||
.push(plan.clone());
|
||||
if plan.request_id == "provider-oauth:local-refresh-token" {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"access_token": "refreshed-codex-access-token",
|
||||
"refresh_token": "refreshed-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
"scope": "openid email profile offline_access",
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus"
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
provider.proxy = Some(json!({"node_id":"proxy-node-provider","enabled":true}));
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-cli",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-oauth-refresh",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"stale-codex-access-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.proxy = Some(json!({"node_id":"proxy-node-key","enabled":true}));
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","refresh_token":"old-codex-refresh-token","email":"alice@example.com","account_id":"acct-codex-123","plan_type":"plus","expires_at":1}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![key],
|
||||
));
|
||||
let mut key_node = sample_proxy_node("proxy-node-key");
|
||||
key_node.status = "online".to_string();
|
||||
key_node.is_manual = true;
|
||||
key_node.tunnel_mode = false;
|
||||
key_node.tunnel_connected = false;
|
||||
key_node.proxy_url = Some("http://proxy-key.example:8080".to_string());
|
||||
let mut provider_node = sample_proxy_node("proxy-node-provider");
|
||||
provider_node.status = "online".to_string();
|
||||
provider_node.is_manual = true;
|
||||
provider_node.tunnel_mode = false;
|
||||
provider_node.tunnel_connected = false;
|
||||
provider_node.proxy_url = Some("http://proxy-provider.example:8080".to_string());
|
||||
let mut system_node = sample_proxy_node("proxy-node-system");
|
||||
system_node.status = "online".to_string();
|
||||
system_node.is_manual = true;
|
||||
system_node.tunnel_mode = false;
|
||||
system_node.tunnel_connected = false;
|
||||
system_node.proxy_url = Some("http://proxy-system.example:8080".to_string());
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![
|
||||
key_node,
|
||||
provider_node,
|
||||
system_node,
|
||||
]));
|
||||
|
||||
let oauth_refresh =
|
||||
crate::provider_transport::LocalOAuthRefreshCoordinator::with_adapters_for_tests(vec![
|
||||
Arc::new(
|
||||
crate::provider_transport::oauth_refresh::GenericOAuthRefreshAdapter::default()
|
||||
.with_token_url_for_tests("codex", "https://oauth.example/oauth/token"),
|
||||
),
|
||||
]);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.attach_proxy_node_repository_for_tests(proxy_node_repository)
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"system_proxy_node_id".to_string(),
|
||||
json!("proxy-node-system"),
|
||||
)])
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_oauth_refresh_coordinator_for_tests(oauth_refresh),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/keys/key-codex-oauth-refresh/refresh"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
let refresh_plan = plans
|
||||
.iter()
|
||||
.find(|plan| plan.request_id == "provider-oauth:local-refresh-token")
|
||||
.expect("local refresh plan should exist");
|
||||
assert_eq!(
|
||||
refresh_plan
|
||||
.proxy
|
||||
.as_ref()
|
||||
.and_then(|proxy| proxy.node_id.as_deref()),
|
||||
Some("proxy-node-key")
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_oauth_unavailable_routes_locally_with_trusted_admin_principal(
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data::repository::proxy_nodes::{
|
||||
InMemoryProxyNodeRepository, ProxyNodeHeartbeatMutation, StoredProxyNodeEvent,
|
||||
};
|
||||
@@ -9,10 +10,11 @@ use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, sample_management_token, sample_proxy_node,
|
||||
start_server, AppState,
|
||||
build_router_with_state, hash_management_token, sample_endpoint, sample_key,
|
||||
sample_management_token, sample_provider, sample_proxy_node, start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
|
||||
@@ -715,6 +717,251 @@ async fn gateway_registers_and_unregisters_proxy_nodes_locally_with_management_t
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_creates_updates_and_tests_manual_proxy_nodes_locally() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let proxy_port = listener
|
||||
.local_addr()
|
||||
.expect("listener addr should resolve")
|
||||
.port();
|
||||
let accept_handle =
|
||||
tokio::spawn(async move { while let Ok((_stream, _addr)) = listener.accept().await {} });
|
||||
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::default());
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_proxy_node_repository_for_tests(
|
||||
proxy_node_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
let proxy_url = format!("http://127.0.0.1:{proxy_port}");
|
||||
|
||||
let create_response = client
|
||||
.post(format!("{gateway_url}/api/admin/proxy-nodes/manual"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"name": "manual-node",
|
||||
"proxy_url": proxy_url,
|
||||
"region": "US-West"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("create request should succeed");
|
||||
let create_status = create_response.status();
|
||||
let create_body = create_response
|
||||
.text()
|
||||
.await
|
||||
.expect("body should read as text");
|
||||
assert_eq!(create_status, StatusCode::OK, "create body: {create_body}");
|
||||
let create_payload: serde_json::Value =
|
||||
serde_json::from_str(&create_body).expect("json body should parse");
|
||||
let node_id = create_payload["node_id"]
|
||||
.as_str()
|
||||
.expect("node id should exist")
|
||||
.to_string();
|
||||
assert_eq!(create_payload["node"]["is_manual"], true);
|
||||
assert_eq!(create_payload["node"]["status"], "online");
|
||||
assert_eq!(create_payload["node"]["proxy_url"], proxy_url);
|
||||
|
||||
let test_url_response = client
|
||||
.post(format!("{gateway_url}/api/admin/proxy-nodes/test-url"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"proxy_url": proxy_url
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("test-url request should succeed");
|
||||
assert_eq!(test_url_response.status(), StatusCode::OK);
|
||||
let test_url_payload: serde_json::Value = test_url_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(test_url_payload["success"], true);
|
||||
assert!(test_url_payload["latency_ms"].is_u64());
|
||||
|
||||
let test_node_response = client
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/{node_id}/test"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("test-node request should succeed");
|
||||
assert_eq!(test_node_response.status(), StatusCode::OK);
|
||||
let test_node_payload: serde_json::Value = test_node_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(test_node_payload["success"], true);
|
||||
|
||||
let update_response = client
|
||||
.patch(format!("{gateway_url}/api/admin/proxy-nodes/{node_id}"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"name": "manual-node-updated",
|
||||
"region": "US-East"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("update request should succeed");
|
||||
assert_eq!(update_response.status(), StatusCode::OK);
|
||||
let update_payload: serde_json::Value = update_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(update_payload["node"]["name"], "manual-node-updated");
|
||||
assert_eq!(update_payload["node"]["region"], "US-East");
|
||||
assert_eq!(update_payload["node"]["proxy_url"], proxy_url);
|
||||
|
||||
gateway_handle.abort();
|
||||
accept_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_tests_disconnected_tunnel_proxy_nodes_locally() {
|
||||
let proxy_node_repository =
|
||||
Arc::new(InMemoryProxyNodeRepository::seed(vec![sample_proxy_node(
|
||||
"node-offline",
|
||||
)]));
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_proxy_node_repository_for_tests(
|
||||
proxy_node_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/proxy-nodes/node-offline/test"
|
||||
))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], false);
|
||||
assert_eq!(payload["error"], "tunnel 未连接");
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_deletes_proxy_nodes_and_clears_proxy_refs_locally() {
|
||||
let mut manual_node = sample_proxy_node("manual-node-1");
|
||||
manual_node.name = "manual-node-1".to_string();
|
||||
manual_node.status = "online".to_string();
|
||||
manual_node.is_manual = true;
|
||||
manual_node.tunnel_mode = false;
|
||||
manual_node.tunnel_connected = false;
|
||||
manual_node.proxy_url = Some("http://127.0.0.1:8899".to_string());
|
||||
manual_node.last_heartbeat_at_unix_secs = None;
|
||||
manual_node.tunnel_connected_at_unix_secs = None;
|
||||
|
||||
let proxy_node_repository = Arc::new(InMemoryProxyNodeRepository::seed(vec![manual_node]));
|
||||
let mut provider = sample_provider("provider-1", "OpenAI", 10);
|
||||
provider.proxy = Some(json!({ "node_id": "manual-node-1", "enabled": true }));
|
||||
let mut endpoint = sample_endpoint(
|
||||
"endpoint-1",
|
||||
"provider-1",
|
||||
"openai:chat",
|
||||
"https://example.com/v1",
|
||||
);
|
||||
endpoint.proxy = Some(json!({ "node_id": "manual-node-1", "enabled": true }));
|
||||
let mut key = sample_key("key-1", "provider-1", "openai:chat", "sk-test");
|
||||
key.proxy = Some(json!({ "node_id": "manual-node-1", "enabled": true }));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let data_state =
|
||||
GatewayDataState::with_proxy_node_repository_for_tests(Arc::clone(&proxy_node_repository))
|
||||
.attach_provider_catalog_repository_for_tests(Arc::clone(&provider_catalog_repository))
|
||||
.with_system_config_values_for_tests(vec![(
|
||||
"system_proxy_node_id".to_string(),
|
||||
json!("manual-node-1"),
|
||||
)]);
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data_state.clone()),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.delete(format!("{gateway_url}/api/admin/proxy-nodes/manual-node-1"))
|
||||
.header(GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["cleared_system_proxy"], true);
|
||||
assert_eq!(payload["cleared_providers"], 1);
|
||||
assert_eq!(payload["cleared_endpoints"], 1);
|
||||
assert_eq!(payload["cleared_keys"], 1);
|
||||
|
||||
assert!(data_state
|
||||
.find_proxy_node("manual-node-1")
|
||||
.await
|
||||
.expect("node lookup should succeed")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
data_state
|
||||
.find_system_config_value("system_proxy_node_id")
|
||||
.await
|
||||
.expect("system config lookup should succeed"),
|
||||
Some(serde_json::Value::Null)
|
||||
);
|
||||
|
||||
let provider_ids = vec!["provider-1".to_string()];
|
||||
let providers = data_state
|
||||
.list_provider_catalog_providers(false)
|
||||
.await
|
||||
.expect("provider list should succeed");
|
||||
assert!(providers.iter().all(|provider| provider.proxy.is_none()));
|
||||
let endpoints = data_state
|
||||
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.expect("endpoint list should succeed");
|
||||
assert!(endpoints.iter().all(|endpoint| endpoint.proxy.is_none()));
|
||||
let keys = data_state
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await
|
||||
.expect("key list should succeed");
|
||||
assert!(keys.iter().all(|key| key.proxy.is_none()));
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_proxy_node_events_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user