mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 10:27:46 +08:00
feat(proxy): 实现代理节点批量升级回滚、隧道重定向跟随及远程配置管理
核心功能: - 新增代理节点批量升级回滚工作流,支持分批升级、健康探针、跳过/重试/取消等操作 - proxy 隧道流处理器支持 HTTP 重定向跟随(最多 10 跳),区分 307/308 可重播与不可重播请求体 - proxy 协议新增 follow_redirects / http1_only 字段,网关侧同步支持 - 新增代理节点远端配置变更接口(名称、允许端口、调度状态、升级目标等) - 新增代理节点注册/反注册/心跳的 Admin API,及节点过期清理维护任务 - gateway 隧道 owner-relay 支持流式代理大请求体,新增 5 MiB 默认限制 - 新增 ProxyNodeRegistrationMutation / ProxyNodeRemoteConfigMutation 数据类型 - proxy 配置新增重定向重播预算、心跳间隔等参数,TUI 安装向导同步更新 - 前端 ProxyNodes 页面新增批量升级操作面板及滚动进度展示
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_contracts::ExecutionPlan;
|
||||
use aether_contracts::{
|
||||
ExecutionPlan, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
|
||||
};
|
||||
use aether_crypto::{
|
||||
decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY,
|
||||
};
|
||||
@@ -466,7 +468,7 @@ async fn gateway_saves_admin_provider_ops_config_locally_with_trusted_admin_prin
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], true);
|
||||
assert_eq!(payload["success"], true, "payload={payload}");
|
||||
assert_eq!(payload["message"], "配置保存成功");
|
||||
|
||||
let stored_provider = provider_catalog_repository
|
||||
@@ -1142,6 +1144,12 @@ async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_proxy_mode()
|
||||
assert_eq!(parsed_proxy.password(), Some("supersecret"));
|
||||
|
||||
if plan.url.ends_with("/api/user/self") {
|
||||
assert_eq!(
|
||||
plan.headers
|
||||
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
||||
.map(String::as_str),
|
||||
None
|
||||
);
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
@@ -1160,6 +1168,14 @@ async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_proxy_mode()
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
assert_eq!(plan.request_id, "provider-ops-acw:anyrouter");
|
||||
assert_eq!(plan.url, "https://ops.example");
|
||||
assert_eq!(
|
||||
plan.headers
|
||||
.get(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER)
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
@@ -1242,9 +1258,159 @@ async fn gateway_verifies_admin_provider_ops_locally_for_anyrouter_proxy_mode()
|
||||
assert_eq!(payload["data"]["request_count"], 8);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 1);
|
||||
assert_eq!(plans[0].request_id, "provider-ops-verify:anyrouter");
|
||||
assert_eq!(plans[0].url, "https://ops.example/api/user/self");
|
||||
assert_eq!(plans.len(), 2);
|
||||
assert_eq!(plans[0].request_id, "provider-ops-acw:anyrouter");
|
||||
assert_eq!(plans[0].url, "https://ops.example");
|
||||
assert_eq!(plans[1].request_id, "provider-ops-verify:anyrouter");
|
||||
assert_eq!(plans[1].url, "https://ops.example/api/user/self");
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_verifies_admin_provider_ops_sub2api_proxy_mode_via_execution_runtime_http1_only() {
|
||||
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-sub2api"));
|
||||
assert_eq!(
|
||||
plan.headers
|
||||
.get(EXECUTION_REQUEST_HTTP1_ONLY_HEADER)
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
|
||||
if plan.url.ends_with("/api/v1/auth/refresh") {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"access_token": "sub2api-access-token",
|
||||
"refresh_token": "sub2api-refresh-token-new"
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer sub2api-access-token")
|
||||
);
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"username": "sub2api-user",
|
||||
"email": "[email protected]",
|
||||
"balance": 8.5,
|
||||
"points": 1.5,
|
||||
"status": "active",
|
||||
"concurrency": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![],
|
||||
));
|
||||
let mut manual_node = sample_proxy_node("proxy-node-sub2api");
|
||||
manual_node.name = "sub2api-manual".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://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,
|
||||
)
|
||||
.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-sub2api"),
|
||||
)]),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
||||
))
|
||||
.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!({
|
||||
"architecture_id": "sub2api",
|
||||
"base_url": "https://sub2api.example",
|
||||
"connector": {
|
||||
"auth_type": "session_login",
|
||||
"config": {},
|
||||
"credentials": {
|
||||
"refresh_token": "refresh-token-old",
|
||||
}
|
||||
},
|
||||
"actions": {},
|
||||
"schedule": {},
|
||||
}))
|
||||
.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"], true);
|
||||
assert_eq!(payload["data"]["username"], "sub2api-user");
|
||||
assert_eq!(
|
||||
payload["updated_credentials"]["refresh_token"],
|
||||
"sub2api-refresh-token-new"
|
||||
);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 2);
|
||||
assert_eq!(plans[0].url, "https://sub2api.example/api/v1/auth/refresh");
|
||||
assert!(
|
||||
plans[1]
|
||||
.url
|
||||
.starts_with("https://sub2api.example/api/v1/auth/me"),
|
||||
"url={}",
|
||||
plans[1].url
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
@@ -1516,6 +1682,109 @@ async fn gateway_verifies_admin_provider_ops_locally_for_new_api_proxy_node_mode
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_verifies_admin_provider_ops_locally_for_new_api_without_proxy_via_execution_runtime(
|
||||
) {
|
||||
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());
|
||||
assert_eq!(plan.request_id, "provider-ops-verify:new_api");
|
||||
assert_eq!(plan.url, "https://ops.example/api/user/self");
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer live-secret-api-key")
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("new-api-user").map(String::as_str),
|
||||
Some("42")
|
||||
);
|
||||
assert!(plan.proxy.is_none());
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"username": "alice",
|
||||
"display_name": "Alice",
|
||||
"quota": 42.5,
|
||||
"used_quota": 12.5,
|
||||
"request_count": 9
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "openai", 10)],
|
||||
vec![],
|
||||
vec![],
|
||||
));
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/verify"
|
||||
))
|
||||
.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!({
|
||||
"architecture_id": "new_api",
|
||||
"base_url": "https://ops.example",
|
||||
"connector": {
|
||||
"auth_type": "api_key",
|
||||
"config": {},
|
||||
"credentials": {
|
||||
"api_key": "live-secret-api-key",
|
||||
"user_id": "42",
|
||||
"cookie": "session=foo"
|
||||
}
|
||||
},
|
||||
"actions": {},
|
||||
"schedule": {},
|
||||
}))
|
||||
.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"], true);
|
||||
assert_eq!(payload["data"]["username"], "alice");
|
||||
assert_eq!(payload["data"]["used_quota"], 12.5);
|
||||
assert_eq!(payload["data"]["request_count"], 9);
|
||||
assert_eq!(execution_plans.lock().expect("mutex should lock").len(), 1);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_verifies_admin_provider_ops_locally_for_sub2api_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -2446,6 +2715,145 @@ async fn gateway_handles_admin_provider_ops_balance_locally_for_generic_api_prox
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_ops_balance_locally_without_proxy_via_execution_runtime() {
|
||||
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());
|
||||
assert!(plan.proxy.is_none());
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer live-secret-api-key")
|
||||
);
|
||||
|
||||
if plan.url.ends_with("/api/user/checkin") {
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"success": true,
|
||||
"message": "执行层签到成功"
|
||||
}
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
assert!(plan.url.ends_with("/api/user/balance"));
|
||||
Json(json!({
|
||||
"request_id": plan.request_id,
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"quota": 2500000,
|
||||
"used_quota": 500000
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![
|
||||
sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"provider_ops": {
|
||||
"architecture_id": "generic_api",
|
||||
"base_url": "https://ops.example",
|
||||
"connector": {
|
||||
"auth_type": "api_key",
|
||||
"config": {
|
||||
"auth_method": "bearer"
|
||||
},
|
||||
"credentials": {
|
||||
"api_key": encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
"live-secret-api-key",
|
||||
).expect("api key should encrypt"),
|
||||
}
|
||||
}
|
||||
}
|
||||
})),
|
||||
),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
));
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/provider-ops/providers/provider-openai/balance?refresh=false"
|
||||
))
|
||||
.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["status"], "success");
|
||||
assert_eq!(payload["data"]["total_available"], 5.0);
|
||||
assert_eq!(payload["data"]["total_used"], 1.0);
|
||||
assert_eq!(payload["data"]["extra"]["checkin_success"], true);
|
||||
assert_eq!(
|
||||
payload["data"]["extra"]["checkin_message"],
|
||||
"执行层签到成功"
|
||||
);
|
||||
|
||||
let plans = execution_plans.lock().expect("mutex should lock");
|
||||
assert_eq!(plans.len(), 2);
|
||||
assert!(plans.iter().all(|plan| plan.proxy.is_none()));
|
||||
assert!(plans
|
||||
.iter()
|
||||
.any(|plan| plan.request_id == "provider-ops-action:probe_checkin"));
|
||||
assert!(plans.iter().any(|plan| {
|
||||
plan.request_id == "provider-ops-action:generic_api:query_balance:provider-openai"
|
||||
}));
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_ops_checkin_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
Reference in New Issue
Block a user