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
@@ -4,6 +4,7 @@ use super::super::errors::{
use super::json_non_empty_string;
use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
use axum::{body::Body, http, response::Response};
use url::form_urlencoded;
pub(crate) async fn exchange_admin_provider_oauth_code(
state: &AdminAppState<'_>,
@@ -11,9 +12,9 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
code: &str,
state_nonce: &str,
pkce_verifier: Option<&str>,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let request = state.http_client().post(token_url);
let response = if template.provider_type == "claude_code" {
let mut body = serde_json::Map::from_iter([
(
@@ -43,44 +44,78 @@ pub(crate) async fn exchange_admin_provider_oauth_code(
serde_json::Value::String(verifier.to_string()),
);
}
request
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&serde_json::Value::Object(body))
.send()
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:exchange-code",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy_node_id,
)
.await
} else {
let mut form = vec![
("grant_type", "authorization_code".to_string()),
("client_id", template.client_id.to_string()),
("redirect_uri", template.redirect_uri.to_string()),
("code", code.to_string()),
];
if !template.client_secret.trim().is_empty() {
form.push(("client_secret", template.client_secret.to_string()));
}
if let Some(verifier) = pkce_verifier {
form.push(("code_verifier", verifier.to_string()));
}
request
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.form(&form)
.send()
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "authorization_code");
form.append_pair("client_id", template.client_id);
form.append_pair("redirect_uri", template.redirect_uri);
form.append_pair("code", code);
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
}
if let Some(verifier) = pkce_verifier {
form.append_pair("code_verifier", verifier);
}
form.finish().into_bytes()
};
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:exchange-code",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy_node_id,
)
.await
}
.map_err(|_| {
build_internal_control_error_response(http::StatusCode::BAD_REQUEST, "token exchange 失败")
})?;
if !response.status().is_success() {
if !response.status.is_success() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 失败",
));
}
let payload = response.json::<serde_json::Value>().await.map_err(|_| {
let payload = response.json_body.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token exchange 返回缺少 access_token",
@@ -99,9 +134,9 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
state: &AdminAppState<'_>,
template: AdminProviderOAuthTemplate,
refresh_token: &str,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
let token_url = state.provider_oauth_token_url(template.provider_type, template.token_url);
let request = state.http_client().post(token_url);
let scope = template.scopes.join(" ");
let response = if template.provider_type == "claude_code" {
let mut body = serde_json::Map::from_iter([
@@ -121,29 +156,63 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
if !scope.trim().is_empty() {
body.insert("scope".to_string(), serde_json::Value::String(scope));
}
request
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&serde_json::Value::Object(body))
.send()
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:refresh-token",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/json"),
Some(serde_json::Value::Object(body)),
None,
proxy_node_id,
)
.await
} else {
let mut form = vec![
("grant_type", "refresh_token".to_string()),
("client_id", template.client_id.to_string()),
("refresh_token", refresh_token.to_string()),
];
if !scope.trim().is_empty() {
form.push(("scope", scope));
}
if !template.client_secret.trim().is_empty() {
form.push(("client_secret", template.client_secret.to_string()));
}
request
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.form(&form)
.send()
let form_body = {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", "refresh_token");
form.append_pair("client_id", template.client_id);
form.append_pair("refresh_token", refresh_token);
if !scope.trim().is_empty() {
form.append_pair("scope", &scope);
}
if !template.client_secret.trim().is_empty() {
form.append_pair("client_secret", template.client_secret);
}
form.finish().into_bytes()
};
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/x-www-form-urlencoded"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json"),
),
]);
state
.execute_admin_provider_oauth_http_request(
"provider-oauth:refresh-token",
reqwest::Method::POST,
&token_url,
&headers,
Some("application/x-www-form-urlencoded"),
None,
Some(form_body),
proxy_node_id,
)
.await
}
.map_err(|_| {
@@ -153,13 +222,8 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
)
})?;
let status = response.status();
let body = response.text().await.map_err(|_| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"Refresh Token 验证失败: token exchange 失败",
)
})?;
let status = response.status;
let body = response.body_text;
if !status.is_success() {
let reason =
normalize_provider_oauth_refresh_error_message(Some(status.as_u16()), Some(&body));
@@ -169,12 +233,15 @@ pub(crate) async fn exchange_admin_provider_oauth_refresh_token(
));
}
let payload = serde_json::from_str::<serde_json::Value>(&body).map_err(|_| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token refresh 返回缺少 access_token",
)
})?;
let payload = response
.json_body
.or_else(|| serde_json::from_str::<serde_json::Value>(&body).ok())
.ok_or_else(|| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"token refresh 返回缺少 access_token",
)
})?;
if json_non_empty_string(payload.get("access_token")).is_none() {
return Err(build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,