feat: aether-proxy 远程配置下发、连通性测试与 setup TUI

- 后端新增远程配置管理 API (PUT /config) 和连通性测试 API (POST /test)
- 前端新增远程配置编辑对话框和节点连通性测试按钮
- aether-proxy 支持通过心跳接收并热加载远程配置 (端口白名单、日志级别、心跳间隔、时间戳容差)
- aether-proxy 新增 TOML 配置文件支持和交互式 setup TUI
- aether-proxy 心跳 404 时自动重注册节点
- plain proxy 响应改为流式传输,减少内存缓冲
- 新增 remote_config 和 config_version 数据库字段及迁移
This commit is contained in:
fawney19
2026-02-07 19:20:09 +08:00
parent 3b8398b2e5
commit 31bc452374
18 changed files with 2771 additions and 113 deletions

View File

@@ -1,9 +1,28 @@
use reqwest::Client;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use tracing::{debug, error, info, warn};
use crate::config::Config;
/// Heartbeat-specific error that distinguishes "node not found" (needs
/// re-registration) from transient / other failures.
#[derive(Debug)]
pub enum HeartbeatError {
/// HTTP 404 the node_id is no longer known to Aether.
NodeNotFound(String),
/// Any other failure (network, 5xx, etc.).
Other(anyhow::Error),
}
impl std::fmt::Display for HeartbeatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NodeNotFound(msg) => write!(f, "node not found: {}", msg),
Self::Other(e) => write!(f, "{}", e),
}
}
}
#[derive(Debug, Serialize)]
struct RegisterRequest {
name: String,
@@ -30,6 +49,37 @@ struct HeartbeatRequest {
avg_latency_ms: Option<f64>,
}
/// Remote configuration pushed by the Aether management backend.
#[derive(Debug, Clone, Deserialize)]
pub struct RemoteConfig {
pub allowed_ports: Option<Vec<u16>>,
pub log_level: Option<String>,
pub heartbeat_interval: Option<u64>,
pub timestamp_tolerance: Option<u64>,
}
/// Parsed heartbeat response from Aether.
#[derive(Debug, Deserialize)]
struct HeartbeatResponseBody {
#[serde(default)]
node: Option<HeartbeatNodeInfo>,
}
#[derive(Debug, Deserialize)]
struct HeartbeatNodeInfo {
#[serde(default)]
remote_config: Option<RemoteConfig>,
#[serde(default)]
config_version: Option<u64>,
}
/// Heartbeat result returned to the caller.
#[derive(Debug)]
pub struct HeartbeatResult {
pub remote_config: Option<RemoteConfig>,
pub config_version: u64,
}
#[derive(Debug, Serialize)]
struct UnregisterRequest {
node_id: String,
@@ -101,13 +151,17 @@ impl AetherClient {
}
/// Send heartbeat to Aether.
///
/// On success, returns any remote config included in the response.
/// Returns [`HeartbeatError::NodeNotFound`] on HTTP 404 so the caller
/// can trigger re-registration.
pub async fn heartbeat(
&self,
node_id: &str,
active_connections: Option<i64>,
total_requests: Option<i64>,
avg_latency_ms: Option<f64>,
) -> anyhow::Result<()> {
) -> Result<HeartbeatResult, HeartbeatError> {
let url = format!("{}/api/admin/proxy-nodes/heartbeat", self.base_url);
let body = HeartbeatRequest {
node_id: node_id.to_string(),
@@ -124,17 +178,43 @@ impl AetherClient {
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
.send()
.await?;
.await
.map_err(|e| HeartbeatError::Other(e.into()))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
warn!(status = %status, body = %text, "heartbeat failed");
anyhow::bail!("heartbeat failed (HTTP {}): {}", status, text);
if status == StatusCode::NOT_FOUND {
return Err(HeartbeatError::NodeNotFound(text));
}
return Err(HeartbeatError::Other(anyhow::anyhow!(
"heartbeat failed (HTTP {}): {}",
status,
text
)));
}
debug!(node_id = %node_id, "heartbeat ok");
Ok(())
// Parse remote config from response (best-effort)
let result = match resp.json::<HeartbeatResponseBody>().await {
Ok(body) => {
let (remote_config, config_version) = match body.node {
Some(node) => (node.remote_config, node.config_version.unwrap_or(0)),
None => (None, 0),
};
HeartbeatResult {
remote_config,
config_version,
}
}
Err(_) => HeartbeatResult {
remote_config: None,
config_version: 0,
},
};
debug!(node_id = %node_id, config_version = result.config_version, "heartbeat ok");
Ok(result)
}
/// Unregister this node from Aether (graceful shutdown).

View File

@@ -1,46 +1,99 @@
use std::sync::Arc;
use std::sync::{Arc, RwLock};
use tokio::sync::watch;
use tracing::{debug, warn};
use tracing::{debug, error, info, warn};
use crate::registration::client::AetherClient;
use crate::config::Config;
use crate::registration::client::{AetherClient, HeartbeatError};
use crate::runtime::{self, SharedDynamicConfig};
/// Run periodic heartbeat task until shutdown signal.
///
/// When Aether responds with 404 (node not found), this task automatically
/// re-registers the node and updates the shared `node_id` so the proxy
/// server and future heartbeats use the new identity.
///
/// When the heartbeat response includes a `remote_config`, it is applied
/// to the [`DynamicConfig`](crate::runtime::DynamicConfig) so the proxy
/// picks up changes without a restart.
pub async fn run(
client: Arc<AetherClient>,
node_id: Arc<String>,
interval_secs: u64,
node_id: Arc<RwLock<String>>,
config: Arc<Config>,
public_ip: String,
dynamic: SharedDynamicConfig,
mut shutdown_rx: watch::Receiver<bool>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
// Skip the first immediate tick (registration already acts as initial heartbeat)
interval.tick().await;
let mut consecutive_failures: u32 = 0;
// Skip the first tick (registration already acts as initial heartbeat)
let initial_interval = dynamic.read().unwrap().heartbeat_interval;
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(initial_interval)) => {}
_ = shutdown_rx.changed() => {
debug!("heartbeat task stopping (during initial wait)");
return;
}
}
loop {
tokio::select! {
_ = interval.tick() => {
match client.heartbeat(&node_id, None, None, None).await {
Ok(()) => {
if consecutive_failures > 0 {
debug!(
previous_failures = consecutive_failures,
"heartbeat recovered"
);
}
let current_node_id = node_id.read().unwrap().clone();
match client.heartbeat(&current_node_id, None, None, None).await {
Ok(result) => {
if consecutive_failures > 0 {
debug!(
previous_failures = consecutive_failures,
"heartbeat recovered"
);
}
consecutive_failures = 0;
// Apply remote config if present and version changed
if let Some(ref remote) = result.remote_config {
runtime::apply_remote_config(&dynamic, remote, result.config_version);
}
}
Err(HeartbeatError::NodeNotFound(_)) => {
warn!(
old_node_id = %current_node_id,
"node not found, re-registering"
);
match client.register(&config, &public_ip).await {
Ok(new_id) => {
info!(
old_node_id = %current_node_id,
new_node_id = %new_id,
"re-registered successfully"
);
*node_id.write().unwrap() = new_id;
consecutive_failures = 0;
}
Err(e) => {
consecutive_failures += 1;
warn!(
error!(
error = %e,
consecutive_failures,
"heartbeat failed"
"re-registration failed"
);
}
}
}
Err(HeartbeatError::Other(e)) => {
consecutive_failures += 1;
warn!(
error = %e,
consecutive_failures,
"heartbeat failed"
);
}
}
// Read interval from dynamic config (may have been updated remotely)
let interval_secs = dynamic.read().unwrap().heartbeat_interval;
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(interval_secs)) => {}
_ = shutdown_rx.changed() => {
debug!("heartbeat task stopping");
break;