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

@@ -126,6 +126,7 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
state,
template,
entry.refresh_token.as_str(),
proxy_node_id,
)
.await
{

View File

@@ -1,6 +1,5 @@
use super::parse::{AdminProviderOAuthBatchImportEntry, AdminProviderOAuthBatchImportOutcome};
use crate::handlers::admin::provider::oauth::duplicates::find_duplicate_provider_oauth_key;
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use crate::handlers::admin::provider::oauth::provisioning::{
create_provider_oauth_catalog_key, provider_oauth_active_api_formats,
provider_oauth_key_proxy_value, update_existing_provider_oauth_catalog_key,
@@ -8,9 +7,8 @@ use crate::handlers::admin::provider::oauth::provisioning::{
use crate::handlers::admin::provider::oauth::runtime::refresh_provider_oauth_account_state_after_update;
use crate::handlers::admin::provider::oauth::state::decode_jwt_claims;
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
use crate::handlers::admin::request::{
AdminAppState, AdminKiroAuthConfig, AdminKiroOAuthRefreshAdapter,
};
use crate::handlers::admin::request::{AdminAppState, AdminKiroAuthConfig};
use crate::provider_transport::kiro::generate_machine_id;
use crate::GatewayError;
use aether_admin::provider::oauth::{
build_kiro_batch_import_key_name, coerce_admin_provider_oauth_import_str,
@@ -20,6 +18,9 @@ use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
use std::time::{SystemTime, UNIX_EPOCH};
const KIRO_IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
fn admin_provider_oauth_kiro_refresh_base_url_override(
state: &AdminAppState<'_>,
override_key: &str,
@@ -29,6 +30,293 @@ fn admin_provider_oauth_kiro_refresh_base_url_override(
(!normalized.is_empty()).then(|| normalized.to_string())
}
fn admin_provider_oauth_kiro_build_refresh_url(
auth_config: &AdminKiroAuthConfig,
override_base_url: Option<&str>,
path: &str,
default_host: impl FnOnce(&str) -> String,
) -> String {
if let Some(base_url) = override_base_url
.map(str::trim)
.filter(|value| !value.is_empty())
{
return format!("{}/{}", base_url.trim_end_matches('/'), path);
}
let region = auth_config.effective_auth_region();
default_host(region)
}
fn admin_provider_oauth_kiro_effective_host(url: &str, fallback_host: String) -> String {
reqwest::Url::parse(url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.unwrap_or(fallback_host)
}
fn admin_provider_oauth_kiro_ide_tag(kiro_version: &str, machine_id: &str) -> String {
if machine_id.trim().is_empty() {
format!("KiroIDE-{kiro_version}")
} else {
format!("KiroIDE-{kiro_version}-{machine_id}")
}
}
fn admin_provider_oauth_kiro_refresh_expires_at(payload: &Value) -> u64 {
let expires_in = payload
.get("expiresIn")
.and_then(|value| {
value
.as_u64()
.or_else(|| value.as_str()?.parse::<u64>().ok())
})
.unwrap_or(3600);
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|value| value.as_secs())
.unwrap_or_default()
.saturating_add(expires_in)
}
fn admin_provider_oauth_kiro_refresh_response_json(
body_text: &str,
json_body: Option<Value>,
) -> Result<Value, String> {
json_body
.or_else(|| serde_json::from_str::<Value>(body_text).ok())
.ok_or_else(|| "refresh 接口返回了非 JSON 响应".to_string())
}
fn admin_provider_oauth_kiro_refresh_error_detail(
status: http::StatusCode,
body_text: &str,
) -> String {
let detail = body_text.trim();
if detail.is_empty() {
format!("HTTP {}", status.as_u16())
} else {
detail.to_string()
}
}
async fn refresh_admin_provider_oauth_kiro_auth_config(
state: &AdminAppState<'_>,
auth_config: &AdminKiroAuthConfig,
proxy_node_id: Option<&str>,
social_refresh_base_url: Option<&str>,
idc_refresh_base_url: Option<&str>,
) -> Result<AdminKiroAuthConfig, String> {
if auth_config.is_idc_auth() {
let fallback_host = format!("oidc.{}.amazonaws.com", auth_config.effective_auth_region());
let url = admin_provider_oauth_kiro_build_refresh_url(
auth_config,
idc_refresh_base_url,
"token",
|region| format!("https://oidc.{region}.amazonaws.com/token"),
);
let host = admin_provider_oauth_kiro_effective_host(&url, fallback_host);
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::HOST,
reqwest::header::HeaderValue::from_str(&host)
.map_err(|_| "IDC host 无效".to_string())?,
),
(
reqwest::header::HeaderName::from_static("x-amz-user-agent"),
reqwest::header::HeaderValue::from_static(KIRO_IDC_AMZ_USER_AGENT),
),
(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("node"),
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("*/*"),
),
]);
let response = state
.execute_admin_provider_oauth_http_request(
"kiro_batch_refresh:idc",
reqwest::Method::POST,
&url,
&headers,
Some("application/json"),
Some(json!({
"clientId": auth_config
.client_id
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"clientSecret": auth_config
.client_secret
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
"grantType": "refresh_token",
})),
None,
proxy_node_id,
)
.await
.map_err(|err| format!("IDC refresh 请求失败: {err}"))?;
if !response.status.is_success() {
return Err(format!(
"IDC refresh 失败: {}",
admin_provider_oauth_kiro_refresh_error_detail(
response.status,
&response.body_text
)
));
}
let payload = admin_provider_oauth_kiro_refresh_response_json(
&response.body_text,
response.json_body,
)?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "IDC refresh 返回了空 accessToken".to_string())?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(admin_provider_oauth_kiro_refresh_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id = generate_machine_id(auth_config, None);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
return Ok(refreshed);
}
let machine_id = generate_machine_id(auth_config, None)
.ok_or_else(|| "缺少 machine_id 种子,无法刷新 social token".to_string())?;
let fallback_host = format!(
"prod.{}.auth.desktop.kiro.dev",
auth_config.effective_auth_region()
);
let url = admin_provider_oauth_kiro_build_refresh_url(
auth_config,
social_refresh_base_url,
"refreshToken",
|region| format!("https://prod.{region}.auth.desktop.kiro.dev/refreshToken"),
);
let host = admin_provider_oauth_kiro_effective_host(&url, fallback_host);
let user_agent =
admin_provider_oauth_kiro_ide_tag(auth_config.effective_kiro_version(), &machine_id);
let headers = reqwest::header::HeaderMap::from_iter([
(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_str(&user_agent)
.map_err(|_| "Kiro User-Agent 无效".to_string())?,
),
(
reqwest::header::HOST,
reqwest::header::HeaderValue::from_str(&host)
.map_err(|_| "Kiro host 无效".to_string())?,
),
(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/json, text/plain, */*"),
),
(
reqwest::header::CONTENT_TYPE,
reqwest::header::HeaderValue::from_static("application/json"),
),
(
reqwest::header::CONNECTION,
reqwest::header::HeaderValue::from_static("close"),
),
(
reqwest::header::ACCEPT_ENCODING,
reqwest::header::HeaderValue::from_static("gzip, compress, deflate, br"),
),
]);
let response = state
.execute_admin_provider_oauth_http_request(
"kiro_batch_refresh:social",
reqwest::Method::POST,
&url,
&headers,
Some("application/json"),
Some(json!({
"refreshToken": auth_config
.refresh_token
.as_deref()
.map(str::trim)
.unwrap_or_default(),
})),
None,
proxy_node_id,
)
.await
.map_err(|err| format!("social refresh 请求失败: {err}"))?;
if !response.status.is_success() {
return Err(format!(
"social refresh 失败: {}",
admin_provider_oauth_kiro_refresh_error_detail(response.status, &response.body_text)
));
}
let payload =
admin_provider_oauth_kiro_refresh_response_json(&response.body_text, response.json_body)?;
let access_token = payload
.get("accessToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "social refresh 返回了空 accessToken".to_string())?;
let mut refreshed = auth_config.clone();
refreshed.access_token = Some(access_token.to_string());
refreshed.expires_at = Some(admin_provider_oauth_kiro_refresh_expires_at(&payload));
if refreshed
.machine_id
.as_deref()
.map(str::trim)
.is_none_or(|value| value.is_empty())
{
refreshed.machine_id = Some(machine_id);
}
if let Some(refresh_token) = payload
.get("refreshToken")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.refresh_token = Some(refresh_token.to_string());
}
if let Some(profile_arn) = payload
.get("profileArn")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
refreshed.profile_arn = Some(profile_arn.to_string());
}
Ok(refreshed)
}
pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
state: &AdminAppState<'_>,
provider_id: &str,
@@ -65,10 +353,10 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
.list_provider_catalog_endpoints_by_provider_ids(&[provider_id.to_string()])
.await?;
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id);
let adapter = AdminKiroOAuthRefreshAdapter::default().with_refresh_base_urls(
admin_provider_oauth_kiro_refresh_base_url_override(state, "kiro_social_refresh"),
admin_provider_oauth_kiro_refresh_base_url_override(state, "kiro_idc_refresh"),
);
let social_refresh_base_url =
admin_provider_oauth_kiro_refresh_base_url_override(state, "kiro_social_refresh");
let idc_refresh_base_url =
admin_provider_oauth_kiro_refresh_base_url_override(state, "kiro_idc_refresh");
let mut results = Vec::with_capacity(entries.len());
let mut success = 0usize;
let mut failed = 0usize;
@@ -101,9 +389,14 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
continue;
}
refreshed_auth_config = match adapter
.refresh_auth_config(state.http_client(), &refreshed_auth_config)
.await
refreshed_auth_config = match refresh_admin_provider_oauth_kiro_auth_config(
state,
&refreshed_auth_config,
proxy_node_id,
social_refresh_base_url.as_deref(),
idc_refresh_base_url.as_deref(),
)
.await
{
Ok(config) => config,
Err(err) => {
@@ -111,7 +404,7 @@ pub(super) async fn execute_admin_provider_oauth_kiro_batch_import(
results.push(json!({
"index": index,
"status": "error",
"error": format!("Token 验证失败: {err:?}"),
"error": format!("Token 验证失败: {err}"),
"replaced": false,
}));
continue;

View File

@@ -130,6 +130,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
&callback.code,
&callback.state_nonce,
state_data.pkce_verifier.as_deref(),
payload.proxy_node_id.as_deref(),
)
.await
{

View File

@@ -121,6 +121,7 @@ pub(super) async fn handle_admin_provider_oauth_complete_provider(
&callback.code,
&callback.state_nonce,
state_data.pkce_verifier.as_deref(),
payload.proxy_node_id.as_deref(),
)
.await
{

View File

@@ -85,7 +85,11 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
};
let client_registration = match state
.register_admin_kiro_device_oidc_client(&region, &start_url)
.register_admin_kiro_device_oidc_client(
&region,
&start_url,
payload.proxy_node_id.as_deref(),
)
.await
{
Ok(payload) => payload,
@@ -105,7 +109,13 @@ pub(super) async fn handle_admin_provider_oauth_device_authorize(
};
let device_authorization = match state
.start_admin_kiro_device_authorization(&region, &client_id, &client_secret, &start_url)
.start_admin_kiro_device_authorization(
&region,
&client_id,
&client_secret,
&start_url,
payload.proxy_node_id.as_deref(),
)
.await
{
Ok(payload) => payload,

View File

@@ -132,6 +132,7 @@ pub(super) async fn handle_admin_provider_oauth_device_poll(
&session.client_id,
&session.client_secret,
&session.device_code,
session.proxy_node_id.as_deref(),
)
.await
{

View File

@@ -98,7 +98,11 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
};
let token_payload = match state
.exchange_admin_provider_oauth_refresh_token(template, refresh_token_input)
.exchange_admin_provider_oauth_refresh_token(
template,
refresh_token_input,
proxy_node_id.as_deref(),
)
.await
{
Ok(payload) => payload,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,10 @@ impl<'a> AdminAppState<'a> {
self.app.has_proxy_node_reader()
}
pub(crate) fn has_proxy_node_writer(&self) -> bool {
self.app.has_proxy_node_writer()
}
pub(crate) fn has_auth_api_key_writer(&self) -> bool {
self.app.data.has_auth_api_key_writer()
}

View File

@@ -1,5 +1,9 @@
use super::*;
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
use aether_contracts::{
ExecutionPlan, ExecutionResult, ExecutionTimeouts, RequestBody,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
};
use aether_data::repository::provider_oauth::{
build_provider_oauth_batch_task_status_payload, provider_oauth_batch_task_storage_key,
provider_oauth_device_session_storage_key, provider_oauth_state_storage_key,
@@ -7,11 +11,22 @@ use aether_data::repository::provider_oauth::{
PROVIDER_OAUTH_BATCH_TASK_TTL_SECS, PROVIDER_OAUTH_STATE_TTL_SECS,
};
use axum::http;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use flate2::read::{DeflateDecoder, GzDecoder};
use serde_json::json;
use std::collections::BTreeMap;
use std::io::Read;
use url::Url;
const KIRO_IDC_AMZ_USER_AGENT: &str =
"aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE";
const ADMIN_PROVIDER_OAUTH_TIMEOUT_MS: u64 = 30_000;
pub(crate) struct AdminProviderOAuthHttpResponse {
pub(crate) status: http::StatusCode,
pub(crate) body_text: String,
pub(crate) json_body: Option<serde_json::Value>,
}
impl<'a> AdminAppState<'a> {
pub(crate) async fn update_provider_catalog_key_oauth_credentials(
@@ -117,6 +132,7 @@ impl<'a> AdminAppState<'a> {
code: &str,
state_nonce: &str,
pkce_verifier: Option<&str>,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
crate::handlers::admin::provider::oauth::state::exchange_admin_provider_oauth_code(
self,
@@ -124,6 +140,7 @@ impl<'a> AdminAppState<'a> {
code,
state_nonce,
pkce_verifier,
proxy_node_id,
)
.await
}
@@ -132,11 +149,13 @@ impl<'a> AdminAppState<'a> {
&self,
template: AdminProviderOAuthTemplate,
refresh_token: &str,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
crate::handlers::admin::provider::oauth::state::exchange_admin_provider_oauth_refresh_token(
self,
template,
refresh_token,
proxy_node_id,
)
.await
}
@@ -296,6 +315,7 @@ impl<'a> AdminAppState<'a> {
&self,
region: &str,
start_url: &str,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
let payload = post_kiro_device_oidc_json(
self,
@@ -317,6 +337,7 @@ impl<'a> AdminAppState<'a> {
],
"issuerUrl": start_url,
}),
proxy_node_id,
)
.await?;
if payload
@@ -343,6 +364,7 @@ impl<'a> AdminAppState<'a> {
client_id: &str,
client_secret: &str,
start_url: &str,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
let payload = post_kiro_device_oidc_json(
self,
@@ -353,6 +375,7 @@ impl<'a> AdminAppState<'a> {
"clientSecret": client_secret,
"startUrl": start_url,
}),
proxy_node_id,
)
.await?;
if payload
@@ -379,6 +402,7 @@ impl<'a> AdminAppState<'a> {
client_id: &str,
client_secret: &str,
device_code: &str,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
post_kiro_device_oidc_json(
self,
@@ -390,6 +414,7 @@ impl<'a> AdminAppState<'a> {
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
"deviceCode": device_code,
}),
proxy_node_id,
)
.await
}
@@ -479,22 +504,43 @@ async fn post_kiro_device_oidc_json(
endpoint_key: &str,
default_url: String,
body: serde_json::Value,
proxy_node_id: Option<&str>,
) -> Result<serde_json::Value, Response<Body>> {
let url = state.provider_oauth_token_url(endpoint_key, &default_url);
let host = Url::parse(&url)
.ok()
.and_then(|value| value.host_str().map(ToOwned::to_owned))
.unwrap_or_default();
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("*/*"),
),
(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("node"),
),
(
reqwest::header::HeaderName::from_static("x-amz-user-agent"),
reqwest::header::HeaderValue::from_static(KIRO_IDC_AMZ_USER_AGENT),
),
]);
let headers = maybe_insert_host_header(headers, host.as_str());
let response = state
.http_client()
.post(url)
.header("Content-Type", "application/json")
.header("Accept", "*/*")
.header("User-Agent", "node")
.header("x-amz-user-agent", KIRO_IDC_AMZ_USER_AGENT)
.header("Host", host)
.json(&body)
.send()
.execute_admin_provider_oauth_http_request(
endpoint_key,
reqwest::Method::POST,
&url,
&headers,
Some("application/json"),
Some(body),
None,
proxy_node_id,
)
.await
.map_err(|_| {
build_internal_control_error_response(
@@ -502,13 +548,8 @@ async fn post_kiro_device_oidc_json(
"发起设备授权失败: unknown",
)
})?;
let status = response.status();
let body_text = response.text().await.map_err(|_| {
build_internal_control_error_response(
http::StatusCode::BAD_REQUEST,
"发起设备授权失败: unknown",
)
})?;
let status = response.status;
let body_text = response.body_text;
Ok(
serde_json::from_str::<serde_json::Value>(&body_text).unwrap_or_else(|_| {
json!({
@@ -518,3 +559,179 @@ async fn post_kiro_device_oidc_json(
}),
)
}
impl<'a> AdminAppState<'a> {
pub(crate) async fn execute_admin_provider_oauth_http_request(
&self,
request_id: &str,
method: reqwest::Method,
url: &str,
headers: &reqwest::header::HeaderMap,
content_type: Option<&str>,
json_body: Option<serde_json::Value>,
body_bytes: Option<Vec<u8>>,
proxy_node_id: Option<&str>,
) -> Result<AdminProviderOAuthHttpResponse, String> {
let body = if let Some(json_body) = json_body {
RequestBody::from_json(json_body)
} else {
RequestBody {
json_body: None,
body_bytes_b64: body_bytes.map(|bytes| STANDARD.encode(bytes)),
body_ref: None,
}
};
let plan = ExecutionPlan {
request_id: request_id.to_string(),
candidate_id: None,
provider_name: Some("provider_oauth".to_string()),
provider_id: String::new(),
endpoint_id: String::new(),
key_id: String::new(),
method: method.as_str().to_string(),
url: url.to_string(),
headers: admin_provider_oauth_execution_headers(headers),
content_type: content_type
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
content_encoding: None,
body,
stream: false,
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,
tls_profile: None,
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
read_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
write_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
pool_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
total_ms: Some(ADMIN_PROVIDER_OAUTH_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
};
let result = self
.execute_execution_runtime_sync_plan(None, &plan)
.await
.map_err(admin_provider_oauth_gateway_error_message)?;
Ok(AdminProviderOAuthHttpResponse {
status: http::StatusCode::from_u16(result.status_code)
.unwrap_or(http::StatusCode::BAD_GATEWAY),
body_text: admin_provider_oauth_execution_body_text(&result),
json_body: admin_provider_oauth_execution_json_body(&result),
})
}
}
fn maybe_insert_host_header(
mut headers: reqwest::header::HeaderMap,
host: &str,
) -> reqwest::header::HeaderMap {
let host = host.trim();
if host.is_empty() {
return headers;
}
if let Ok(value) = reqwest::header::HeaderValue::from_str(host) {
headers.insert(reqwest::header::HOST, value);
}
headers
}
fn admin_provider_oauth_execution_headers(
headers: &reqwest::header::HeaderMap,
) -> BTreeMap<String, String> {
let mut headers: BTreeMap<String, String> = headers
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|text| (name.as_str().to_string(), text.to_string()))
})
.collect();
headers.insert(
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER.to_string(),
"true".to_string(),
);
headers
}
fn admin_provider_oauth_execution_json_body(result: &ExecutionResult) -> Option<serde_json::Value> {
result
.body
.as_ref()
.and_then(|body| body.json_body.clone())
.or_else(|| {
result
.body
.as_ref()
.and_then(|body| admin_provider_oauth_execution_body_bytes(&result.headers, body))
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
})
}
fn admin_provider_oauth_execution_body_text(result: &ExecutionResult) -> String {
result
.body
.as_ref()
.and_then(|body| admin_provider_oauth_execution_body_bytes(&result.headers, body))
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
.or_else(|| {
result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
.and_then(|value| serde_json::to_string(value).ok())
})
.unwrap_or_default()
}
fn admin_provider_oauth_execution_body_bytes(
headers: &BTreeMap<String, String>,
body: &aether_contracts::ResponseBody,
) -> Option<Vec<u8>> {
let bytes = body
.body_bytes_b64
.as_deref()
.and_then(|value| STANDARD.decode(value).ok())?;
admin_provider_oauth_decode_response_bytes(
&bytes,
headers.get("content-encoding").map(String::as_str),
)
.or(Some(bytes))
}
fn admin_provider_oauth_decode_response_bytes(
bytes: &[u8],
content_encoding: Option<&str>,
) -> Option<Vec<u8>> {
let encoding = content_encoding
.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(bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
Some("deflate") => {
let mut decoder = DeflateDecoder::new(bytes);
let mut out = Vec::new();
decoder.read_to_end(&mut out).ok()?;
Some(out)
}
_ => None,
}
}
fn admin_provider_oauth_gateway_error_message(error: GatewayError) -> String {
match error {
GatewayError::UpstreamUnavailable { message, .. }
| GatewayError::ControlUnavailable { message, .. }
| GatewayError::Internal(message) => message,
}
}

View File

@@ -1,4 +1,13 @@
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 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(
@@ -100,6 +109,100 @@ impl<'a> AdminAppState<'a> {
)
}
pub(crate) async fn resolve_admin_connector_proxy_snapshot(
&self,
connector_config: Option<&Map<String, Value>>,
) -> Option<ProxySnapshot> {
let explicit_node_id = connector_config
.and_then(|config| admin_provider_transport_string_field(config, "proxy_node_id"));
if let Some(snapshot) = self
.resolve_admin_proxy_node_snapshot(explicit_node_id.as_deref())
.await
{
return Some(snapshot);
}
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
{
return Some(snapshot);
}
}
connector_config
.and_then(|config| config.get("proxy"))
.and_then(admin_provider_transport_legacy_proxy_snapshot)
}
pub(crate) async fn resolve_admin_proxy_node_snapshot(
&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,
})
}
pub(crate) fn supports_local_gemini_transport_with_network(
&self,
transport: &AdminGatewayProviderTransportSnapshot,
@@ -222,3 +325,121 @@ impl<'a> AdminAppState<'a> {
crate::provider_transport::url::build_openai_chat_url(upstream_base_url, query)
}
}
fn admin_provider_transport_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_transport_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_transport_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_transport_inject_proxy_auth(proxy_url, username, password)
.or_else(|| Some(proxy_url.to_string())),
extra: None,
})
}
_ => None,
}
}
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>,
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_transport_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_transport_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)
}

View File

@@ -1,4 +1,6 @@
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::unix_secs_to_rfc3339;
use crate::maintenance::{inspect_proxy_upgrade_rollout, ProxyUpgradeRolloutStatus};
use crate::GatewayError;
use aether_admin::system::{
build_admin_proxy_node_event_payload, build_admin_proxy_node_events_payload_response,
@@ -9,6 +11,20 @@ use aether_admin::system::{
use axum::{body::Body, response::Response};
impl<'a> AdminAppState<'a> {
pub(crate) async fn register_proxy_node(
&self,
mutation: &aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation,
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
self.app.register_proxy_node(mutation).await
}
pub(crate) async fn apply_proxy_node_heartbeat(
&self,
mutation: &aether_data::repository::proxy_nodes::ProxyNodeHeartbeatMutation,
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
self.app.apply_proxy_node_heartbeat(mutation).await
}
pub(crate) async fn build_admin_proxy_nodes_list_response(
&self,
skip: usize,
@@ -42,8 +58,12 @@ impl<'a> AdminAppState<'a> {
.take(limit)
.map(|node| build_admin_proxy_node_payload(&node))
.collect::<Vec<_>>();
let rollout = inspect_proxy_upgrade_rollout(self.app().data.as_ref())
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?
.map(build_admin_proxy_upgrade_rollout_payload);
Ok(build_admin_proxy_nodes_list_payload_response(
items, total, skip, limit,
items, total, skip, limit, rollout,
))
}
@@ -66,4 +86,55 @@ impl<'a> AdminAppState<'a> {
.collect::<Vec<_>>();
Ok(build_admin_proxy_node_events_payload_response(items))
}
pub(crate) async fn unregister_proxy_node(
&self,
node_id: &str,
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
self.app.unregister_proxy_node(node_id).await
}
pub(crate) async fn update_proxy_node_remote_config(
&self,
mutation: &aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation,
) -> Result<Option<aether_data::repository::proxy_nodes::StoredProxyNode>, GatewayError> {
self.app.update_proxy_node_remote_config(mutation).await
}
}
fn build_admin_proxy_upgrade_rollout_payload(
rollout: ProxyUpgradeRolloutStatus,
) -> serde_json::Value {
serde_json::json!({
"version": rollout.version,
"batch_size": rollout.batch_size,
"cooldown_secs": rollout.cooldown_secs,
"started_at": unix_secs_to_rfc3339(rollout.started_at_unix_secs),
"last_dispatched_at": rollout
.last_dispatched_at_unix_secs
.and_then(unix_secs_to_rfc3339),
"updated_at": unix_secs_to_rfc3339(rollout.updated_at_unix_secs),
"probe": rollout.probe.map(|probe| serde_json::json!({
"url": probe.url,
"timeout_secs": probe.timeout_secs,
})),
"blocked": rollout.blocked,
"online_eligible_total": rollout.online_eligible_total,
"completed_node_ids": rollout.completed_node_ids,
"pending_node_ids": rollout.pending_node_ids,
"conflict_node_ids": rollout.conflict_node_ids,
"skipped_node_ids": rollout.skipped_node_ids,
"tracked_nodes": rollout.tracked_nodes.into_iter().map(|tracked| serde_json::json!({
"node_id": tracked.node_id,
"state": tracked.state,
"dispatched_at": unix_secs_to_rfc3339(tracked.dispatched_at_unix_secs),
"version_confirmed_at": tracked
.version_confirmed_at_unix_secs
.and_then(unix_secs_to_rfc3339),
"traffic_confirmed_at": tracked
.traffic_confirmed_at_unix_secs
.and_then(unix_secs_to_rfc3339),
"cooldown_remaining_secs": tracked.cooldown_remaining_secs,
})).collect::<Vec<_>>(),
})
}

View File

@@ -1,15 +1,103 @@
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::query_param_value;
use crate::maintenance::{
cancel_proxy_upgrade_rollout, clear_proxy_upgrade_rollout_conflicts,
restore_proxy_upgrade_rollout_skipped_nodes, retry_proxy_upgrade_rollout_node,
skip_proxy_upgrade_rollout_node, start_proxy_upgrade_rollout, ProxyUpgradeRolloutProbeConfig,
};
use crate::GatewayError;
use aether_admin::system::{
admin_proxy_node_event_node_id_from_path, build_admin_proxy_nodes_data_unavailable_response,
build_admin_proxy_nodes_not_found_response,
admin_proxy_node_event_node_id_from_path, build_admin_proxy_node_payload,
build_admin_proxy_nodes_data_unavailable_response, build_admin_proxy_nodes_not_found_response,
};
use axum::{body::Body, http, response::Response};
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Deserialize)]
struct ProxyNodeRegisterRequest {
name: String,
ip: String,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
heartbeat_interval: Option<i32>,
#[serde(default)]
active_connections: Option<i32>,
#[serde(default)]
total_requests: Option<i64>,
#[serde(default)]
avg_latency_ms: Option<f64>,
#[serde(default)]
hardware_info: Option<Value>,
#[serde(default)]
estimated_max_concurrency: Option<i32>,
#[serde(default)]
proxy_metadata: Option<Value>,
#[serde(default)]
proxy_version: Option<String>,
#[serde(default)]
tunnel_mode: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct ProxyNodeHeartbeatRequest {
node_id: String,
#[serde(default)]
heartbeat_interval: Option<i32>,
#[serde(default)]
active_connections: Option<i32>,
#[serde(default)]
total_requests: Option<i64>,
#[serde(default)]
avg_latency_ms: Option<f64>,
#[serde(default)]
failed_requests: Option<i64>,
#[serde(default)]
dns_failures: Option<i64>,
#[serde(default)]
stream_errors: Option<i64>,
#[serde(default)]
proxy_metadata: Option<Value>,
#[serde(default)]
proxy_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ProxyNodeUnregisterRequest {
node_id: String,
}
#[derive(Debug, Deserialize)]
struct ProxyNodeBatchUpgradeRequest {
version: String,
#[serde(default)]
batch_size: Option<usize>,
#[serde(default)]
cooldown_secs: Option<u64>,
#[serde(default)]
probe_url: Option<String>,
#[serde(default)]
probe_timeout_secs: Option<u64>,
}
const JSON_OBJECT_REQUIRED_DETAIL: &str = "请求体必须是合法的 JSON 对象";
const DEFAULT_PROXY_UPGRADE_BATCH_SIZE: usize = 1;
const DEFAULT_PROXY_UPGRADE_COOLDOWN_SECS: u64 = 60;
const DEFAULT_PROXY_UPGRADE_PROBE_TIMEOUT_SECS: u64 = 10;
pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
return Ok(None);
@@ -61,5 +149,806 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
));
}
if decision.route_kind.as_deref() == Some("register_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::<ProxyNodeRegisterRequest>(request_body) {
Ok(input) => input,
Err(response) => return Ok(Some(response)),
};
let mutation = match validate_register_request(input, request_context) {
Ok(mutation) => mutation,
Err(response) => return Ok(Some(response)),
};
let Some(node) = state.register_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("heartbeat_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::<ProxyNodeHeartbeatRequest>(request_body) {
Ok(input) => input,
Err(response) => return Ok(Some(response)),
};
let mutation = match validate_heartbeat_request(input) {
Ok(mutation) => mutation,
Err(response) => return Ok(Some(response)),
};
let Some(existing) = state.find_proxy_node(&mutation.node_id).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
if !existing.tunnel_mode {
return Ok(Some(bad_request_response(
"non-tunnel mode is no longer supported, please upgrade aether-proxy to use tunnel mode",
)));
}
let Some(node) = state.apply_proxy_node_heartbeat(&mutation).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
return Ok(Some(
Json(json!({
"message": "heartbeat ok",
"node": build_admin_proxy_node_payload(&node),
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("unregister_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::<ProxyNodeUnregisterRequest>(request_body) {
Ok(input) => input,
Err(response) => return Ok(Some(response)),
};
let node_id = match validate_node_id(&input.node_id) {
Ok(node_id) => node_id,
Err(response) => return Ok(Some(response)),
};
let Some(node) = state.unregister_proxy_node(&node_id).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
return Ok(Some(
Json(json!({
"message": "unregistered",
"node_id": node.id,
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("update_node_config")
&& request_context.method() == http::Method::PUT
{
if !state.has_proxy_node_writer() {
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let Some(node_id) = admin_proxy_node_config_node_id_from_path(request_context.path())
else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
let raw = match parse_json_object_body(request_body) {
Ok(raw) => raw,
Err(response) => return Ok(Some(response)),
};
let Some(existing) = state.find_proxy_node(&node_id).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
if existing.is_manual {
return Ok(Some(bad_request_response("手动节点不支持远程配置下发")));
}
let mutation = match validate_remote_config_request(node_id, &raw) {
Ok(mutation) => mutation,
Err(response) => return Ok(Some(response)),
};
let Some(node) = state.update_proxy_node_remote_config(&mutation).await? else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
return Ok(Some(
Json(json!({
"node_id": node.id,
"config_version": node.config_version,
"remote_config": node.remote_config,
"node": build_admin_proxy_node_payload(&node),
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("batch_upgrade_nodes")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let input = match parse_json_body::<ProxyNodeBatchUpgradeRequest>(request_body) {
Ok(input) => input,
Err(response) => return Ok(Some(response)),
};
let version = match validate_version(&input.version) {
Ok(version) => version,
Err(response) => return Ok(Some(response)),
};
let batch_size = match validate_batch_size(input.batch_size) {
Ok(batch_size) => batch_size,
Err(response) => return Ok(Some(response)),
};
let cooldown_secs = match validate_cooldown_secs(input.cooldown_secs) {
Ok(cooldown_secs) => cooldown_secs,
Err(response) => return Ok(Some(response)),
};
let probe =
match validate_probe_config(input.probe_url.as_deref(), input.probe_timeout_secs) {
Ok(probe) => probe,
Err(response) => return Ok(Some(response)),
};
let rollout = start_proxy_upgrade_rollout(
&state.app().data,
version.clone(),
batch_size,
cooldown_secs,
probe,
)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(json!({
"version": version,
"batch_size": rollout.batch_size,
"cooldown_secs": rollout.cooldown_secs,
"updated": rollout.updated,
"skipped": rollout.skipped,
"node_ids": rollout.node_ids,
"blocked": rollout.blocked,
"pending_node_ids": rollout.pending_node_ids,
"rollout_active": rollout.rollout_active,
"completed": rollout.completed,
"remaining": rollout.remaining,
}))
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("cancel_upgrade_rollout")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let summary = cancel_proxy_upgrade_rollout(&state.app().data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(match summary {
Some(summary) => json!({
"cancelled": true,
"version": summary.version,
"pending_node_ids": summary.pending_node_ids,
"conflict_node_ids": summary.conflict_node_ids,
"completed": summary.completed,
"remaining": summary.remaining,
}),
None => json!({
"cancelled": false,
"rollout_active": false,
}),
})
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("clear_upgrade_rollout_conflicts")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let summary = clear_proxy_upgrade_rollout_conflicts(&state.app().data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(match summary {
Some(summary) => json!({
"version": summary.version,
"cleared": summary.cleared_node_ids.len(),
"node_ids": summary.cleared_node_ids,
"updated": summary.updated,
"blocked": summary.blocked,
"pending_node_ids": summary.pending_node_ids,
"rollout_active": summary.rollout_active,
"completed": summary.completed,
"remaining": summary.remaining,
}),
None => json!({
"version": null,
"cleared": 0,
"node_ids": [],
"updated": 0,
"blocked": false,
"pending_node_ids": [],
"rollout_active": false,
"completed": 0,
"remaining": 0,
}),
})
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("restore_skipped_upgrade_rollout_nodes")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let summary = restore_proxy_upgrade_rollout_skipped_nodes(&state.app().data)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(match summary {
Some(summary) => json!({
"version": summary.version,
"restored": summary.restored_node_ids.len(),
"node_ids": summary.restored_node_ids,
"skipped_node_ids": summary.skipped_node_ids,
"updated": summary.updated,
"blocked": summary.blocked,
"pending_node_ids": summary.pending_node_ids,
"rollout_active": summary.rollout_active,
"completed": summary.completed,
"remaining": summary.remaining,
}),
None => json!({
"version": null,
"restored": 0,
"node_ids": [],
"skipped_node_ids": [],
"updated": 0,
"blocked": false,
"pending_node_ids": [],
"rollout_active": false,
"completed": 0,
"remaining": 0,
}),
})
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("skip_upgrade_rollout_node")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let Some(node_id) = admin_proxy_node_upgrade_action_node_id_from_path(
request_context.path(),
"/upgrade/skip",
) else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
let summary = skip_proxy_upgrade_rollout_node(&state.app().data, &node_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(match summary {
Some(summary) => json!({
"version": summary.version,
"node_id": summary.node_id,
"skipped_node_ids": summary.skipped_node_ids,
"updated": summary.updated,
"blocked": summary.blocked,
"pending_node_ids": summary.pending_node_ids,
"rollout_active": summary.rollout_active,
"completed": summary.completed,
"remaining": summary.remaining,
}),
None => json!({
"version": null,
"node_id": node_id,
"skipped_node_ids": [],
"updated": 0,
"blocked": false,
"pending_node_ids": [],
"rollout_active": false,
"completed": 0,
"remaining": 0,
}),
})
.into_response(),
));
}
if decision.route_kind.as_deref() == Some("retry_upgrade_rollout_node")
&& request_context.method() == http::Method::POST
{
if !state.has_proxy_node_reader()
|| !state.has_proxy_node_writer()
|| !state.app().data.has_system_config_store()
{
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
}
let Some(node_id) = admin_proxy_node_upgrade_action_node_id_from_path(
request_context.path(),
"/upgrade/retry",
) else {
return Ok(Some(build_admin_proxy_nodes_not_found_response()));
};
let summary = retry_proxy_upgrade_rollout_node(&state.app().data, &node_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(
Json(match summary {
Some(summary) => json!({
"version": summary.version,
"node_id": summary.node_id,
"skipped_node_ids": summary.skipped_node_ids,
"updated": summary.updated,
"blocked": summary.blocked,
"pending_node_ids": summary.pending_node_ids,
"rollout_active": summary.rollout_active,
"completed": summary.completed,
"remaining": summary.remaining,
}),
None => json!({
"version": null,
"node_id": node_id,
"skipped_node_ids": [],
"updated": 0,
"blocked": false,
"pending_node_ids": [],
"rollout_active": false,
"completed": 0,
"remaining": 0,
}),
})
.into_response(),
));
}
Ok(Some(build_admin_proxy_nodes_data_unavailable_response()))
}
fn validate_register_request(
input: ProxyNodeRegisterRequest,
request_context: &AdminRequestContext<'_>,
) -> Result<aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation, Response<Body>> {
let name = normalize_required_string(&input.name, "name", 100)?;
let ip = normalize_ip_address(&input.ip)?;
let heartbeat_interval = validate_optional_i32_range(
input.heartbeat_interval.unwrap_or(30),
"heartbeat_interval",
5,
600,
)?;
if !input.tunnel_mode.unwrap_or(true) {
return Err(bad_request_response("仅支持 tunnel_mode=true"));
}
validate_optional_counter(
input.active_connections.map(i64::from),
"active_connections",
)?;
validate_optional_counter(input.total_requests, "total_requests")?;
validate_optional_counter(
input.estimated_max_concurrency.map(i64::from),
"estimated_max_concurrency",
)?;
if input
.avg_latency_ms
.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(bad_request_response("avg_latency_ms 必须是非负有限数值"));
}
validate_optional_object(input.hardware_info.as_ref(), "hardware_info")?;
validate_optional_object(input.proxy_metadata.as_ref(), "proxy_metadata")?;
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::ProxyNodeRegistrationMutation {
name,
ip,
port: i32::from(input.port.unwrap_or_default()),
region: normalize_optional_string(input.region.as_deref(), "region", 100)?,
heartbeat_interval,
active_connections: input.active_connections,
total_requests: input.total_requests,
avg_latency_ms: input.avg_latency_ms,
hardware_info: input.hardware_info,
estimated_max_concurrency: input.estimated_max_concurrency,
proxy_metadata: input.proxy_metadata,
proxy_version: normalize_optional_string(
input.proxy_version.as_deref(),
"proxy_version",
20,
)?,
registered_by,
tunnel_mode: true,
},
)
}
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/")?;
let node_id = node_id.strip_suffix(suffix)?;
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) {
Ok(batch_size)
} else {
Err(bad_request_response("batch_size 必须在 1 到 100 之间"))
}
}
fn validate_cooldown_secs(cooldown_secs: Option<u64>) -> Result<u64, Response<Body>> {
let cooldown_secs = cooldown_secs.unwrap_or(DEFAULT_PROXY_UPGRADE_COOLDOWN_SECS);
if cooldown_secs <= 3600 {
Ok(cooldown_secs)
} else {
Err(bad_request_response("cooldown_secs 不能超过 3600"))
}
}
fn validate_probe_config(
probe_url: Option<&str>,
probe_timeout_secs: Option<u64>,
) -> Result<Option<ProxyUpgradeRolloutProbeConfig>, Response<Body>> {
let Some(probe_url) = probe_url.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let parsed = reqwest::Url::parse(probe_url)
.map_err(|_| bad_request_response("probe_url 必须是合法的 http/https URL"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(bad_request_response("probe_url 仅支持 http 或 https"));
}
if parsed.as_str().len() > 2048 {
return Err(bad_request_response("probe_url 长度不能超过 2048"));
}
let timeout_secs = probe_timeout_secs.unwrap_or(DEFAULT_PROXY_UPGRADE_PROBE_TIMEOUT_SECS);
if !(5..=60).contains(&timeout_secs) {
return Err(bad_request_response(
"probe_timeout_secs 必须在 5 到 60 秒之间",
));
}
Ok(Some(ProxyUpgradeRolloutProbeConfig {
url: parsed.to_string(),
timeout_secs,
}))
}
fn validate_heartbeat_request(
input: ProxyNodeHeartbeatRequest,
) -> Result<aether_data::repository::proxy_nodes::ProxyNodeHeartbeatMutation, Response<Body>> {
let node_id = validate_node_id(&input.node_id)?;
if let Some(interval) = input.heartbeat_interval {
validate_optional_i32_range(interval, "heartbeat_interval", 5, 600)?;
}
validate_optional_counter(
input.active_connections.map(i64::from),
"active_connections",
)?;
validate_optional_counter(input.total_requests, "total_requests")?;
validate_optional_counter(input.failed_requests, "failed_requests")?;
validate_optional_counter(input.dns_failures, "dns_failures")?;
validate_optional_counter(input.stream_errors, "stream_errors")?;
if input
.avg_latency_ms
.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err(bad_request_response("avg_latency_ms 必须是非负有限数值"));
}
validate_optional_object(input.proxy_metadata.as_ref(), "proxy_metadata")?;
Ok(
aether_data::repository::proxy_nodes::ProxyNodeHeartbeatMutation {
node_id,
heartbeat_interval: input.heartbeat_interval,
active_connections: input.active_connections,
total_requests_delta: input.total_requests,
avg_latency_ms: input.avg_latency_ms,
failed_requests_delta: input.failed_requests,
dns_failures_delta: input.dns_failures,
stream_errors_delta: input.stream_errors,
proxy_metadata: input.proxy_metadata,
proxy_version: normalize_optional_string(
input.proxy_version.as_deref(),
"proxy_version",
20,
)?,
},
)
}
fn validate_remote_config_request(
node_id: String,
raw: &serde_json::Map<String, Value>,
) -> Result<aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation, Response<Body>> {
let node_name = match raw.get("node_name") {
Some(Value::Null) | None => None,
Some(Value::String(value)) => Some(normalize_required_string(value, "node_name", 100)?),
Some(_) => return Err(bad_request_response("node_name 必须是字符串")),
};
let allowed_ports = match raw.get("allowed_ports") {
Some(Value::Null) | None => None,
Some(Value::Array(items)) => {
let mut ports = Vec::with_capacity(items.len());
for item in items {
let Some(port) = item.as_u64() else {
return Err(bad_request_response("allowed_ports 必须是端口数字数组"));
};
if !(1..=65535).contains(&port) {
return Err(bad_request_response("allowed_ports 仅支持 1-65535"));
}
ports.push(port as u16);
}
Some(ports)
}
Some(_) => return Err(bad_request_response("allowed_ports 必须是端口数字数组")),
};
let log_level = match raw.get("log_level") {
Some(Value::Null) | None => None,
Some(Value::String(value)) => {
let normalized = normalize_required_string(value, "log_level", 16)?;
if !matches!(
normalized.as_str(),
"trace" | "debug" | "info" | "warn" | "error"
) {
return Err(bad_request_response(
"log_level 必须是 trace/debug/info/warn/error 之一",
));
}
Some(normalized)
}
Some(_) => return Err(bad_request_response("log_level 必须是字符串")),
};
let heartbeat_interval = match raw.get("heartbeat_interval") {
Some(Value::Null) | None => None,
Some(value) => Some(validate_json_i32_range(
value,
"heartbeat_interval",
5,
600,
)?),
};
let scheduling_state = if raw.contains_key("scheduling_state") {
match raw.get("scheduling_state") {
Some(Value::Null) | None => Some(None),
Some(Value::String(value)) => {
let normalized = normalize_required_string(value, "scheduling_state", 16)?;
match normalized.as_str() {
"active" => Some(None),
"draining" | "cordoned" => Some(Some(normalized)),
_ => {
return Err(bad_request_response(
"scheduling_state 必须是 active/draining/cordoned 之一",
));
}
}
}
Some(_) => return Err(bad_request_response("scheduling_state 必须是字符串或 null")),
}
} else {
None
};
let upgrade_to = if raw.contains_key("upgrade_to") {
match raw.get("upgrade_to") {
Some(Value::Null) | None => Some(None),
Some(Value::String(value)) => {
let normalized = value.trim();
if normalized.is_empty() {
Some(None)
} else {
Some(Some(validate_version(normalized)?))
}
}
Some(_) => return Err(bad_request_response("upgrade_to 必须是字符串或 null")),
}
} else {
None
};
Ok(
aether_data::repository::proxy_nodes::ProxyNodeRemoteConfigMutation {
node_id,
node_name,
allowed_ports,
log_level,
heartbeat_interval,
scheduling_state,
upgrade_to,
},
)
}
fn admin_proxy_node_config_node_id_from_path(path: &str) -> Option<String> {
let value = path
.strip_prefix("/api/admin/proxy-nodes/")?
.strip_suffix("/config")?;
if value.is_empty() || value.contains('/') {
None
} else {
Some(value.to_string())
}
}
fn parse_json_body<T: DeserializeOwned>(request_body: Option<&Bytes>) -> Result<T, Response<Body>> {
let Some(request_body) = request_body else {
return Err(bad_request_response("请求体不能为空"));
};
let raw_value = serde_json::from_slice::<Value>(request_body)
.map_err(|_| bad_request_response(JSON_OBJECT_REQUIRED_DETAIL))?;
serde_json::from_value::<T>(raw_value)
.map_err(|_| bad_request_response(JSON_OBJECT_REQUIRED_DETAIL))
}
fn parse_json_object_body(
request_body: Option<&Bytes>,
) -> Result<serde_json::Map<String, Value>, Response<Body>> {
let Some(request_body) = request_body else {
return Err(bad_request_response("请求体不能为空"));
};
let raw_value = serde_json::from_slice::<Value>(request_body)
.map_err(|_| bad_request_response(JSON_OBJECT_REQUIRED_DETAIL))?;
raw_value
.as_object()
.cloned()
.ok_or_else(|| bad_request_response(JSON_OBJECT_REQUIRED_DETAIL))
}
fn validate_node_id(value: &str) -> Result<String, Response<Body>> {
normalize_required_string(value, "node_id", 36)
}
fn validate_version(value: &str) -> Result<String, Response<Body>> {
normalize_required_string(value, "version", 50)
}
fn normalize_required_string(
value: &str,
field: &str,
max_len: usize,
) -> Result<String, Response<Body>> {
let normalized = value.trim();
if normalized.is_empty() {
return Err(bad_request_response(format!("{field} 不能为空")));
}
if normalized.chars().count() > max_len {
return Err(bad_request_response(format!(
"{field} 长度不能超过 {max_len}"
)));
}
Ok(normalized.to_string())
}
fn normalize_optional_string(
value: Option<&str>,
field: &str,
max_len: usize,
) -> Result<Option<String>, Response<Body>> {
let Some(value) = value else {
return Ok(None);
};
let normalized = value.trim();
if normalized.is_empty() {
return Ok(None);
}
if normalized.chars().count() > max_len {
return Err(bad_request_response(format!(
"{field} 长度不能超过 {max_len}"
)));
}
Ok(Some(normalized.to_string()))
}
fn normalize_ip_address(value: &str) -> Result<String, Response<Body>> {
let normalized = value.trim();
normalized
.parse::<std::net::IpAddr>()
.map(|ip| ip.to_string())
.map_err(|_| bad_request_response("ip 必须是合法的 IPv4/IPv6 地址"))
}
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} 必须是非负整数")));
}
Ok(())
}
fn validate_optional_i32_range(
value: i32,
field: &str,
min: i32,
max: i32,
) -> Result<i32, Response<Body>> {
if !(min..=max).contains(&value) {
return Err(bad_request_response(format!(
"{field} 必须在 {min}-{max} 范围内"
)));
}
Ok(value)
}
fn validate_json_i32_range(
value: &Value,
field: &str,
min: i32,
max: i32,
) -> Result<i32, Response<Body>> {
let Some(raw) = value.as_i64() else {
return Err(bad_request_response(format!("{field} 必须是整数")));
};
let parsed =
i32::try_from(raw).map_err(|_| bad_request_response(format!("{field} 超出范围")))?;
validate_optional_i32_range(parsed, field, min, max)
}
fn validate_optional_object(value: Option<&Value>, field: &str) -> Result<(), Response<Body>> {
if value.is_some_and(|value| !value.is_object()) {
return Err(bad_request_response(format!("{field} 必须是 JSON 对象")));
}
Ok(())
}
fn bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}

View File

@@ -38,6 +38,7 @@ pub(crate) async fn maybe_build_local_admin_system_response(
if let Some(response) = proxy_nodes::maybe_build_local_admin_proxy_nodes_response(
&request.state(),
&request.request_context(),
request.request_body(),
)
.await?
{

View File

@@ -820,7 +820,11 @@ pub(crate) async fn maybe_build_local_internal_proxy_response_impl(
};
let response = match state.apply_proxy_node_heartbeat(&mutation).await {
Ok(Some(node)) => Json(build_internal_tunnel_heartbeat_ack(&node)).into_response(),
Ok(Some(node)) => Json(build_internal_tunnel_heartbeat_ack(
&node,
payload.heartbeat_id,
))
.into_response(),
Ok(None) => build_internal_control_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("heartbeat sync failed: ProxyNode {node_id} 不存在"),
@@ -850,7 +854,7 @@ pub(crate) async fn maybe_build_local_internal_proxy_response_impl(
connected: payload.connected,
conn_count: payload.conn_count,
detail: None,
observed_at_unix_secs: None,
observed_at_unix_secs: payload.observed_at_unix_secs,
};
let response = match state.update_proxy_node_tunnel_status(&mutation).await {

View File

@@ -349,22 +349,26 @@ pub(crate) fn gateway_error_message(error: GatewayError) -> String {
}
}
pub(crate) fn build_internal_tunnel_heartbeat_ack(node: &StoredProxyNode) -> serde_json::Value {
let Some(remote_config) = node.remote_config.as_ref() else {
return json!({});
};
pub(crate) fn build_internal_tunnel_heartbeat_ack(
node: &StoredProxyNode,
heartbeat_id: Option<u64>,
) -> serde_json::Value {
let mut payload = serde_json::Map::new();
payload.insert("remote_config".to_string(), remote_config.clone());
payload.insert("config_version".to_string(), json!(node.config_version));
if let Some(upgrade_to) = remote_config
.as_object()
.and_then(|value| value.get("upgrade_to"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload.insert("upgrade_to".to_string(), json!(upgrade_to));
if let Some(heartbeat_id) = heartbeat_id {
payload.insert("heartbeat_id".to_string(), json!(heartbeat_id));
}
if let Some(remote_config) = node.remote_config.as_ref() {
payload.insert("remote_config".to_string(), remote_config.clone());
payload.insert("config_version".to_string(), json!(node.config_version));
if let Some(upgrade_to) = remote_config
.as_object()
.and_then(|value| value.get("upgrade_to"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
payload.insert("upgrade_to".to_string(), json!(upgrade_to));
}
}
serde_json::Value::Object(payload)
}

View File

@@ -51,6 +51,7 @@ use crate::{
use axum::body::{to_bytes, Body, Bytes};
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
use sha2::{Digest, Sha256};
use std::time::Instant;
use tracing::{debug, info, warn};
@@ -96,6 +97,141 @@ fn execution_runtime_candidate_header_value(decision: &GatewayControlDecision) -
}
}
fn extract_management_token_bearer(headers: &http::HeaderMap) -> Option<String> {
let header = crate::headers::header_value_str(headers, http::header::AUTHORIZATION.as_str())?;
let token = header
.strip_prefix("Bearer ")
.or_else(|| header.strip_prefix("bearer "))?
.trim()
.to_string();
(!token.is_empty() && token.starts_with("ae_")).then_some(token)
}
fn hash_management_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn remote_ip_allowed(allowed_ips: Option<&serde_json::Value>, remote_ip: std::net::IpAddr) -> bool {
let Some(allowed_ips) = allowed_ips else {
return true;
};
let Some(items) = allowed_ips.as_array() else {
return false;
};
if items.is_empty() {
return false;
}
items
.iter()
.filter_map(serde_json::Value::as_str)
.any(|value| ip_or_cidr_matches(value, remote_ip))
}
fn ip_or_cidr_matches(pattern: &str, remote_ip: std::net::IpAddr) -> bool {
let pattern = pattern.trim();
if pattern.is_empty() {
return false;
}
if let Ok(ip) = pattern.parse::<std::net::IpAddr>() {
return ip == remote_ip;
}
let Some((network, prefix)) = pattern.split_once('/') else {
return false;
};
let Ok(prefix) = prefix.trim().parse::<u8>() else {
return false;
};
match (network.trim().parse::<std::net::IpAddr>(), remote_ip) {
(Ok(std::net::IpAddr::V4(network)), std::net::IpAddr::V4(remote)) if prefix <= 32 => {
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
(u32::from(network) & mask) == (u32::from(remote) & mask)
}
(Ok(std::net::IpAddr::V6(network)), std::net::IpAddr::V6(remote)) if prefix <= 128 => {
let mask = if prefix == 0 {
0
} else {
u128::MAX << (128 - prefix)
};
(u128::from(network) & mask) == (u128::from(remote) & mask)
}
_ => false,
}
}
async fn maybe_promote_management_token_admin_principal(
state: &AppState,
remote_addr: &std::net::SocketAddr,
headers: &http::HeaderMap,
trace_id: &str,
request_context: &mut GatewayPublicRequestContext,
) -> Result<(), GatewayError> {
let Some(decision) = request_context.control_decision.as_mut() else {
return Ok(());
};
if decision.route_class.as_deref() != Some("admin_proxy") || decision.admin_principal.is_some()
{
return Ok(());
}
let Some(token) = extract_management_token_bearer(headers) else {
return Ok(());
};
let token_hash = hash_management_token(&token);
let Some(token_with_user) = state
.get_management_token_with_user_by_hash(&token_hash)
.await?
else {
return Ok(());
};
if !token_with_user.token.is_active {
return Ok(());
}
if token_with_user
.token
.expires_at_unix_secs
.is_some_and(|value| value <= chrono::Utc::now().timestamp().max(0) as u64)
{
return Ok(());
}
if !remote_ip_allowed(token_with_user.token.allowed_ips.as_ref(), remote_addr.ip()) {
return Ok(());
}
let Some(user) = state.find_user_auth_by_id(&token_with_user.user.id).await? else {
return Ok(());
};
if !user.is_active || user.is_deleted || !user.role.eq_ignore_ascii_case("admin") {
return Ok(());
}
decision.admin_principal = Some(crate::control::GatewayAdminPrincipalContext {
user_id: user.id.clone(),
user_role: user.role.clone(),
session_id: None,
management_token_id: Some(token_with_user.token.id.clone()),
});
let remote_ip = remote_addr.ip().to_string();
if let Err(err) = state
.record_management_token_usage(&token_with_user.token.id, Some(remote_ip.as_str()))
.await
{
warn!(
trace_id = %trace_id,
token_id = %token_with_user.token.id,
error = ?err,
"gateway failed to record management token usage"
);
}
Ok(())
}
async fn maybe_forward_public_request_to_tunnel_owner(
state: &AppState,
remote_addr: &std::net::SocketAddr,
@@ -379,7 +515,7 @@ pub(crate) async fn proxy_request(
));
}
let request_context_started_at = Instant::now();
let request_context = crate::control::resolve_public_request_context(
let mut request_context = crate::control::resolve_public_request_context(
&state,
&parts.method,
&parts.uri,
@@ -387,6 +523,14 @@ pub(crate) async fn proxy_request(
&trace_id,
)
.await?;
maybe_promote_management_token_admin_principal(
&state,
&remote_addr,
&parts.headers,
&trace_id,
&mut request_context,
)
.await?;
let request_context_ms = request_context_started_at.elapsed().as_millis() as u64;
if request_context
.control_decision

View File

@@ -5,6 +5,8 @@ use std::collections::BTreeMap;
pub(crate) struct InternalTunnelHeartbeatRequest {
pub(crate) node_id: String,
#[serde(default)]
pub(crate) heartbeat_id: Option<u64>,
#[serde(default)]
pub(crate) heartbeat_interval: Option<i32>,
#[serde(default)]
pub(crate) active_connections: Option<i32>,
@@ -30,6 +32,8 @@ pub(crate) struct InternalTunnelNodeStatusRequest {
pub(crate) connected: bool,
#[serde(default)]
pub(crate) conn_count: i32,
#[serde(default)]
pub(crate) observed_at_unix_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]

View File

@@ -305,6 +305,11 @@ 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("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("batch_upgrade_nodes"))
| (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"))
| (Some("users_manage"), http::Method::POST, Some("create_user"))