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
+231 -19
View File
@@ -1,8 +1,7 @@
use std::fmt;
use std::path::Path;
use aether_runtime::{
FileLoggingConfig, LogDestination, LogFormat, LogRotation, ServiceRuntimeConfig,
};
use aether_runtime::{FileLoggingConfig, LogDestination, LogRotation, ServiceRuntimeConfig};
use clap::Parser;
use serde::{Deserialize, Serialize};
@@ -36,6 +35,148 @@ const DELEGATE_TO_UPSTREAM: &[(&str, &str)] = &[
("delegate_tcp_nodelay", "upstream_tcp_nodelay"),
];
/// Default bytes buffered before a tunnel request becomes non-replayable for
/// 307/308 redirects. Kept aligned with the current admin-side request size
/// default, but exposed as an independent proxy transport budget.
pub const DEFAULT_HEARTBEAT_INTERVAL_SECS: u64 = 30;
pub const DEFAULT_REDIRECT_REPLAY_BUDGET_BYTES: usize = 5_242_880;
pub const DEFAULT_REDIRECT_REPLAY_BUDGET_HUMAN: &str = "5M";
pub const DEFAULT_LOG_RETENTION_DAYS: u64 = 7;
pub const DEFAULT_LOG_MAX_FILES: usize = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
enum ByteSizeValue {
Text(String),
Integer(u64),
}
impl<'de> Deserialize<'de> for ByteSizeValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct ByteSizeValueVisitor;
impl serde::de::Visitor<'_> for ByteSizeValueVisitor {
type Value = ByteSizeValue;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a byte-size string like 5M or an integer byte count")
}
fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ByteSizeValue::Integer(value))
}
fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if value < 0 {
return Err(E::custom("byte size must be >= 0"));
}
Ok(ByteSizeValue::Integer(value as u64))
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ByteSizeValue::Text(value.to_string()))
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(ByteSizeValue::Text(value))
}
}
deserializer.deserialize_any(ByteSizeValueVisitor)
}
}
fn deserialize_optional_byte_size<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<ByteSizeValue>::deserialize(deserializer)?;
value
.map(|value| match value {
ByteSizeValue::Text(text) => {
normalize_byte_size_text(&text).map_err(serde::de::Error::custom)
}
ByteSizeValue::Integer(value) => usize::try_from(value)
.map(format_byte_size_human)
.map_err(|_| serde::de::Error::custom("byte size exceeds usize")),
})
.transpose()
}
pub fn parse_byte_size(input: &str) -> Result<usize, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("byte size must not be empty".to_string());
}
let digits_end = trimmed
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(trimmed.len());
if digits_end == 0 {
return Err(format!("invalid byte size `{trimmed}`"));
}
let number = trimmed[..digits_end]
.parse::<u64>()
.map_err(|_| format!("invalid byte size `{trimmed}`"))?;
let suffix = trimmed[digits_end..].trim().to_ascii_lowercase();
let multiplier = match suffix.as_str() {
"" | "b" => 1u64,
"k" | "kb" | "kib" => 1024u64,
"m" | "mb" | "mib" => 1024u64.pow(2),
"g" | "gb" | "gib" => 1024u64.pow(3),
_ => {
return Err(format!(
"invalid byte size suffix `{}`; use B, K, M, or G",
&trimmed[digits_end..].trim()
))
}
};
let total = number
.checked_mul(multiplier)
.ok_or_else(|| format!("byte size `{trimmed}` is too large"))?;
usize::try_from(total).map_err(|_| format!("byte size `{trimmed}` exceeds usize"))
}
fn normalize_byte_size_text(input: &str) -> Result<String, String> {
parse_byte_size(input).map(format_byte_size_human)
}
pub fn format_byte_size_human(bytes: usize) -> String {
const KIB: usize = 1024;
const MIB: usize = 1024 * 1024;
const GIB: usize = 1024 * 1024 * 1024;
if bytes == 0 {
return "0".to_string();
}
if bytes.is_multiple_of(GIB) {
return format!("{}G", bytes / GIB);
}
if bytes.is_multiple_of(MIB) {
return format!("{}M", bytes / MIB);
}
if bytes.is_multiple_of(KIB) {
return format!("{}K", bytes / KIB);
}
bytes.to_string()
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProxyLogDestinationArg {
@@ -91,7 +232,7 @@ pub struct Config {
pub public_ip: Option<String>,
/// Human-readable node name
#[arg(long, env = "AETHER_PROXY_NODE_NAME", default_value = "proxy-01")]
#[arg(long, env = "AETHER_PROXY_NODE_NAME")]
pub node_name: String,
/// Region label (e.g. ap-northeast-1)
@@ -99,7 +240,11 @@ pub struct Config {
pub node_region: Option<String>,
/// Heartbeat interval in seconds
#[arg(long, env = "AETHER_PROXY_HEARTBEAT_INTERVAL", default_value_t = 30)]
#[arg(
long,
env = "AETHER_PROXY_HEARTBEAT_INTERVAL",
default_value_t = DEFAULT_HEARTBEAT_INTERVAL_SECS
)]
pub heartbeat_interval: u64,
/// Allowed destination ports (default: 80,443,8080,8443)
@@ -271,14 +416,20 @@ pub struct Config {
)]
pub upstream_tcp_nodelay: bool,
/// Maximum request body bytes buffered to support 307/308 redirect replay.
/// Accepts values like 5M / 512K / 1G. Set to 0 to disable request-body replay buffering.
#[arg(
long,
env = "AETHER_PROXY_REDIRECT_REPLAY_BUDGET_BYTES",
value_parser = parse_byte_size,
default_value = DEFAULT_REDIRECT_REPLAY_BUDGET_HUMAN
)]
pub redirect_replay_budget_bytes: usize,
/// Log level (trace, debug, info, warn, error)
#[arg(long, env = "AETHER_PROXY_LOG_LEVEL", default_value = "info")]
pub log_level: String,
/// Output logs as JSON
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
pub log_json: bool,
/// Log destination (stdout, file, both)
#[arg(
long,
@@ -302,11 +453,19 @@ pub struct Config {
pub log_rotation: ProxyLogRotationArg,
/// Log file retention days for file logging
#[arg(long, env = "AETHER_PROXY_LOG_RETENTION_DAYS", default_value_t = 7)]
#[arg(
long,
env = "AETHER_PROXY_LOG_RETENTION_DAYS",
default_value_t = DEFAULT_LOG_RETENTION_DAYS
)]
pub log_retention_days: u64,
/// Maximum number of retained rolled log files
#[arg(long, env = "AETHER_PROXY_LOG_MAX_FILES", default_value_t = 30)]
#[arg(
long,
env = "AETHER_PROXY_LOG_MAX_FILES",
default_value_t = DEFAULT_LOG_MAX_FILES
)]
pub log_max_files: usize,
/// Tunnel reconnect base delay in milliseconds (used by exponential backoff)
@@ -371,6 +530,9 @@ impl Config {
if self.allowed_ports.is_empty() {
anyhow::bail!("allowed_ports must not be empty");
}
if self.node_name.trim().is_empty() {
anyhow::bail!("node_name must not be empty");
}
for &port in &self.allowed_ports {
if port == 0 {
anyhow::bail!("allowed_ports: port 0 is not valid");
@@ -439,11 +601,7 @@ impl Config {
pub fn service_runtime_config(&self) -> anyhow::Result<ServiceRuntimeConfig> {
let mut config = ServiceRuntimeConfig::new("aether-proxy", "aether_proxy=info")
.with_log_format(if self.log_json {
LogFormat::Json
} else {
LogFormat::Pretty
})
.with_log_format(aether_runtime::LogFormat::Pretty)
.with_log_destination(self.log_destination.into())
.with_node_role("proxy")
.with_instance_id(self.node_name.trim().to_string());
@@ -535,11 +693,15 @@ pub struct ConfigFile {
pub upstream_tcp_keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_tcp_nodelay: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_byte_size"
)]
pub redirect_replay_budget_bytes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_json: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_destination: Option<ProxyLogDestinationArg>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_dir: Option<String>,
@@ -794,8 +956,11 @@ impl ConfigFile {
"AETHER_PROXY_UPSTREAM_TCP_NODELAY",
self.upstream_tcp_nodelay
);
set!(
"AETHER_PROXY_REDIRECT_REPLAY_BUDGET_BYTES",
self.redirect_replay_budget_bytes
);
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
set!("AETHER_PROXY_LOG_JSON", self.log_json);
set!(
"AETHER_PROXY_LOG_DESTINATION",
self.log_destination.map(|v| match v {
@@ -855,3 +1020,50 @@ impl ConfigFile {
}
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use super::*;
#[test]
fn parse_byte_size_supports_human_units() {
assert_eq!(
parse_byte_size("5M").expect("5M should parse"),
5 * 1024 * 1024
);
assert_eq!(
parse_byte_size("512K").expect("512K should parse"),
512 * 1024
);
assert_eq!(
parse_byte_size("1G").expect("1G should parse"),
1024 * 1024 * 1024
);
assert_eq!(parse_byte_size("0").expect("0 should parse"), 0);
}
#[test]
fn config_file_deserializes_budget_from_integer_and_string() {
let numeric: ConfigFile =
toml::from_str("redirect_replay_budget_bytes = 5242880").expect("numeric toml");
assert_eq!(numeric.redirect_replay_budget_bytes.as_deref(), Some("5M"));
let stringy: ConfigFile =
toml::from_str("redirect_replay_budget_bytes = \"6m\"").expect("string toml");
assert_eq!(stringy.redirect_replay_budget_bytes.as_deref(), Some("6M"));
}
#[test]
fn config_requires_node_name() {
let command = Config::command();
let node_name = command
.get_arguments()
.find(|arg| arg.get_id() == "node_name")
.expect("node_name arg");
assert!(node_name.is_required_set());
assert!(node_name.get_default_values().is_empty());
}
}