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:
fawney19
2026-04-12 16:02:38 +08:00
parent 7c5bb7f383
commit 9703840a36
83 changed files with 11832 additions and 1520 deletions

View File

@@ -1,16 +1,20 @@
use std::collections::BTreeMap;
use std::error::Error as _;
use std::io::Read;
use std::io::Write;
use std::time::{Duration, Instant};
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTelemetry, ProxySnapshot, ResponseBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
};
use aether_http::{apply_http_client_config, HttpClientConfig};
use base64::Engine as _;
use flate2::read::{DeflateDecoder, GzDecoder};
use flate2::write::GzEncoder;
use flate2::Compression;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use reqwest::redirect::Policy;
use reqwest::tls::Version;
use serde::Serialize;
use serde_json::Value;
@@ -116,11 +120,21 @@ struct RelayRequestMeta {
url: String,
headers: BTreeMap<String, String>,
timeout: u64,
#[serde(skip_serializing_if = "Option::is_none")]
follow_redirects: Option<bool>,
#[serde(default, skip_serializing_if = "is_false")]
http1_only: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct DirectSyncExecutionRuntime;
#[derive(Debug, Clone, Copy, Default)]
struct ExecutionTransportControls {
follow_redirects: Option<bool>,
http1_only: bool,
}
#[derive(Debug)]
pub(crate) struct DirectUpstreamStreamExecution {
pub(crate) request_id: String,
@@ -150,6 +164,8 @@ impl DirectSyncExecutionRuntime {
let body_bytes = response.bytes().await.map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format_upstream_request_error(&err))
})?;
let decoded_body_bytes = decode_response_body_bytes(&headers, &body_bytes)
.unwrap_or_else(|| body_bytes.to_vec());
let elapsed_ms = started_at.elapsed().as_millis() as u64;
let upstream_bytes = body_bytes.len() as u64;
@@ -160,8 +176,8 @@ impl DirectSyncExecutionRuntime {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)),
})
} else if response_body_is_json(&headers, &body_bytes) {
let body_json: Value = serde_json::from_slice(&body_bytes)
} else if response_body_is_json(&headers, &decoded_body_bytes) {
let body_json: Value = serde_json::from_slice(&decoded_body_bytes)
.map_err(ExecutionRuntimeTransportError::InvalidJson)?;
Some(ResponseBody {
json_body: Some(body_json),
@@ -253,6 +269,7 @@ async fn send_request(
}
let method = plan.method.parse::<reqwest::Method>()?;
let transport_controls = resolve_execution_transport_controls(&plan.headers);
let headers = build_request_headers(
&plan.headers,
plan.content_encoding.as_deref(),
@@ -266,14 +283,23 @@ async fn send_request(
.map(Duration::from_millis);
if let Some(node_id) = resolve_tunnel_node_id(plan.proxy.as_ref()) {
return send_via_tunnel_relay(plan, method, headers, body_bytes, &node_id, total_timeout)
.await;
return send_via_tunnel_relay(
plan,
method,
headers,
body_bytes,
&node_id,
total_timeout,
transport_controls,
)
.await;
}
let client = build_client(
plan.timeouts.as_ref(),
plan.proxy.as_ref(),
plan.tls_profile.as_deref(),
transport_controls,
)?;
let mut request = client.request(method, &plan.url);
request = request.headers(headers).body(body_bytes);
@@ -320,6 +346,7 @@ async fn send_via_tunnel_relay(
body_bytes: Vec<u8>,
node_id: &str,
total_timeout: Option<Duration>,
transport_controls: ExecutionTransportControls,
) -> Result<reqwest::Response, ExecutionRuntimeTransportError> {
let client = build_relay_client(plan.timeouts.as_ref())?;
let relay_url = build_relay_url(plan.proxy.as_ref(), node_id);
@@ -329,6 +356,8 @@ async fn send_via_tunnel_relay(
url: plan.url.clone(),
headers: header_map_to_string_map(&headers),
timeout: resolve_relay_timeout_seconds(plan),
follow_redirects: transport_controls.follow_redirects,
http1_only: transport_controls.http1_only,
},
&body_bytes,
)?;
@@ -501,9 +530,17 @@ fn build_client(
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
proxy: Option<&ProxySnapshot>,
tls_profile: Option<&str>,
transport_controls: ExecutionTransportControls,
) -> Result<reqwest::Client, ExecutionRuntimeTransportError> {
let mut builder = reqwest::Client::builder();
if transport_controls.follow_redirects != Some(true) {
builder = builder.redirect(Policy::none());
}
if transport_controls.http1_only {
builder = builder.http1_only();
}
let mut builder = apply_http_client_config(
reqwest::Client::builder(),
builder,
&HttpClientConfig {
connect_timeout_ms: timeouts.and_then(|timeouts| timeouts.connect_ms),
..HttpClientConfig::default()
@@ -608,7 +645,11 @@ fn build_request_headers(
}
for (key, value) in headers {
let normalized_key = key.trim().to_ascii_lowercase();
if is_hop_by_hop_header(&normalized_key) || normalized_key == "content-encoding" {
if is_hop_by_hop_header(&normalized_key)
|| normalized_key == "content-encoding"
|| normalized_key == EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER
|| normalized_key == EXECUTION_REQUEST_HTTP1_ONLY_HEADER
{
continue;
}
@@ -629,6 +670,43 @@ fn build_request_headers(
Ok(out)
}
fn resolve_execution_transport_controls(
headers: &BTreeMap<String, String>,
) -> ExecutionTransportControls {
ExecutionTransportControls {
follow_redirects: execution_transport_header_value(
headers,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
)
.and_then(|value| parse_execution_transport_bool(value)),
http1_only: execution_transport_header_value(headers, EXECUTION_REQUEST_HTTP1_ONLY_HEADER)
.and_then(|value| parse_execution_transport_bool(value))
.unwrap_or(false),
}
}
fn execution_transport_header_value<'a>(
headers: &'a BTreeMap<String, String>,
target: &str,
) -> Option<&'a str> {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(target))
.map(|(_, value)| value.as_str())
}
fn parse_execution_transport_bool(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn is_false(value: &bool) -> bool {
!*value
}
fn header_map_to_string_map(headers: &HeaderMap) -> BTreeMap<String, String> {
headers
.iter()
@@ -661,6 +739,33 @@ fn collect_response_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
header_map_to_string_map(headers)
}
fn decode_response_body_bytes(
headers: &BTreeMap<String, String>,
body_bytes: &[u8],
) -> Option<Vec<u8>> {
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(body_bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
Some("deflate") => {
let mut decoder = DeflateDecoder::new(body_bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
_ => None,
}
}
fn response_body_is_json(headers: &BTreeMap<String, String>, body_bytes: &[u8]) -> bool {
if headers
.get("content-type")
@@ -678,7 +783,10 @@ mod tests {
use std::collections::BTreeMap;
use std::io::Read;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, RequestBody};
use aether_contracts::{
ExecutionPlan, ExecutionTimeouts, RequestBody, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
};
use axum::body::Bytes;
use axum::extract::Path;
use axum::routing::post;
@@ -884,6 +992,229 @@ mod tests {
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_disables_redirects_by_default() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let app = Router::new()
.route(
"/redirect",
post(|| async {
(
axum::http::StatusCode::TEMPORARY_REDIRECT,
[(
axum::http::header::LOCATION,
axum::http::HeaderValue::from_static("/final"),
)],
)
}),
)
.route(
"/final",
post(|| async {
(
axum::http::StatusCode::OK,
Json(json!({"redirected": true})),
)
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test server should run");
});
let execution_runtime = DirectSyncExecutionRuntime::new();
let result = execution_runtime
.execute_sync(ExecutionPlan {
request_id: "req-redirect-1".into(),
candidate_id: None,
provider_name: Some("provider_ops".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: format!("http://{addr}/redirect"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "gpt-4.1"})),
stream: false,
client_api_format: "provider_ops:verify".into(),
provider_api_format: "provider_ops:verify".into(),
model_name: Some("verify-auth".into()),
proxy: None,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
..ExecutionTimeouts::default()
}),
})
.await
.expect("sync execution should succeed");
server.abort();
assert_eq!(result.status_code, 307);
assert_eq!(
result.headers.get("location").map(String::as_str),
Some("/final")
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_follows_redirects_when_explicitly_enabled() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let app = Router::new()
.route(
"/redirect",
post(|| async {
(
axum::http::StatusCode::TEMPORARY_REDIRECT,
[(
axum::http::header::LOCATION,
axum::http::HeaderValue::from_static("/final"),
)],
)
}),
)
.route(
"/final",
post(|| async {
(
axum::http::StatusCode::OK,
Json(json!({"redirected": true})),
)
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test server should run");
});
let execution_runtime = DirectSyncExecutionRuntime::new();
let result = execution_runtime
.execute_sync(ExecutionPlan {
request_id: "req-redirect-2".into(),
candidate_id: None,
provider_name: Some("provider_oauth".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: format!("http://{addr}/redirect"),
headers: BTreeMap::from([
("content-type".into(), "application/json".into()),
(
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.into(),
"true".into(),
),
]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "gpt-4.1"})),
stream: false,
client_api_format: "provider_oauth:exchange".into(),
provider_api_format: "provider_oauth:exchange".into(),
model_name: Some("oauth-exchange".into()),
proxy: None,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
..ExecutionTimeouts::default()
}),
})
.await
.expect("sync execution should succeed");
server.abort();
assert_eq!(result.status_code, 200);
assert_eq!(
result.body.and_then(|body| body.json_body),
Some(json!({"redirected": true}))
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_forwards_http1_only_control_to_tunnel_relay() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let app = Router::new().route(
"/api/internal/tunnel/relay/{node_id}",
post(|Path(node_id): Path<String>, body: Bytes| async move {
let (meta, request_body) = decode_relay_envelope(&body);
assert_eq!(node_id, "node-1");
assert_eq!(meta["http1_only"], true);
assert_eq!(meta["follow_redirects"], json!(false));
let request_json: serde_json::Value =
serde_json::from_slice(&request_body).expect("request body should be json");
assert_eq!(request_json["model"], "gpt-4.1");
(axum::http::StatusCode::OK, Json(json!({"ok": true})))
}),
);
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("relay test server should run");
});
let execution_runtime = DirectSyncExecutionRuntime::new();
let result = execution_runtime
.execute_sync(ExecutionPlan {
request_id: "req-relay-http1-1".into(),
candidate_id: None,
provider_name: Some("provider_ops".into()),
provider_id: "prov-1".into(),
endpoint_id: "ep-1".into(),
key_id: "key-1".into(),
method: "POST".into(),
url: "https://example.com/chat".into(),
headers: BTreeMap::from([
("content-type".into(), "application/json".into()),
(EXECUTION_REQUEST_HTTP1_ONLY_HEADER.into(), "true".into()),
(
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.into(),
"false".into(),
),
]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "gpt-4.1"})),
stream: false,
client_api_format: "provider_ops:verify".into(),
provider_api_format: "provider_ops:verify".into(),
model_name: Some("verify-auth".into()),
proxy: Some(tunnel_proxy_snapshot(format!("http://{addr}"))),
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(5_000),
..ExecutionTimeouts::default()
}),
})
.await
.expect("tunnel relay execution should succeed");
server.abort();
assert_eq!(result.status_code, 200);
assert_eq!(
result.body.and_then(|body| body.json_body),
Some(json!({"ok": true}))
);
}
#[tokio::test]
async fn direct_sync_execution_runtime_allows_tls_profile_best_effort() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")