Complete secure tunnel encryption support

This commit is contained in:
RWDai
2026-05-22 09:47:28 +08:00
parent 4f49dd5943
commit 2e701a90c9
24 changed files with 720 additions and 33 deletions

View File

@@ -7,7 +7,7 @@ AETHER_TUNNEL_MANAGEMENT_TOKEN=ae_xxxxx
# Node identification
AETHER_TUNNEL_NODE_NAME=jp-proxy-01
# Secure non-TLS tunnel MVP. Use non_tls_required only with a per-node base64 32-byte PSK.
# Secure non-TLS tunnel. http:// plus a key auto-enables non_tls_required.
AETHER_TUNNEL_SECURITY=off
# AETHER_TUNNEL_ENCRYPTION_KEY=base64-32-bytes

View File

@@ -113,8 +113,8 @@ sudo aether-tunnel uninstall
| `--aether-url` | `AETHER_TUNNEL_AETHER_URL` | **必填** | Aether 服务器地址 |
| `--management-token` | `AETHER_TUNNEL_MANAGEMENT_TOKEN` | **必填** | 管理员 Token`ae_xxx` 格式) |
| `--node-name` | `AETHER_TUNNEL_NODE_NAME` | **必填** | 节点名称标识 |
| `--tunnel-security` | `AETHER_TUNNEL_SECURITY` | `off` | Aether ↔ tunnel 通道安全模式;MVP 仅支持 `off` / `non_tls_required` |
| `--tunnel-encryption-key` | `AETHER_TUNNEL_ENCRYPTION_KEY` | 空 | `non_tls_required` 使用的长期 PSKbase64 32-byte每个 `[[servers]]` 节点独立配置 |
| `--tunnel-security` | `AETHER_TUNNEL_SECURITY` | `off` | Aether ↔ tunnel 通道安全模式;支持 `off` / `non_tls_required``http://` 且提供 key 时会自动按 `non_tls_required` 生效 |
| `--tunnel-encryption-key` | `AETHER_TUNNEL_ENCRYPTION_KEY` | 空 | secure tunnel 使用的长期 PSKbase64 32-byte每个 `[[servers]]` 节点独立配置 |
| `--public-ip` | `AETHER_TUNNEL_PUBLIC_IP` | 自动检测 | 公网 IP |
| `--node-region` | `AETHER_TUNNEL_NODE_REGION` | 自动检测 | 地区标识 |
| `--heartbeat-interval` | `AETHER_TUNNEL_HEARTBEAT_INTERVAL` | `5` | 心跳间隔(秒) |
@@ -226,12 +226,13 @@ tunnel_security = "off"
aether_url = "http://aether-2.example.com"
management_token = "ae_yyy"
node_name = "jp-proxy-02"
tunnel_security = "non_tls_required"
tunnel_encryption_key = "base64-32-bytes"
```
`tunnel_security = "non_tls_required"` 是非 TLS secure tunnel 的 MVP 配置面:它要求同时提供当前 `[[servers]]` 条目的 `tunnel_encryption_key`,后续握手使用 `node_name` / `X-Node-Id` 查找对应 PSK不引入 `tunnel_encryption_key_id``wss://` 仍是推荐方案;`ws:// + secure tunnel` 只保护 Aether ↔ tunnel 之间的 token 和 payload不等价于 HTTPS 伪装,也不覆盖 tunnel ↔ origin/provider 这段链路。
如果 `aether_url` 使用 `http://` 且当前 `[[servers]]` 条目提供了 `tunnel_encryption_key`,即使省略或保留 `tunnel_security = "off"`,运行时也会自动按 `non_tls_required` 生效。secure tunnel 会在 WebSocket tunnel 上加密所有二进制 tunnel frame未配置 key 的旧节点仍按原明文协议工作。
## 发布新版本
推送 `tunnel-v*` 格式的 tagGitHub Actions 会自动:

View File

@@ -18,7 +18,10 @@ use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use tracing::{error, info, warn};
use crate::config::{Config, ServerEntry, TunnelPoolSizing};
use crate::config::{
effective_tunnel_security, validate_tunnel_encryption_key, Config, ServerEntry,
TunnelPoolSizing,
};
use crate::net;
use crate::registration::client::AetherClient;
use crate::runtime::{self, DynamicConfig};
@@ -212,8 +215,24 @@ pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Resul
&entry.aether_url,
&entry.management_token,
));
let tunnel_security = effective_tunnel_security(
&entry.aether_url,
entry.tunnel_security,
entry.tunnel_encryption_key.as_deref(),
);
if tunnel_security == crate::config::TunnelSecurity::NonTlsRequired {
let key = entry
.tunnel_encryption_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
anyhow::anyhow!("tunnel_encryption_key must be set for secure non-TLS tunnel")
})?;
validate_tunnel_encryption_key(key)?;
}
match client
.register(&config, &node_name, &public_ip, Some(&hw_info))
.register(&config, entry, &node_name, &public_ip, Some(&hw_info))
.await
{
Ok(node_id) => {
@@ -603,7 +622,13 @@ async fn retry_failed_registration(
}
match client
.register(&state.config, &node_name, &public_ip, Some(&hw_info))
.register(
&state.config,
&entry,
&node_name,
&public_ip,
Some(&hw_info),
)
.await
{
Ok(node_id) => {
@@ -681,6 +706,12 @@ fn build_server_context(
server_label: label.to_string(),
aether_url: entry.aether_url.clone(),
management_token: entry.management_token.clone(),
tunnel_security: effective_tunnel_security(
&entry.aether_url,
entry.tunnel_security,
entry.tunnel_encryption_key.as_deref(),
),
tunnel_encryption_key: entry.tunnel_encryption_key.clone(),
node_name: node_name.to_string(),
node_id: Arc::new(RwLock::new(node_id)),
aether_client: client,

View File

@@ -278,6 +278,31 @@ impl FromStr for TunnelSecurity {
}
}
pub fn validate_tunnel_encryption_key(value: &str) -> anyhow::Result<()> {
aether_contracts::tunnel_security::decode_psk(value)
.map(|_| ())
.map_err(|err| anyhow::anyhow!(err))
}
pub fn effective_tunnel_security(
aether_url: &str,
configured: Option<TunnelSecurity>,
tunnel_encryption_key: Option<&str>,
) -> TunnelSecurity {
if configured == Some(TunnelSecurity::NonTlsRequired) {
return TunnelSecurity::NonTlsRequired;
}
if aether_url.trim_start().starts_with("http://")
&& tunnel_encryption_key
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
{
return TunnelSecurity::NonTlsRequired;
}
TunnelSecurity::Off
}
/// Aether tunnel agent.
///
/// Deployed on overseas VPS to relay API traffic for Aether instances
@@ -713,12 +738,18 @@ impl Config {
if self.node_name.trim().is_empty() {
anyhow::bail!("node_name must not be empty");
}
if self.tunnel_security == TunnelSecurity::NonTlsRequired
&& normalized_proxy_url(&self.tunnel_encryption_key).is_none()
{
anyhow::bail!(
"tunnel_encryption_key must be set when tunnel_security=non_tls_required"
);
let effective_security = effective_tunnel_security(
&self.aether_url,
Some(self.tunnel_security),
self.tunnel_encryption_key.as_deref(),
);
if effective_security == TunnelSecurity::NonTlsRequired {
let Some(key) = normalized_proxy_url(&self.tunnel_encryption_key) else {
anyhow::bail!(
"tunnel_encryption_key must be set when tunnel_security=non_tls_required"
);
};
validate_tunnel_encryption_key(key)?;
}
for &port in &self.allowed_ports {
if port == 0 {
@@ -1465,7 +1496,7 @@ aether_url = "http://aether.example.com"
management_token = "ae_test"
node_name = "jp-proxy-01"
tunnel_security = "non_tls_required"
tunnel_encryption_key = "base64-32-bytes"
tunnel_encryption_key = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
"#,
)
.expect("server tunnel security TOML");
@@ -1477,7 +1508,7 @@ tunnel_encryption_key = "base64-32-bytes"
);
assert_eq!(
cfg.servers[0].tunnel_encryption_key.as_deref(),
Some("base64-32-bytes")
Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=")
);
}
@@ -1740,13 +1771,61 @@ node_name = "tunnel-test"
"--tunnel-security",
"non_tls_required",
"--tunnel-encryption-key",
"base64-32-bytes",
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
]);
with_key
.validate()
.expect("non_tls_required with a PSK should validate");
}
#[test]
fn validate_infers_non_tls_security_for_http_url_with_key() {
let config = Config::parse_from([
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-encryption-key",
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
]);
assert_eq!(config.tunnel_security, TunnelSecurity::Off);
assert_eq!(
effective_tunnel_security(
&config.aether_url,
Some(config.tunnel_security),
config.tunnel_encryption_key.as_deref(),
),
TunnelSecurity::NonTlsRequired
);
config
.validate()
.expect("http URL with PSK should infer secure tunnel mode");
}
#[test]
fn validate_rejects_invalid_tunnel_encryption_key() {
let config = Config::parse_from([
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-encryption-key",
"not-a-valid-32-byte-key",
]);
let error = config
.validate()
.expect_err("invalid PSK should fail validation");
assert!(error.to_string().contains("base64-encoded 32 bytes"));
}
#[test]
fn cli_accepts_tunnel_ipv4_only() {
let config = Config::parse_from([

View File

@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use tracing::{debug, error, info};
use crate::config::Config;
use crate::config::{effective_tunnel_security, Config, ServerEntry, TunnelSecurity};
use crate::hardware::HardwareInfo;
#[derive(Debug, Serialize)]
@@ -22,6 +22,10 @@ struct RegisterRequest {
estimated_max_concurrency: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
proxy_metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
tunnel_security: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tunnel_encryption_key: Option<String>,
tunnel_mode: bool,
}
@@ -95,11 +99,17 @@ impl AetherClient {
pub async fn register(
&self,
config: &Config,
server: &ServerEntry,
node_name: &str,
public_ip: &str,
hw: Option<&HardwareInfo>,
) -> anyhow::Result<String> {
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
let effective_security = effective_tunnel_security(
&server.aether_url,
server.tunnel_security,
server.tunnel_encryption_key.as_deref(),
);
let body = RegisterRequest {
name: node_name.to_string(),
ip: public_ip.to_string(),
@@ -111,6 +121,11 @@ impl AetherClient {
proxy_metadata: Some(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
})),
tunnel_security: (effective_security == TunnelSecurity::NonTlsRequired)
.then(|| effective_security.to_string()),
tunnel_encryption_key: (effective_security == TunnelSecurity::NonTlsRequired)
.then(|| server.tunnel_encryption_key.clone())
.flatten(),
tunnel_mode: true,
};

View File

@@ -99,7 +99,7 @@ impl ServerTab {
value: "off".into(),
kind: FieldKind::Text,
required: false,
help: "off or non_tls_required for secure ws:// tunnel MVP",
help: "off or non_tls_required; http:// plus a key auto-enables secure tunnel",
},
Field {
label: "Tunnel Encryption Key",

View File

@@ -13,6 +13,7 @@ use aether_runtime::{
use aether_runtime_state::{RuntimeSemaphore, RuntimeSemaphoreError, RuntimeSemaphoreSnapshot};
use crate::config::Config;
use crate::config::TunnelSecurity;
use crate::hardware::RuntimeResourceMonitor;
use crate::registration::client::AetherClient;
use crate::runtime::SharedDynamicConfig;
@@ -44,6 +45,10 @@ pub struct ServerContext {
pub aether_url: String,
/// Management token for this server.
pub management_token: String,
/// Effective security mode for this server connection.
pub tunnel_security: TunnelSecurity,
/// PSK used for secure non-TLS tunnel frames.
pub tunnel_encryption_key: Option<String>,
/// Resolved node name at registration time (per-server override or global fallback).
/// After startup, the active node_name is read from `dynamic` (may be updated remotely).
#[allow(dead_code)]

View File

@@ -17,6 +17,10 @@ use crate::egress_proxy::{
};
use crate::state::{AppState, ServerContext};
use aether_contracts::tunnel::{CURRENT_TUNNEL_PROTOCOL_VERSION, TUNNEL_PROTOCOL_VERSION_HEADER};
use aether_contracts::tunnel_security::{
SecureFrameCodec, TunnelSecurityRole, TUNNEL_SECURITY_HEADER, TUNNEL_SECURITY_NON_TLS_REQUIRED,
TUNNEL_SECURITY_SESSION_HEADER,
};
use super::{dispatcher, heartbeat, writer};
@@ -45,16 +49,28 @@ pub async fn connect_and_run(
// Build WebSocket request with auth headers
let mut request = ws_url.clone().into_client_request()?;
let headers = request.headers_mut();
headers.insert(
"Authorization",
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
);
if server.tunnel_security != crate::config::TunnelSecurity::NonTlsRequired {
headers.insert(
"Authorization",
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
);
}
headers.insert(
TUNNEL_PROTOCOL_VERSION_HEADER,
http::HeaderValue::from_str(&CURRENT_TUNNEL_PROTOCOL_VERSION.to_string())?,
);
let node_id = server.node_id.read().unwrap().clone();
headers.insert("X-Node-Id", http::HeaderValue::from_str(&node_id)?);
if server.tunnel_security == crate::config::TunnelSecurity::NonTlsRequired {
headers.insert(
TUNNEL_SECURITY_HEADER,
http::HeaderValue::from_static(TUNNEL_SECURITY_NON_TLS_REQUIRED),
);
headers.insert(
TUNNEL_SECURITY_SESSION_HEADER,
http::HeaderValue::from_str(&node_id)?,
);
}
// Use dynamic node_name (may be updated by remote config) instead of
// the static server.node_name, so that remote name changes take effect
// on the next reconnect.
@@ -119,6 +135,19 @@ pub async fn connect_and_run(
handshake_timeout.as_millis()
)
})??;
let security = if server.tunnel_security == crate::config::TunnelSecurity::NonTlsRequired {
let key = server
.tunnel_encryption_key
.as_deref()
.ok_or_else(|| anyhow::anyhow!("secure tunnel requires tunnel_encryption_key"))?;
Some(Arc::new(SecureFrameCodec::new(
key,
&node_id,
TunnelSecurityRole::Client,
)?))
} else {
None
};
let stale_timeout = state
.config
.tunnel_stale_timeout()
@@ -146,10 +175,11 @@ pub async fn connect_and_run(
let (ws_sink, ws_read) = futures_util::StreamExt::split(ws_stream);
// Spawn writer task (with WebSocket ping keepalive)
let (frame_tx, mut writer_handle) = writer::spawn_writer_with_metrics(
let (frame_tx, mut writer_handle) = writer::spawn_writer_with_metrics_and_security(
ws_sink,
ping_interval,
Some(Arc::clone(&server.tunnel_metrics)),
security.clone(),
);
let drain_signal = spawn_drain_signal(conn_idx, frame_tx.clone(), drain.clone());
@@ -174,13 +204,14 @@ pub async fn connect_and_run(
let state_clone = Arc::clone(state);
let server_clone = Arc::clone(server);
let outcome = tokio::select! {
result = dispatcher::run(
result = dispatcher::run_with_security(
state_clone,
server_clone,
ws_read,
frame_tx.clone(),
hb_handle,
drain.clone(),
security.clone(),
) => {
match result {
Ok(()) => Ok(TunnelOutcome::Disconnected),

View File

@@ -18,6 +18,7 @@ use super::heartbeat::HeartbeatHandle;
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
use super::stream_handler;
use super::writer::FrameSender;
use aether_contracts::tunnel_security::SecureFrameCodec;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StreamDispatchStatus {
@@ -27,13 +28,32 @@ enum StreamDispatchStatus {
}
/// Run the dispatcher loop, reading from the WebSocket stream.
#[allow(dead_code)]
pub async fn run<S>(
state: Arc<AppState>,
server: Arc<ServerContext>,
ws_stream: S,
frame_tx: FrameSender,
heartbeat: HeartbeatHandle,
drain: watch::Receiver<bool>,
) -> Result<(), anyhow::Error>
where
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
+ Unpin
+ Send
+ 'static,
{
run_with_security(state, server, ws_stream, frame_tx, heartbeat, drain, None).await
}
pub async fn run_with_security<S>(
state: Arc<AppState>,
server: Arc<ServerContext>,
mut ws_stream: S,
frame_tx: FrameSender,
heartbeat: HeartbeatHandle,
mut drain: watch::Receiver<bool>,
security: Option<Arc<SecureFrameCodec>>,
) -> Result<(), anyhow::Error>
where
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
@@ -130,6 +150,19 @@ where
continue;
}
};
let frame = match security.as_deref() {
Some(codec) => match codec.decrypt_frame(frame) {
Ok(frame) => frame,
Err(e) => {
warn!(error = %e, "failed to decrypt secure tunnel frame");
server
.tunnel_metrics
.record_error("secure_frame_decrypt_error", &e.to_string());
break None;
}
},
None => frame,
};
match frame.msg_type {
MsgType::RequestHeaders => {

View File

@@ -425,6 +425,8 @@ mod tests {
server_label: "heartbeat-test".to_string(),
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
tunnel_security: config.tunnel_security,
tunnel_encryption_key: config.tunnel_encryption_key.clone(),
node_name: config.node_name.clone(),
node_id: Arc::new(RwLock::new("node-123".to_string())),
aether_client: Arc::new(AetherClient::new(

View File

@@ -476,6 +476,8 @@ mod tests {
server_label: "gateway-owned-tunnel".to_string(),
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
tunnel_security: config.tunnel_security,
tunnel_encryption_key: config.tunnel_encryption_key.clone(),
node_name: config.node_name.clone(),
node_id: Arc::new(std::sync::RwLock::new(node_id.to_string())),
aether_client: Arc::new(AetherClient::new(

View File

@@ -2491,6 +2491,8 @@ mod tests {
server_label: "server".to_string(),
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
tunnel_security: config.tunnel_security,
tunnel_encryption_key: config.tunnel_encryption_key.clone(),
node_name: config.node_name.clone(),
node_id: Arc::new(std::sync::RwLock::new("node-1".to_string())),
aether_client: Arc::new(AetherClient::new(

View File

@@ -20,6 +20,7 @@ use tracing::{debug, error, trace};
use crate::state::TunnelMetrics;
use super::protocol::Frame;
use aether_contracts::tunnel_security::SecureFrameCodec;
const HIGH_PRIORITY_QUEUE_CAPACITY: usize = 64;
const NORMAL_PRIORITY_QUEUE_CAPACITY: usize = 256;
@@ -89,10 +90,23 @@ where
}
/// Spawn the writer task with optional tunnel metrics instrumentation.
#[allow(dead_code)]
pub fn spawn_writer_with_metrics<S>(
sink: S,
ping_interval: Duration,
tunnel_metrics: Option<Arc<TunnelMetrics>>,
) -> (FrameSender, JoinHandle<()>)
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
{
spawn_writer_with_metrics_and_security(sink, ping_interval, tunnel_metrics, None)
}
pub fn spawn_writer_with_metrics_and_security<S>(
mut sink: S,
ping_interval: Duration,
tunnel_metrics: Option<Arc<TunnelMetrics>>,
security: Option<Arc<SecureFrameCodec>>,
) -> (FrameSender, JoinHandle<()>)
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
@@ -109,7 +123,14 @@ where
loop {
if let Ok(frame) = high_rx.try_recv() {
if !write_frame(&mut sink, frame, tunnel_metrics.as_deref()).await {
if !write_frame(
&mut sink,
frame,
tunnel_metrics.as_deref(),
security.as_deref(),
)
.await
{
break;
}
continue;
@@ -123,7 +144,7 @@ where
frame = high_rx.recv(), if high_open => {
match frame {
Some(frame) => {
if !write_frame(&mut sink, frame, tunnel_metrics.as_deref()).await {
if !write_frame(&mut sink, frame, tunnel_metrics.as_deref(), security.as_deref()).await {
break;
}
}
@@ -143,7 +164,7 @@ where
frame = normal_rx.recv(), if normal_open => {
match frame {
Some(frame) => {
if !write_frame(&mut sink, frame, tunnel_metrics.as_deref()).await {
if !write_frame(&mut sink, frame, tunnel_metrics.as_deref(), security.as_deref()).await {
break;
}
}
@@ -175,14 +196,31 @@ fn classify_frame_priority(frame: &Frame) -> FramePriority {
}
}
async fn write_frame<S>(sink: &mut S, frame: Frame, tunnel_metrics: Option<&TunnelMetrics>) -> bool
async fn write_frame<S>(
sink: &mut S,
frame: Frame,
tunnel_metrics: Option<&TunnelMetrics>,
security: Option<&SecureFrameCodec>,
) -> bool
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
{
let stream_id = frame.stream_id;
let msg_type = frame.msg_type;
let flags = frame.flags;
let data = frame.encode();
let data = match security {
Some(codec) => match codec.encrypt_frame(frame) {
Ok(data) => data,
Err(e) => {
error!(error = %e, "failed to encrypt tunnel frame");
if let Some(metrics) = tunnel_metrics {
metrics.record_error("secure_frame_encrypt_error", &e.to_string());
}
return false;
}
},
None => frame.encode(),
};
let wire_len = data.len().max(HEADER_SIZE);
if let Err(e) = sink.send(Message::Binary(data.into())).await {
error!(