feat(admin): 完善代理节点与 OAuth 授权管理

This commit is contained in:
fawney19
2026-04-14 14:09:24 +08:00
parent 593640ac19
commit 861ae81ff0
44 changed files with 2757 additions and 365 deletions

View File

@@ -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

View File

@@ -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),
})
}

View File

@@ -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),

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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} 必须是非负整数")));

View File

@@ -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);
};

View File

@@ -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"))