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
@@ -1,5 +1,7 @@
use super::super::super::support::AdminProviderOpsCheckinOutcome;
use super::super::super::verify::admin_provider_ops_execute_proxy_json_request;
use super::super::super::verify::{
admin_provider_ops_execute_json_request, AdminProviderOpsExecuteJsonError,
};
use super::super::support::{admin_provider_ops_json_object_map, admin_provider_ops_request_url};
use super::shared::{
admin_provider_ops_checkin_already_done, admin_provider_ops_checkin_auth_failure,
@@ -27,40 +29,20 @@ pub(in super::super) async fn admin_provider_ops_probe_new_api_checkin(
&admin_provider_ops_json_object_map(json!({ "endpoint": endpoint })),
endpoint,
);
let (status, response_json) = if let Some(proxy_snapshot) = proxy_snapshot {
match admin_provider_ops_execute_proxy_json_request(
state,
"provider-ops-action:probe_checkin",
reqwest::Method::POST,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(_) => return None,
}
} else {
let response = match state
.http_client()
.request(reqwest::Method::POST, url)
.headers(headers.clone())
.send()
.await
{
Ok(response) => response,
Err(_) => return None,
};
let status = response.status();
let response_json = match response.bytes().await {
Ok(bytes) => {
serde_json::from_slice::<serde_json::Value>(&bytes).unwrap_or_else(|_| json!({}))
}
Err(_) => json!({}),
};
(status, response_json)
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
"provider-ops-action:probe_checkin",
reqwest::Method::POST,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(_))
| Err(AdminProviderOpsExecuteJsonError::Transport(_)) => return None,
};
if status == http::StatusCode::NOT_FOUND {
@@ -1,5 +1,7 @@
use super::super::super::support::ADMIN_PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE;
use super::super::super::verify::admin_provider_ops_execute_proxy_json_request;
use super::super::super::verify::{
admin_provider_ops_execute_json_request, AdminProviderOpsExecuteJsonError,
};
use super::super::responses::{
admin_provider_ops_action_error, admin_provider_ops_action_not_supported,
admin_provider_ops_action_response,
@@ -32,80 +34,37 @@ pub(in super::super) async fn admin_provider_ops_run_checkin_action(
let url = admin_provider_ops_request_url(base_url, action_config, "/api/user/checkin");
let method = admin_provider_ops_request_method(action_config, "POST");
let (status, response_json) = if let Some(proxy_snapshot) = proxy_snapshot {
match admin_provider_ops_execute_proxy_json_request(
state,
&format!(
"provider-ops-action:{}:checkin",
architecture.architecture_id
),
method,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"checkin",
admin_provider_ops_network_error_message(&err),
None,
);
}
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
&format!(
"provider-ops-action:{}:checkin",
architecture.architecture_id
),
method,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(_)) => {
return admin_provider_ops_action_error(
"parse_error",
"checkin",
"响应不是有效的 JSON",
Some(start.elapsed().as_millis() as u64),
);
}
Err(AdminProviderOpsExecuteJsonError::Transport(err)) => {
return admin_provider_ops_action_error(
"network_error",
"checkin",
admin_provider_ops_network_error_message(&err),
None,
);
}
} else {
let response = match state
.http_client()
.request(method, url)
.headers(headers.clone())
.send()
.await
{
Ok(response) => response,
Err(err) if err.is_timeout() => {
return admin_provider_ops_action_error(
"network_error",
"checkin",
"请求超时",
None,
);
}
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"checkin",
format!("网络错误: {err}"),
None,
);
}
};
let status = response.status();
let response_json = match response.bytes().await {
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(value) => value,
Err(_) => {
return admin_provider_ops_action_error(
"parse_error",
"checkin",
"响应不是有效的 JSON",
Some(start.elapsed().as_millis() as u64),
);
}
},
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"checkin",
format!("网络错误: {err}"),
Some(start.elapsed().as_millis() as u64),
);
}
};
(status, response_json)
};
let response_time_ms = Some(start.elapsed().as_millis() as u64);
@@ -2,7 +2,9 @@ mod sub2api;
mod yescode;
use super::super::support::AdminProviderOpsCheckinOutcome;
use super::super::verify::admin_provider_ops_execute_proxy_json_request;
use super::super::verify::{
admin_provider_ops_execute_json_request, AdminProviderOpsExecuteJsonError,
};
use super::checkin::admin_provider_ops_probe_new_api_checkin;
use super::responses::{admin_provider_ops_action_error, admin_provider_ops_action_response};
use super::support::{admin_provider_ops_request_method, admin_provider_ops_request_url};
@@ -73,80 +75,37 @@ pub(super) async fn admin_provider_ops_run_query_balance_action(
let start = std::time::Instant::now();
let url = admin_provider_ops_request_url(base_url, action_config, "/api/user/balance");
let method = admin_provider_ops_request_method(action_config, "GET");
let (status, response_json) = if let Some(proxy_snapshot) = proxy_snapshot {
match admin_provider_ops_execute_proxy_json_request(
state,
&format!(
"provider-ops-action:{}:query_balance:{provider_id}",
architecture.architecture_id
),
method,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
admin_provider_ops_network_error_message(&err),
None,
);
}
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
&format!(
"provider-ops-action:{}:query_balance:{provider_id}",
architecture.architecture_id
),
method,
&url,
headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(_)) => {
return admin_provider_ops_action_error(
"parse_error",
"query_balance",
"响应不是有效的 JSON",
Some(start.elapsed().as_millis() as u64),
);
}
Err(AdminProviderOpsExecuteJsonError::Transport(err)) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
admin_provider_ops_network_error_message(&err),
None,
);
}
} else {
let response = match state
.http_client()
.request(method, url)
.headers(headers.clone())
.send()
.await
{
Ok(response) => response,
Err(err) if err.is_timeout() => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
"请求超时",
None,
);
}
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
format!("网络错误: {err}"),
None,
);
}
};
let status = response.status();
let response_json = match response.bytes().await {
Ok(bytes) => match serde_json::from_slice::<serde_json::Value>(&bytes) {
Ok(value) => value,
Err(_) => {
return admin_provider_ops_action_error(
"parse_error",
"query_balance",
"响应不是有效的 JSON",
Some(start.elapsed().as_millis() as u64),
);
}
},
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
format!("网络错误: {err}"),
Some(start.elapsed().as_millis() as u64),
);
}
};
(status, response_json)
};
let response_time_ms = Some(start.elapsed().as_millis() as u64);
@@ -1,7 +1,7 @@
use super::super::super::config::persist_admin_provider_ops_runtime_credentials;
use super::super::super::verify::{
admin_provider_ops_execute_proxy_json_request, admin_provider_ops_sub2api_exchange_token,
admin_provider_ops_sub2api_request_url,
admin_provider_ops_execute_json_request, admin_provider_ops_sub2api_exchange_token,
admin_provider_ops_sub2api_request_url, AdminProviderOpsExecuteJsonError,
};
use super::super::responses::{
admin_provider_ops_action_error, admin_provider_ops_action_response,
@@ -93,97 +93,37 @@ pub(super) async fn admin_provider_ops_sub2api_balance_payload(
};
let auth_headers =
reqwest::header::HeaderMap::from_iter([(reqwest::header::AUTHORIZATION, auth_value)]);
let (me_result, subscription_result) = if let Some(proxy_snapshot) = proxy_snapshot {
let me_request_id = format!("provider-ops-action:sub2api:me:{provider_id}");
let subscription_request_id =
format!("provider-ops-action:sub2api:subscriptions:{provider_id}");
tokio::join!(
admin_provider_ops_execute_proxy_json_request(
state,
&me_request_id,
reqwest::Method::GET,
&me_url,
&auth_headers,
None,
proxy_snapshot,
),
admin_provider_ops_execute_proxy_json_request(
state,
&subscription_request_id,
reqwest::Method::GET,
&subscription_url,
&auth_headers,
None,
proxy_snapshot,
)
let me_request_id = format!("provider-ops-action:sub2api:me:{provider_id}");
let subscription_request_id =
format!("provider-ops-action:sub2api:subscriptions:{provider_id}");
let (me_result, subscription_result) = tokio::join!(
admin_provider_ops_execute_json_request(
state,
&me_request_id,
reqwest::Method::GET,
&me_url,
&auth_headers,
None,
proxy_snapshot,
),
admin_provider_ops_execute_json_request(
state,
&subscription_request_id,
reqwest::Method::GET,
&subscription_url,
&auth_headers,
None,
proxy_snapshot,
)
} else {
let http_client = state.http_client();
let (me_response, subscription_response) = tokio::join!(
http_client.get(me_url).bearer_auth(&access_token).send(),
http_client
.get(subscription_url)
.bearer_auth(&access_token)
.send()
);
let me_result = match me_response {
Ok(response) => {
let status = response.status();
let value = match response.bytes().await {
Ok(bytes) => {
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| json!({}))
}
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
format!("网络错误: {err}"),
Some(start.elapsed().as_millis() as u64),
)
}
};
Ok((status, value))
}
Err(err) if err.is_timeout() => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
"请求超时",
Some(start.elapsed().as_millis() as u64),
);
}
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
format!("网络错误: {err}"),
Some(start.elapsed().as_millis() as u64),
);
}
};
let subscription_result = match subscription_response {
Ok(response) => {
let status = response.status();
let value = match response.bytes().await {
Ok(bytes) => {
serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| json!({}))
}
Err(err) => {
return admin_provider_ops_action_error(
"network_error",
"query_balance",
format!("网络错误: {err}"),
Some(start.elapsed().as_millis() as u64),
)
}
};
Ok((status, value))
}
Err(err) if err.is_timeout() => Err("请求超时".to_string()),
Err(err) => Err(format!("网络错误: {err}")),
};
(me_result, subscription_result)
};
);
let me_result = me_result.map_err(|err| match err {
AdminProviderOpsExecuteJsonError::InvalidJson(message)
| AdminProviderOpsExecuteJsonError::Transport(message) => message,
});
let subscription_result = subscription_result.map_err(|err| match err {
AdminProviderOpsExecuteJsonError::InvalidJson(message)
| AdminProviderOpsExecuteJsonError::Transport(message) => message,
});
let response_time_ms = Some(start.elapsed().as_millis() as u64);
let (me_status, me_json) = match me_result {
@@ -1,4 +1,6 @@
use super::super::super::verify::admin_provider_ops_execute_proxy_json_request;
use super::super::super::verify::{
admin_provider_ops_execute_json_request, AdminProviderOpsExecuteJsonError,
};
use super::super::responses::{
admin_provider_ops_action_error, admin_provider_ops_action_response,
};
@@ -17,65 +19,34 @@ pub(super) async fn admin_provider_ops_yescode_balance_payload(
let start = std::time::Instant::now();
let balance_url = format!("{}/api/v1/user/balance", base_url.trim_end_matches('/'));
let profile_url = format!("{}/api/v1/auth/profile", base_url.trim_end_matches('/'));
let (balance_result, profile_result) = if let Some(proxy_snapshot) = proxy_snapshot {
tokio::join!(
admin_provider_ops_execute_proxy_json_request(
state,
"provider-ops-action:yescode:balance",
reqwest::Method::GET,
&balance_url,
headers,
None,
proxy_snapshot,
),
admin_provider_ops_execute_proxy_json_request(
state,
"provider-ops-action:yescode:profile",
reqwest::Method::GET,
&profile_url,
headers,
None,
proxy_snapshot,
)
let (balance_result, profile_result) = tokio::join!(
admin_provider_ops_execute_json_request(
state,
"provider-ops-action:yescode:balance",
reqwest::Method::GET,
&balance_url,
headers,
None,
proxy_snapshot,
),
admin_provider_ops_execute_json_request(
state,
"provider-ops-action:yescode:profile",
reqwest::Method::GET,
&profile_url,
headers,
None,
proxy_snapshot,
)
} else {
let balance_future = state
.http_client()
.request(reqwest::Method::GET, balance_url)
.headers(headers.clone())
.send();
let profile_future = state
.http_client()
.request(reqwest::Method::GET, profile_url)
.headers(headers.clone())
.send();
let (balance_result, profile_result) = tokio::join!(balance_future, profile_future);
let balance_result = match balance_result {
Ok(response) => {
let status = response.status();
let value = match response.bytes().await {
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
.unwrap_or_else(|_| json!({})),
Err(_) => json!({}),
};
Ok((status, value))
}
Err(err) => Err(err.to_string()),
};
let profile_result = match profile_result {
Ok(response) => {
let status = response.status();
let value = match response.bytes().await {
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
.unwrap_or_else(|_| json!({})),
Err(_) => json!({}),
};
Ok((status, value))
}
Err(err) => Err(err.to_string()),
};
(balance_result, profile_result)
};
);
let balance_result = balance_result.map_err(|err| match err {
AdminProviderOpsExecuteJsonError::InvalidJson(message)
| AdminProviderOpsExecuteJsonError::Transport(message) => message,
});
let profile_result = profile_result.map_err(|err| match err {
AdminProviderOpsExecuteJsonError::InvalidJson(message)
| AdminProviderOpsExecuteJsonError::Transport(message) => message,
});
let response_time_ms = Some(start.elapsed().as_millis() as u64);
let mut combined = serde_json::Map::new();
@@ -12,7 +12,10 @@ use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogPr
pub(super) use proxy::{
admin_provider_ops_anyrouter_acw_cookie, admin_provider_ops_resolve_proxy_snapshot,
};
pub(super) use request::admin_provider_ops_execute_proxy_json_request;
pub(super) use request::{
admin_provider_ops_execute_json_request, admin_provider_ops_execute_proxy_json_request,
AdminProviderOpsExecuteJsonError,
};
pub(super) use sub2api::{
admin_provider_ops_sub2api_exchange_token, admin_provider_ops_sub2api_request_url,
};
@@ -1,18 +1,9 @@
use super::request::{
admin_provider_ops_execute_get_text, admin_provider_ops_execute_get_text_no_redirect,
};
use super::request::admin_provider_ops_execute_get_text_no_redirect;
use crate::handlers::admin::request::AdminAppState;
use aether_admin::provider::ops::admin_provider_ops_anyrouter_compute_acw_sc_v2;
use aether_contracts::ProxySnapshot;
use aether_data::repository::proxy_nodes::StoredProxyNode;
use aether_provider_transport::TransportTunnelAffinityLookup;
use regex::Regex;
use serde_json::{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";
use serde_json::{Map, Value};
pub(in super::super) struct AdminProviderOpsAnyrouterChallenge {
pub(in super::super) acw_cookie: String,
@@ -30,25 +21,15 @@ pub(in super::super) async fn admin_provider_ops_anyrouter_acw_cookie(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
),
)]);
let response = if admin_provider_ops_proxy_uses_tunnel(proxy_snapshot.as_ref()) {
admin_provider_ops_execute_get_text(
state,
"provider-ops-acw:anyrouter",
base_url.trim_end_matches('/'),
&headers,
proxy_snapshot.as_ref(),
)
.await
.ok()?
} else {
admin_provider_ops_execute_get_text_no_redirect(
base_url.trim_end_matches('/'),
&headers,
proxy_snapshot.as_ref(),
)
.await
.ok()?
};
let response = admin_provider_ops_execute_get_text_no_redirect(
state,
"provider-ops-acw:anyrouter",
base_url.trim_end_matches('/'),
&headers,
proxy_snapshot.as_ref(),
)
.await
.ok()?;
let compiled = Regex::new(r"var\s+arg1\s*=\s*'([0-9a-fA-F]{40})'").ok()?;
let captures = compiled.captures(&response.body)?;
let arg1 = captures.get(1)?.as_str();
@@ -63,222 +44,7 @@ pub(in super::super) async fn admin_provider_ops_resolve_proxy_snapshot(
state: &AdminAppState<'_>,
connector_config: Option<&Map<String, Value>>,
) -> Option<ProxySnapshot> {
let explicit_node_id = connector_config
.and_then(|config| admin_provider_ops_string_field(config, "proxy_node_id"));
if let Some(snapshot) =
admin_provider_ops_resolve_proxy_node_snapshot(state, explicit_node_id.as_deref()).await
{
return Some(snapshot);
}
if explicit_node_id.is_none() {
let system_node_id = state
.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) =
admin_provider_ops_resolve_proxy_node_snapshot(state, system_node_id.as_deref()).await
{
return Some(snapshot);
}
}
connector_config
.and_then(|config| config.get("proxy"))
.and_then(admin_provider_ops_legacy_proxy_snapshot)
}
async fn admin_provider_ops_resolve_proxy_node_snapshot(
state: &AdminAppState<'_>,
node_id: Option<&str>,
) -> Option<ProxySnapshot> {
let node_id = node_id.map(str::trim).filter(|value| !value.is_empty())?;
let node = state.find_proxy_node(node_id).await.ok().flatten()?;
if node.status.trim() != "online" {
return None;
}
if node.tunnel_mode && node.tunnel_connected {
let mut extra = Map::new();
if let Ok(Some(owner)) = state.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_ops_proxy_mode(Some(proxy_url)),
node_id: Some(node.id.clone()),
label: Some(node.name.clone()),
url: admin_provider_ops_proxy_url_with_node_auth(&node),
extra: None,
})
}
fn admin_provider_ops_legacy_proxy_snapshot(value: &Value) -> Option<ProxySnapshot> {
match value {
Value::String(proxy_url) => {
let proxy_url = proxy_url.trim();
if proxy_url.is_empty() {
return None;
}
Some(ProxySnapshot {
enabled: Some(true),
mode: admin_provider_ops_proxy_mode(Some(proxy_url)),
node_id: None,
label: None,
url: Some(proxy_url.to_string()),
extra: None,
})
}
Value::Object(object) => {
if object.get("enabled").and_then(Value::as_bool) == Some(false) {
return None;
}
let proxy_url = object
.get("url")
.or_else(|| object.get("proxy_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let username = object
.get("username")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let password = object
.get("password")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
Some(ProxySnapshot {
enabled: Some(true),
mode: object
.get("mode")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| admin_provider_ops_proxy_mode(Some(proxy_url))),
node_id: None,
label: object
.get("label")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
url: admin_provider_ops_inject_proxy_auth(proxy_url, username, password)
.or_else(|| Some(proxy_url.to_string())),
extra: None,
})
}
_ => None,
}
}
fn admin_provider_ops_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_ops_inject_proxy_auth(proxy_url, username, password)
.or_else(|| Some(proxy_url.to_string()))
}
fn admin_provider_ops_inject_proxy_auth(
proxy_url: &str,
username: Option<&str>,
password: Option<&str>,
) -> Option<String> {
let username = username.filter(|value| !value.is_empty())?;
let mut parsed = Url::parse(proxy_url).ok()?;
parsed.set_username(username).ok()?;
parsed.set_password(password).ok()?;
Some(parsed.to_string())
}
fn admin_provider_ops_proxy_mode(proxy_url: Option<&str>) -> Option<String> {
proxy_url
.and_then(|value| {
Url::parse(value)
.ok()
.map(|parsed| parsed.scheme().to_string())
})
.or_else(|| {
proxy_url.and_then(|value| {
value
.split_once("://")
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
.filter(|scheme| !scheme.is_empty())
})
})
}
fn admin_provider_ops_proxy_uses_tunnel(proxy_snapshot: Option<&ProxySnapshot>) -> bool {
proxy_snapshot.is_some_and(|proxy| {
proxy.mode.as_deref().map(str::trim) == Some("tunnel")
|| (proxy
.url
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
&& proxy
.node_id
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty()))
})
}
fn admin_provider_ops_string_field(config: &Map<String, Value>, key: &str) -> Option<String> {
config
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
state
.resolve_admin_connector_proxy_snapshot(connector_config)
.await
}
@@ -2,11 +2,10 @@ use crate::handlers::admin::request::AdminAppState;
use crate::GatewayError;
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot, RequestBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
};
use aether_http::{apply_http_client_config, HttpClientConfig};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use flate2::read::{DeflateDecoder, GzDecoder};
use reqwest::redirect::Policy;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::io::Read;
@@ -17,6 +16,11 @@ pub(super) struct AdminProviderOpsTextResponse {
pub(super) body: String,
}
pub(in super::super) enum AdminProviderOpsExecuteJsonError {
InvalidJson(String),
Transport(String),
}
pub(super) async fn admin_provider_ops_execute_get_json(
state: &AdminAppState<'_>,
request_id: &str,
@@ -24,37 +28,7 @@ pub(super) async fn admin_provider_ops_execute_get_json(
headers: &reqwest::header::HeaderMap,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Result<(http::StatusCode, Value), String> {
if proxy_snapshot.is_none() {
let response = match state
.http_client()
.get(url)
.headers(headers.clone())
.send()
.await
{
Ok(response) => response,
Err(err) if err.is_timeout() => return Err("timeout".to_string()),
Err(err) => return Err(err.to_string()),
};
let status = response.status();
let content_encoding = response
.headers()
.get(reqwest::header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let bytes = response.bytes().await.map_err(|err| err.to_string())?;
let decoded_bytes =
admin_provider_ops_decode_response_bytes(bytes.as_ref(), content_encoding.as_deref())
.unwrap_or_else(|| bytes.to_vec());
let response_json = match serde_json::from_slice::<Value>(&decoded_bytes) {
Ok(value) => value,
Err(err) if status != http::StatusCode::OK => json!({}),
Err(err) => return Err(format!("upstream response is not valid JSON: {err}")),
};
return Ok((status, response_json));
}
let result = admin_provider_ops_execute_request(
match admin_provider_ops_execute_json_request(
state,
request_id,
reqwest::Method::GET,
@@ -63,11 +37,35 @@ pub(super) async fn admin_provider_ops_execute_get_json(
None,
proxy_snapshot,
)
.await?;
Ok((
admin_provider_ops_execution_status_code(&result),
admin_provider_ops_execution_json_body(&result),
))
.await
{
Ok(result) => Ok(result),
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => Err(message),
}
}
pub(in super::super) async fn admin_provider_ops_execute_json_request(
state: &AdminAppState<'_>,
request_id: &str,
method: reqwest::Method,
url: &str,
headers: &reqwest::header::HeaderMap,
json_body: Option<Value>,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Result<(http::StatusCode, Value), AdminProviderOpsExecuteJsonError> {
let result = admin_provider_ops_execute_request(
state,
request_id,
method,
url,
headers,
json_body,
proxy_snapshot,
)
.await
.map_err(AdminProviderOpsExecuteJsonError::Transport)?;
admin_provider_ops_execution_json_response(&result)
}
pub(in super::super) async fn admin_provider_ops_execute_proxy_json_request(
@@ -79,7 +77,7 @@ pub(in super::super) async fn admin_provider_ops_execute_proxy_json_request(
json_body: Option<Value>,
proxy_snapshot: &ProxySnapshot,
) -> Result<(http::StatusCode, Value), String> {
let result = admin_provider_ops_execute_request(
match admin_provider_ops_execute_json_request(
state,
request_id,
method,
@@ -88,11 +86,12 @@ pub(in super::super) async fn admin_provider_ops_execute_proxy_json_request(
json_body,
Some(proxy_snapshot),
)
.await?;
Ok((
admin_provider_ops_execution_status_code(&result),
admin_provider_ops_execution_json_body(&result),
))
.await
{
Ok(result) => Ok(result),
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => Err(message),
}
}
pub(super) async fn admin_provider_ops_execute_get_text(
@@ -129,50 +128,20 @@ pub(super) async fn admin_provider_ops_execute_get_text(
}
pub(super) async fn admin_provider_ops_execute_get_text_no_redirect(
state: &AdminAppState<'_>,
request_id: &str,
url: &str,
headers: &reqwest::header::HeaderMap,
proxy_snapshot: Option<&ProxySnapshot>,
) -> Result<AdminProviderOpsTextResponse, String> {
let mut builder = apply_http_client_config(
reqwest::Client::builder().redirect(Policy::none()),
&HttpClientConfig {
connect_timeout_ms: Some(10_000),
request_timeout_ms: Some(ADMIN_PROVIDER_OPS_VERIFY_TIMEOUT_MS),
use_rustls_tls: true,
http2_adaptive_window: true,
..HttpClientConfig::default()
},
);
if let Some(proxy_url) = proxy_snapshot
.and_then(|proxy| proxy.url.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
{
let proxy = reqwest::Proxy::all(proxy_url).map_err(|err| format!("连接失败: {err}"))?;
builder = builder.proxy(proxy);
}
let client = builder.build().map_err(|err| format!("验证失败: {err}"))?;
let response = match client.get(url).headers(headers.clone()).send().await {
Ok(response) => response,
Err(err) if err.is_timeout() => return Err("连接超时".to_string()),
Err(err) if err.is_connect() => return Err(format!("连接失败: {err}")),
Err(err) => return Err(format!("验证失败: {err}")),
};
let content_encoding = response
.headers()
.get(reqwest::header::CONTENT_ENCODING)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let body = response
.bytes()
.await
.map_err(|err| format!("验证失败: {err}"))
.map(|bytes| {
admin_provider_ops_decode_response_bytes(bytes.as_ref(), content_encoding.as_deref())
.unwrap_or_else(|| bytes.to_vec())
})
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())?;
Ok(AdminProviderOpsTextResponse { body })
admin_provider_ops_execute_get_text(
state,
request_id,
url,
&admin_provider_ops_headers_with_transport_controls(headers, Some(false), false),
proxy_snapshot,
)
.await
}
async fn admin_provider_ops_execute_request(
@@ -240,23 +209,55 @@ fn admin_provider_ops_execution_headers(
.collect()
}
pub(in super::super) fn admin_provider_ops_headers_with_transport_controls(
headers: &reqwest::header::HeaderMap,
follow_redirects: Option<bool>,
http1_only: bool,
) -> reqwest::header::HeaderMap {
let mut headers = headers.clone();
if let Some(follow_redirects) = follow_redirects {
let value = if follow_redirects { "true" } else { "false" };
headers.insert(
reqwest::header::HeaderName::from_static(EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER),
reqwest::header::HeaderValue::from_static(value),
);
}
if http1_only {
headers.insert(
reqwest::header::HeaderName::from_static(EXECUTION_REQUEST_HTTP1_ONLY_HEADER),
reqwest::header::HeaderValue::from_static("true"),
);
}
headers
}
fn admin_provider_ops_execution_status_code(result: &ExecutionResult) -> http::StatusCode {
http::StatusCode::from_u16(result.status_code).unwrap_or(http::StatusCode::BAD_GATEWAY)
}
fn admin_provider_ops_execution_json_body(result: &ExecutionResult) -> Value {
result
fn admin_provider_ops_execution_json_response(
result: &ExecutionResult,
) -> Result<(http::StatusCode, Value), AdminProviderOpsExecuteJsonError> {
let status = admin_provider_ops_execution_status_code(result);
if let Some(json_body) = result.body.as_ref().and_then(|body| body.json_body.clone()) {
return Ok((status, json_body));
}
let Some(bytes) = result
.body
.as_ref()
.and_then(|body| body.json_body.clone())
.or_else(|| {
result
.body
.as_ref()
.and_then(|body| admin_provider_ops_execution_body_bytes(&result.headers, body))
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
})
.unwrap_or_else(|| json!({}))
.and_then(|body| admin_provider_ops_execution_body_bytes(&result.headers, body))
else {
return Ok((status, json!({})));
};
match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Ok((status, value)),
Err(_) if status != http::StatusCode::OK => Ok((status, json!({}))),
Err(err) => Err(AdminProviderOpsExecuteJsonError::InvalidJson(format!(
"upstream response is not valid JSON: {err}"
))),
}
}
fn admin_provider_ops_execution_body_bytes(
@@ -1,6 +1,6 @@
use super::request::{
admin_provider_ops_execute_proxy_json_request,
admin_provider_ops_verify_execution_error_message,
admin_provider_ops_execute_json_request, admin_provider_ops_headers_with_transport_controls,
admin_provider_ops_verify_execution_error_message, AdminProviderOpsExecuteJsonError,
};
use crate::handlers::admin::provider::ops::providers::config::persist_admin_provider_ops_runtime_credentials;
use crate::handlers::admin::request::AdminAppState;
@@ -10,7 +10,6 @@ use aether_admin::provider::ops::{
};
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogProvider;
use aether_http::{apply_http_client_config, HttpClientConfig};
use serde_json::{json, Map, Value};
use tracing::warn;
@@ -64,51 +63,26 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
reqwest::header::HeaderValue::from_static("*/*"),
),
]);
let (status, response_json) = if let Some(proxy_snapshot) = proxy_snapshot {
match admin_provider_ops_execute_proxy_json_request(
state,
"provider-ops-verify:sub2api",
reqwest::Method::GET,
&verify_url,
&auth_headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(error) => {
return admin_provider_ops_verify_failure(
admin_provider_ops_verify_execution_error_message(&error),
);
}
let auth_headers =
admin_provider_ops_headers_with_transport_controls(&auth_headers, None, true);
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
"provider-ops-verify:sub2api",
reqwest::Method::GET,
&verify_url,
&auth_headers,
None,
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
return admin_provider_ops_verify_failure(
admin_provider_ops_verify_execution_error_message(&message),
);
}
} else {
let http_client = match admin_provider_ops_sub2api_http_client() {
Ok(client) => client,
Err(err) => {
return admin_provider_ops_verify_failure(format!("验证失败: {err}"));
}
};
let response = match http_client
.get(&verify_url)
.headers(auth_headers)
.send()
.await
{
Ok(response) => response,
Err(err) if err.is_timeout() => return admin_provider_ops_verify_failure("连接超时"),
Err(err) if err.is_connect() => {
return admin_provider_ops_verify_failure(format!("连接失败: {err}"));
}
Err(err) => return admin_provider_ops_verify_failure(format!("验证失败: {err}")),
};
let status = response.status();
let response_json = match response.bytes().await {
Ok(bytes) => serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| json!({})),
Err(_) => json!({}),
};
(status, response_json)
};
parse_verify_payload(
@@ -119,20 +93,6 @@ pub(super) async fn admin_provider_ops_local_sub2api_verify_response(
)
}
fn admin_provider_ops_sub2api_http_client() -> Result<reqwest::Client, reqwest::Error> {
let builder = apply_http_client_config(
reqwest::Client::builder().http1_only(),
&HttpClientConfig {
connect_timeout_ms: Some(10_000),
request_timeout_ms: Some(30_000),
use_rustls_tls: true,
user_agent: Some(ADMIN_PROVIDER_OPS_USER_AGENT.to_string()),
..HttpClientConfig::default()
},
);
builder.build()
}
// 对齐 Python httpx.AsyncClient(base_url=...) 的行为:
// 以 "/" 开头的端点始终相对站点根路径解析,而不是简单字符串拼接。
pub(in super::super) fn admin_provider_ops_sub2api_request_url(
@@ -263,39 +223,24 @@ async fn admin_provider_ops_sub2api_token_request(
reqwest::header::HeaderValue::from_static("*/*"),
),
]);
let (status, response_json) = if let Some(proxy_snapshot) = proxy_snapshot {
admin_provider_ops_execute_proxy_json_request(
state,
&format!("provider-ops-sub2api:{path}"),
reqwest::Method::POST,
&url,
&default_headers,
Some(body),
proxy_snapshot,
)
.await
.map_err(|error| admin_provider_ops_verify_execution_error_message(&error))?
} else {
let client =
admin_provider_ops_sub2api_http_client().map_err(|err| format!("验证失败: {err}"))?;
let response = match client
.post(url)
.headers(default_headers)
.json(&body)
.send()
.await
{
Ok(response) => response,
Err(err) if err.is_timeout() => return Err("连接超时".to_string()),
Err(err) if err.is_connect() => return Err(format!("连接失败: {err}")),
Err(err) => return Err(format!("验证失败: {err}")),
};
let status = response.status();
let response_json = match response.bytes().await {
Ok(bytes) => serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| json!({})),
Err(_) => json!({}),
};
(status, response_json)
let default_headers =
admin_provider_ops_headers_with_transport_controls(&default_headers, None, true);
let (status, response_json) = match admin_provider_ops_execute_json_request(
state,
&format!("provider-ops-sub2api:{path}"),
reqwest::Method::POST,
&url,
&default_headers,
Some(body),
proxy_snapshot,
)
.await
{
Ok(result) => result,
Err(AdminProviderOpsExecuteJsonError::InvalidJson(message))
| Err(AdminProviderOpsExecuteJsonError::Transport(message)) => {
return Err(admin_provider_ops_verify_execution_error_message(&message));
}
};
let payload = response_json.as_object().cloned().unwrap_or_default();
if status != http::StatusCode::OK