Merge remote-tracking branch 'origin/pr/535'

This commit is contained in:
fawney19
2026-05-23 13:00:18 +08:00
34 changed files with 1173 additions and 87 deletions

5
Cargo.lock generated
View File

@@ -123,10 +123,14 @@ version = "0.1.0"
name = "aether-contracts"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64 0.22.1",
"bytes",
"flate2",
"hmac",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
]
@@ -511,6 +515,7 @@ dependencies = [
"tower-service",
"tracing",
"url",
"uuid",
"webpki-roots 0.26.11",
]

View File

@@ -67,6 +67,7 @@ aether-http = { path = "crates/aether-http" }
aether-runtime = { path = "crates/aether-runtime" }
aether-testkit = { path = "crates/aether-testkit" }
aes = "0.8"
aes-gcm = "0.10"
async-stream = "0.3"
async-trait = "0.1"
axum = "0.8"

View File

@@ -31,7 +31,7 @@ aether-task-runtime.workspace = true
aether-usage-runtime.workspace = true
aether-video-tasks-core.workspace = true
aether-wallet.workspace = true
aes-gcm = "0.10"
aes-gcm.workspace = true
async-stream.workspace = true
async-trait.workspace = true
axum = { version = "0.8", features = ["ws"] }

View File

@@ -26,7 +26,6 @@ pub(crate) fn mount_public_support_routes(router: Router<AppState>) -> Router<Ap
.route("/api/capabilities/model/{*model_path}", get(proxy_request))
.route("/install/{*install_path}", get(proxy_request))
.route("/install-tunnel/{*install_path}", get(proxy_request))
.route("/install-proxy/{*install_path}", get(proxy_request))
.route("/i/{*install_path}", get(proxy_request))
.route("/", get(proxy_request))
}

View File

@@ -797,7 +797,6 @@ pub(super) fn classify_public_support_route(
} else if method == http::Method::GET
&& (has_single_segment_after_prefix(normalized_path, "/install/")
|| has_single_segment_after_prefix(normalized_path, "/install-tunnel/")
|| has_single_segment_after_prefix(normalized_path, "/install-proxy/")
|| has_single_segment_after_prefix(normalized_path, "/i/"))
{
Some(classified(

View File

@@ -63,6 +63,10 @@ struct ProxyNodeRegisterRequest {
proxy_version: Option<String>,
#[serde(default)]
tunnel_mode: Option<bool>,
#[serde(default)]
tunnel_security: Option<String>,
#[serde(default)]
tunnel_encryption_key: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -349,9 +353,21 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
Ok(mutation) => mutation,
Err(response) => return Ok(Some(response)),
};
let tunnel_encryption_key = mutation
.proxy_metadata
.as_ref()
.and_then(|metadata| metadata.pointer("/tunnel_security/encryption_key"))
.and_then(|value| value.as_str())
.map(str::to_string);
let Some(node) = state.register_proxy_node(&mutation).await? else {
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
};
if let Some(key) = tunnel_encryption_key {
state
.app()
.tunnel
.register_secure_tunnel_key(node.id.clone(), key);
}
return Ok(Some(
Json(json!({
"node_id": node.id,
@@ -1420,12 +1436,43 @@ fn validate_register_request(
}
validate_optional_object(input.hardware_info.as_ref(), "hardware_info")?;
validate_optional_object(input.proxy_metadata.as_ref(), "proxy_metadata")?;
let tunnel_security =
normalize_optional_string(input.tunnel_security.as_deref(), "tunnel_security", 64)?;
let tunnel_encryption_key = normalize_optional_string(
input.tunnel_encryption_key.as_deref(),
"tunnel_encryption_key",
128,
)?;
let registered_by = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.map(|principal| principal.user_id.clone());
let mut proxy_metadata = input.proxy_metadata;
if tunnel_security.as_deref()
== Some(aether_contracts::tunnel_security::TUNNEL_SECURITY_NON_TLS_REQUIRED)
{
let key = tunnel_encryption_key.as_deref().ok_or_else(|| {
bad_request_response(
"tunnel_encryption_key is required when tunnel_security=non_tls_required",
)
})?;
aether_contracts::tunnel_security::decode_psk(key)
.map_err(|err| bad_request_response(err.to_string()))?;
let mut metadata = proxy_metadata
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
metadata.insert(
"tunnel_security".to_string(),
json!({
"mode": aether_contracts::tunnel_security::TUNNEL_SECURITY_NON_TLS_REQUIRED,
"encryption_key": key,
}),
);
proxy_metadata = Some(Value::Object(metadata));
}
Ok(
aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation {
name,
@@ -1438,7 +1485,7 @@ fn validate_register_request(
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_metadata,
proxy_version: normalize_optional_string(
input.proxy_version.as_deref(),
"proxy_version",

View File

@@ -16,7 +16,7 @@ const INSTALL_SESSION_TTL_SECS: u64 = 15 * 60;
const INSTALL_SESSION_KEY_PREFIX: &str = "install:session:";
const TUNNEL_INSTALL_SESSION_KEY_PREFIX: &str = "tunnel-install:session:";
const TUNNEL_INSTALL_UNIX_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh";
"https://raw.githubusercontent.com/fawney19/Aether/refs/heads/main/apps/aether-tunnel/install.sh";
const TUNNEL_INSTALL_POWERSHELL_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1";
@@ -59,6 +59,8 @@ struct StoredTunnelInstallSession {
aether_url: String,
management_token: String,
node_name: String,
tunnel_security: String,
tunnel_encryption_key: String,
expires_at_unix_secs: u64,
}
@@ -93,8 +95,7 @@ fn install_code_from_path(request_path: &str) -> Option<(String, bool)> {
fn tunnel_install_code_from_path(request_path: &str) -> Option<(String, bool)> {
let raw = request_path
.strip_prefix("/install-tunnel/")
.or_else(|| request_path.strip_prefix("/install-proxy/"))?
.strip_prefix("/install-tunnel/")?
.trim()
.trim_matches('/');
if raw.is_empty() || raw.contains('/') {
@@ -122,6 +123,17 @@ fn generate_install_code() -> String {
.collect()
}
fn generate_tunnel_encryption_key() -> String {
use base64::Engine;
let first = uuid::Uuid::new_v4();
let second = uuid::Uuid::new_v4();
let mut key = [0_u8; 32];
key[..16].copy_from_slice(first.as_bytes());
key[16..].copy_from_slice(second.as_bytes());
base64::engine::general_purpose::STANDARD.encode(key)
}
fn unix_secs_now() -> u64 {
chrono::Utc::now().timestamp().max(0) as u64
}
@@ -172,6 +184,8 @@ set -eu
export AETHER_TUNNEL_AETHER_URL={aether_url}
export AETHER_TUNNEL_MANAGEMENT_TOKEN={management_token}
export AETHER_TUNNEL_NODE_NAME={node_name}
export AETHER_TUNNEL_SECURITY={tunnel_security}
export AETHER_TUNNEL_ENCRYPTION_KEY={tunnel_encryption_key}
if command -v curl >/dev/null 2>&1; then
curl -fsSL {script_url} | sh
@@ -185,6 +199,8 @@ fi
aether_url = shell_single_quote(&session.aether_url),
management_token = shell_single_quote(&session.management_token),
node_name = shell_single_quote(&session.node_name),
tunnel_security = shell_single_quote(&session.tunnel_security),
tunnel_encryption_key = shell_single_quote(&session.tunnel_encryption_key),
script_url = shell_single_quote(TUNNEL_INSTALL_UNIX_SCRIPT_URL),
)
}
@@ -195,11 +211,15 @@ fn build_tunnel_powershell_script(session: &StoredTunnelInstallSession) -> Strin
$env:AETHER_TUNNEL_AETHER_URL = {aether_url}
$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = {management_token}
$env:AETHER_TUNNEL_NODE_NAME = {node_name}
$env:AETHER_TUNNEL_SECURITY = {tunnel_security}
$env:AETHER_TUNNEL_ENCRYPTION_KEY = {tunnel_encryption_key}
irm {script_url} | iex
"###,
aether_url = powershell_single_quote(&session.aether_url),
management_token = powershell_single_quote(&session.management_token),
node_name = powershell_single_quote(&session.node_name),
tunnel_security = powershell_single_quote(&session.tunnel_security),
tunnel_encryption_key = powershell_single_quote(&session.tunnel_encryption_key),
script_url = powershell_single_quote(TUNNEL_INSTALL_POWERSHELL_SCRIPT_URL),
)
}
@@ -664,6 +684,8 @@ pub(crate) async fn build_proxy_node_install_session_response(
aether_url: base_url_from_request(headers, request_context),
management_token,
node_name,
tunnel_security: "non_tls_required".to_string(),
tunnel_encryption_key: generate_tunnel_encryption_key(),
expires_at_unix_secs,
};
let serialized = match serde_json::to_string(&session) {
@@ -712,9 +734,7 @@ pub(super) async fn maybe_build_local_install_response(
if decision.route_family.as_deref() != Some("install") {
return None;
}
if request_context.request_path.starts_with("/install-tunnel/")
|| request_context.request_path.starts_with("/install-proxy/")
{
if request_context.request_path.starts_with("/install-tunnel/") {
return Some(maybe_build_local_tunnel_install_response(state, request_context).await);
}
let Some((code, wants_powershell)) = install_code_from_path(&request_context.request_path)
@@ -893,6 +913,8 @@ mod tests {
aether_url: "https://aether.example".to_string(),
management_token: "ae-test-token".to_string(),
node_name: "jp-proxy-01".to_string(),
tunnel_security: "non_tls_required".to_string(),
tunnel_encryption_key: "base64-32-bytes".to_string(),
expires_at_unix_secs: u64::MAX,
}
}
@@ -907,10 +929,6 @@ mod tests {
tunnel_install_code_from_path("/install-tunnel/abc123.ps1"),
Some(("abc123".to_string(), true))
);
assert_eq!(
tunnel_install_code_from_path("/install-proxy/abc123"),
Some(("abc123".to_string(), false))
);
assert_eq!(tunnel_install_code_from_path("/install-tunnel/a/b"), None);
}
@@ -921,8 +939,10 @@ mod tests {
assert!(script.contains("export AETHER_TUNNEL_AETHER_URL='https://aether.example'"));
assert!(script.contains("export AETHER_TUNNEL_MANAGEMENT_TOKEN='ae-test-token'"));
assert!(script.contains("export AETHER_TUNNEL_NODE_NAME='jp-proxy-01'"));
assert!(script.contains("export AETHER_TUNNEL_SECURITY='non_tls_required'"));
assert!(script.contains("export AETHER_TUNNEL_ENCRYPTION_KEY='base64-32-bytes'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh"
"https://raw.githubusercontent.com/fawney19/Aether/refs/heads/main/apps/aether-tunnel/install.sh"
));
assert!(!script.contains("aether-rust-pioneer"));
assert!(!script.contains("[[servers]]"));
@@ -935,6 +955,8 @@ mod tests {
assert!(script.contains("$env:AETHER_TUNNEL_AETHER_URL = 'https://aether.example'"));
assert!(script.contains("$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = 'ae-test-token'"));
assert!(script.contains("$env:AETHER_TUNNEL_NODE_NAME = 'jp-proxy-01'"));
assert!(script.contains("$env:AETHER_TUNNEL_SECURITY = 'non_tls_required'"));
assert!(script.contains("$env:AETHER_TUNNEL_ENCRYPTION_KEY = 'base64-32-bytes'"));
assert!(script.contains(
"https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1"
));

View File

@@ -90,7 +90,6 @@ fn frontend_path_bypasses_static(path: &str) -> bool {
|| path.starts_with("/.well-known/")
|| path.starts_with("/install/")
|| path.starts_with("/install-tunnel/")
|| path.starts_with("/install-proxy/")
|| path.starts_with("/i/")
}

View File

@@ -17,6 +17,7 @@ use axum::http::HeaderMap;
use axum::response::{IntoResponse, Json};
use axum::routing::{get, post};
use axum::Router;
use dashmap::DashMap;
use tracing::warn;
use crate::{data::GatewayDataState, middleware};
@@ -34,6 +35,7 @@ pub struct AppState {
data: Arc<GatewayDataState>,
request_gate: Option<Arc<ConcurrencyGate>>,
distributed_request_gate: Option<Arc<RuntimeSemaphore>>,
secure_tunnel_keys: Arc<DashMap<String, String>>,
}
#[derive(Debug)]
@@ -55,9 +57,48 @@ impl AppState {
data: Arc::new(GatewayDataState::disabled()),
request_gate: None,
distributed_request_gate: None,
secure_tunnel_keys: Arc::new(DashMap::new()),
}
}
pub(crate) fn register_secure_tunnel_key(
&self,
node_id: impl Into<String>,
key: impl Into<String>,
) {
self.secure_tunnel_keys.insert(node_id.into(), key.into());
}
pub(crate) fn secure_tunnel_key(&self, node_id: &str) -> Option<String> {
self.secure_tunnel_keys
.get(node_id)
.map(|entry| entry.value().clone())
}
async fn secure_tunnel_key_for_node(&self, node_id: &str) -> Option<String> {
if let Some(key) = self.secure_tunnel_key(node_id) {
return Some(key);
}
let key = self
.data
.find_proxy_node(node_id)
.await
.ok()
.flatten()
.and_then(|node| {
node.proxy_metadata.and_then(|metadata| {
metadata
.pointer("/tunnel_security/encryption_key")
.and_then(|value| value.as_str())
.map(str::to_string)
})
});
if let Some(key) = key.as_ref() {
self.register_secure_tunnel_key(node_id.to_string(), key.clone());
}
key
}
pub(crate) fn with_data(mut self, data: Arc<GatewayDataState>) -> Self {
self.data = data;
self
@@ -212,11 +253,47 @@ pub async fn ws_proxy(
let max_streams = resolve_proxy_max_streams(&headers, state.max_streams);
let protocol_version = resolve_proxy_protocol_version(&headers);
let tunnel_security = headers
.get(aether_contracts::tunnel_security::TUNNEL_SECURITY_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
let security_session = headers
.get(aether_contracts::tunnel_security::TUNNEL_SECURITY_SESSION_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string);
if node_id.is_empty() {
warn!("proxy connection rejected: missing X-Node-ID header");
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
let stored_security_key = state.secure_tunnel_key_for_node(&node_id).await;
let (security_key, security_session) = match tunnel_security.as_deref() {
Some(aether_contracts::tunnel_security::TUNNEL_SECURITY_NON_TLS_REQUIRED) => {
match stored_security_key {
Some(key) => {
let Some(session) = security_session else {
warn!(node_id = %node_id, "secure tunnel requested without a security session");
return axum::http::StatusCode::BAD_REQUEST.into_response();
};
(Some(key), session)
}
None => {
warn!(node_id = %node_id, "secure tunnel requested but no PSK is registered");
return axum::http::StatusCode::UNAUTHORIZED.into_response();
}
}
}
Some(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(),
None if stored_security_key.is_some() => {
warn!(node_id = %node_id, "proxy connection rejected: stored secure tunnel key requires encrypted frames");
return axum::http::StatusCode::UNAUTHORIZED.into_response();
}
None => (None, String::new()),
};
let request_permit = match state.try_acquire_request_permit().await {
Ok(permit) => permit,
@@ -253,6 +330,8 @@ pub async fn ws_proxy(
node_name,
max_streams,
protocol_version,
security_key,
security_session,
state.proxy_conn_cfg,
)
.await

View File

@@ -13,6 +13,8 @@ use tracing::{debug, info, warn};
use super::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
use super::protocol;
use aether_contracts::tunnel::Frame;
use aether_contracts::tunnel_security::{SecureFrameCodec, TunnelSecurityRole};
/// Maximum single frame size: 64 MB
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
@@ -24,6 +26,8 @@ pub async fn handle_proxy_connection(
node_name: String,
max_streams: usize,
protocol_version: u8,
security_key: Option<String>,
security_session: String,
cfg: ConnConfig,
) {
let conn_id = hub.alloc_conn_id();
@@ -31,6 +35,18 @@ pub async fn handle_proxy_connection(
let (tx, mut rx) = bounded_queue::<Message>(cfg.outbound_queue_capacity);
let (close_tx, mut close_rx) = watch::channel(false);
let security = match security_key.as_deref() {
Some(key) => {
match SecureFrameCodec::new(key, &security_session, TunnelSecurityRole::Server) {
Ok(codec) => Some(Arc::new(codec)),
Err(error) => {
warn!(conn_id, node_id = %node_id, error = %error, "secure tunnel codec initialization failed");
return;
}
}
}
None => None,
};
let conn = Arc::new(ProxyConn::new(
conn_id,
@@ -46,6 +62,7 @@ pub async fn handle_proxy_connection(
let writer_conn_id = conn_id;
let writer_conn = conn.clone();
let writer_security = security.clone();
let writer = tokio::spawn(async move {
let mut frames_sent: u64 = 0;
loop {
@@ -58,6 +75,13 @@ pub async fn handle_proxy_connection(
_ => 0,
};
let send_started_at = std::time::Instant::now();
let msg = match encrypt_message(msg, writer_security.as_deref()) {
Ok(msg) => msg,
Err(error) => {
warn!(conn_id = writer_conn_id, error = %error, "failed to encrypt outbound proxy frame");
break;
}
};
let send_result = tokio::time::timeout(
Duration::from_secs(15),
ws_tx.send(msg),
@@ -194,7 +218,7 @@ pub async fn handle_proxy_connection(
let reader_hub = hub.clone();
let reader_conn = conn.clone();
let reader = tokio::spawn(async move {
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout, security).await;
});
let _ = reader.await;
@@ -223,6 +247,7 @@ async fn run_proxy_reader(
hub: Arc<HubRouter>,
conn: Arc<ProxyConn>,
idle_timeout: Duration,
security: Option<Arc<SecureFrameCodec>>,
) {
let idle_enabled = !idle_timeout.is_zero();
let mut oversized_count = 0u32;
@@ -245,7 +270,14 @@ async fn run_proxy_reader(
match msg {
Some(Ok(Message::Binary(data))) => {
frames_received += 1;
let mut data = data.to_vec();
let mut data = match decrypt_message(data, security.as_deref()) {
Ok(data) => data,
Err(error) => {
warn!(conn_id = conn.id, error = %error, "failed to decrypt secure proxy frame");
conn.request_close();
break;
}
};
if data.len() > MAX_FRAME_SIZE {
oversized_count += 1;
warn!(
@@ -294,3 +326,33 @@ async fn run_proxy_reader(
}
}
}
fn encrypt_message(
msg: Message,
security: Option<&SecureFrameCodec>,
) -> Result<Message, aether_contracts::tunnel_security::TunnelSecurityError> {
let Some(codec) = security else {
return Ok(msg);
};
match msg {
Message::Binary(data) => {
let frame = Frame::decode(bytes::Bytes::from(data.to_vec()))
.map_err(|_| aether_contracts::tunnel_security::TunnelSecurityError::Encrypt)?;
Ok(Message::Binary(codec.encrypt_frame(frame)?))
}
other => Ok(other),
}
}
fn decrypt_message(
data: bytes::Bytes,
security: Option<&SecureFrameCodec>,
) -> Result<Vec<u8>, aether_contracts::tunnel_security::TunnelSecurityError> {
let Some(codec) = security else {
return Ok(data.to_vec());
};
let frame = Frame::decode(data)
.map_err(|_| aether_contracts::tunnel_security::TunnelSecurityError::Decrypt)?;
let frame = codec.decrypt_frame(frame)?;
Ok(frame.encode().to_vec())
}

View File

@@ -455,6 +455,14 @@ impl EmbeddedTunnelState {
self.inner.clone()
}
pub(crate) fn register_secure_tunnel_key(
&self,
node_id: impl Into<String>,
key: impl Into<String>,
) {
self.inner.register_secure_tunnel_key(node_id, key);
}
pub(crate) fn has_local_proxy(&self, node_id: &str) -> bool {
self.inner.hub.has_local_proxy(node_id)
}

View File

@@ -1,13 +0,0 @@
$ErrorActionPreference = 'Stop'
if ($env:AETHER_PROXY_AETHER_URL -and -not $env:AETHER_TUNNEL_AETHER_URL) {
$env:AETHER_TUNNEL_AETHER_URL = $env:AETHER_PROXY_AETHER_URL
}
if ($env:AETHER_PROXY_MANAGEMENT_TOKEN -and -not $env:AETHER_TUNNEL_MANAGEMENT_TOKEN) {
$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = $env:AETHER_PROXY_MANAGEMENT_TOKEN
}
if ($env:AETHER_PROXY_NODE_NAME -and -not $env:AETHER_TUNNEL_NODE_NAME) {
$env:AETHER_TUNNEL_NODE_NAME = $env:AETHER_PROXY_NODE_NAME
}
irm 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1' | iex

View File

@@ -1,21 +0,0 @@
#!/bin/sh
set -eu
if [ -n "${AETHER_PROXY_AETHER_URL:-}" ] && [ -z "${AETHER_TUNNEL_AETHER_URL:-}" ]; then
export AETHER_TUNNEL_AETHER_URL="${AETHER_PROXY_AETHER_URL}"
fi
if [ -n "${AETHER_PROXY_MANAGEMENT_TOKEN:-}" ] && [ -z "${AETHER_TUNNEL_MANAGEMENT_TOKEN:-}" ]; then
export AETHER_TUNNEL_MANAGEMENT_TOKEN="${AETHER_PROXY_MANAGEMENT_TOKEN}"
fi
if [ -n "${AETHER_PROXY_NODE_NAME:-}" ] && [ -z "${AETHER_TUNNEL_NODE_NAME:-}" ]; then
export AETHER_TUNNEL_NODE_NAME="${AETHER_PROXY_NODE_NAME}"
fi
if command -v curl >/dev/null 2>&1; then
curl -fsSL 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh' | sh
elif command -v wget >/dev/null 2>&1; then
wget -qO- 'https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.sh' | sh
else
printf '%s\n' "[Aether Tunnel] 需要 curl 或 wget 下载安装脚本" >&2
exit 1
fi

View File

@@ -7,6 +7,10 @@ AETHER_TUNNEL_MANAGEMENT_TOKEN=ae_xxxxx
# Node identification
AETHER_TUNNEL_NODE_NAME=jp-proxy-01
# Secure non-TLS tunnel. Set non_tls_required with a key to enable secure tunnel.
AETHER_TUNNEL_SECURITY=off
# AETHER_TUNNEL_ENCRYPTION_KEY=base64-32-bytes
# Maximum request body buffered for 307/308 replay (supports K/M/G, 0 disables body replay buffering)
AETHER_TUNNEL_REDIRECT_REPLAY_BUDGET_BYTES=5M

View File

@@ -41,6 +41,7 @@ tar = "0.4"
socket2 = { version = "0.5", features = ["all"] }
tower-service = "0.3"
webpki-roots = "0.26"
uuid.workspace = true
[dev-dependencies]
aether-gateway.workspace = true

View File

@@ -53,6 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tu
AETHER_TUNNEL_AETHER_URL="https://aether.example.com" \
AETHER_TUNNEL_MANAGEMENT_TOKEN="ae_xxx" \
AETHER_TUNNEL_NODE_NAME="jp-proxy-01" \
AETHER_TUNNEL_SECURITY="off" \
sh
```
@@ -60,6 +61,7 @@ curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tu
$env:AETHER_TUNNEL_AETHER_URL = "https://aether.example.com"
$env:AETHER_TUNNEL_MANAGEMENT_TOKEN = "ae_xxx"
$env:AETHER_TUNNEL_NODE_NAME = "jp-proxy-01"
$env:AETHER_TUNNEL_SECURITY = "off"
irm https://raw.githubusercontent.com/fawney19/Aether/main/apps/aether-tunnel/install.ps1 | iex
```
@@ -111,6 +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 通道安全模式;支持 `off` / `non_tls_required`;在 `[[servers]]` 中省略该字段且 `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` | 心跳间隔(秒) |
@@ -216,13 +220,19 @@ tunnel 会在心跳兼容字段 `proxy_metadata` 中主动上报隧道稳定性
aether_url = "https://aether-1.example.com"
management_token = "ae_xxx"
node_name = "jp-proxy-01"
tunnel_security = "off"
[[servers]]
aether_url = "https://aether-2.example.com"
aether_url = "http://aether-2.example.com"
management_token = "ae_yyy"
node_name = "jp-proxy-02"
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` 时运行时会自动按 `non_tls_required` 生效;显式配置 `tunnel_security = "off"` 会关闭该自动推断。secure tunnel 会在 WebSocket tunnel 上加密所有二进制 tunnel frame未配置 key 或显式关闭的旧节点仍按原明文协议工作。
## 发布新版本
推送 `tunnel-v*` 格式的 tagGitHub Actions 会自动:

View File

@@ -105,7 +105,7 @@ function Test-ServerExists([string]$Path, [string]$QuotedUrl, [string]$QuotedNam
return ($FoundUrl -and $FoundName)
}
function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]$NodeName) {
function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]$NodeName, [string]$TunnelSecurity, [string]$TunnelEncryptionKey) {
$ConfigDir = Split-Path -Parent $script:ConfigPath
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
@@ -116,6 +116,7 @@ function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]
$QuotedUrl = ConvertTo-TomlQuotedString $AetherUrl
$QuotedToken = ConvertTo-TomlQuotedString $ManagementToken
$QuotedName = ConvertTo-TomlQuotedString $NodeName
$QuotedTunnelEncryptionKey = ConvertTo-TomlQuotedString $TunnelEncryptionKey
if (Test-ServerExists $script:ConfigPath $QuotedUrl $QuotedName) {
Say "Same aether_url + node_name already exists, skipping config append: $script:ConfigPath"
@@ -134,6 +135,13 @@ function Add-ServerConfig([string]$AetherUrl, [string]$ManagementToken, [string]
"management_token = $QuotedToken",
"node_name = $QuotedName"
) -join "`n"
if ($TunnelSecurity) {
$QuotedTunnelSecurity = ConvertTo-TomlQuotedString $TunnelSecurity
$Block += "`ntunnel_security = $QuotedTunnelSecurity"
}
if ($TunnelEncryptionKey) {
$Block += "`ntunnel_encryption_key = $QuotedTunnelEncryptionKey"
}
Add-Content -Path $script:ConfigPath -Value ($Block + "`n") -Encoding UTF8
Say "Appended [[servers]] to: $script:ConfigPath"
}
@@ -143,13 +151,21 @@ function Main {
$AetherUrl = Prompt-IfEmpty 'AETHER_TUNNEL_AETHER_URL' $env:AETHER_TUNNEL_AETHER_URL 'Aether URL'
$ManagementToken = Prompt-IfEmpty 'AETHER_TUNNEL_MANAGEMENT_TOKEN' $env:AETHER_TUNNEL_MANAGEMENT_TOKEN 'Management token (ae_xxx)'
$NodeName = Prompt-IfEmpty 'AETHER_TUNNEL_NODE_NAME' $env:AETHER_TUNNEL_NODE_NAME 'Node name'
$TunnelSecurity = if ($env:AETHER_TUNNEL_SECURITY) { $env:AETHER_TUNNEL_SECURITY } else { '' }
$TunnelEncryptionKey = if ($env:AETHER_TUNNEL_ENCRYPTION_KEY) { $env:AETHER_TUNNEL_ENCRYPTION_KEY } else { '' }
if ($TunnelSecurity -and ($TunnelSecurity -notin @('off', 'non_tls_required'))) {
Fail 'AETHER_TUNNEL_SECURITY must be off or non_tls_required'
}
if (($TunnelSecurity -eq 'non_tls_required') -and -not $TunnelEncryptionKey) {
Fail 'AETHER_TUNNEL_ENCRYPTION_KEY is required when AETHER_TUNNEL_SECURITY=non_tls_required'
}
$TempDir = Join-Path ([IO.Path]::GetTempPath()) ("aether-tunnel-" + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
try {
$Tag = Resolve-LatestTunnelTag
Install-AetherTunnelBinary $Tag $TempDir
Add-ServerConfig $AetherUrl $ManagementToken $NodeName
Add-ServerConfig $AetherUrl $ManagementToken $NodeName $TunnelSecurity $TunnelEncryptionKey
} finally {
Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue
}

View File

@@ -186,11 +186,17 @@ append_server_config() {
aether_url="$1"
management_token="$2"
node_name="$3"
tunnel_security="$4"
tunnel_encryption_key="$5"
mkdir -p "$(dirname "$CONFIG_PATH")"
quoted_url=$(toml_quote "$aether_url")
quoted_token=$(toml_quote "$management_token")
quoted_name=$(toml_quote "$node_name")
quoted_encryption_key=$(toml_quote "$tunnel_encryption_key")
if [ -n "$tunnel_security" ]; then
quoted_security=$(toml_quote "$tunnel_security")
fi
if has_legacy_single_server_keys; then
fail "现有配置仍使用旧的顶层 aether_url/management_token请先运行 aether-tunnel setup 迁移为 [[servers]] 后重试:$CONFIG_PATH"
@@ -214,6 +220,12 @@ append_server_config() {
printf 'aether_url = %s\n' "$quoted_url"
printf 'management_token = %s\n' "$quoted_token"
printf 'node_name = %s\n' "$quoted_name"
if [ -n "$tunnel_security" ]; then
printf 'tunnel_security = %s\n' "$quoted_security"
fi
if [ -n "$tunnel_encryption_key" ]; then
printf 'tunnel_encryption_key = %s\n' "$quoted_encryption_key"
fi
} >> "$CONFIG_PATH"
chmod 600 "$CONFIG_PATH" 2>/dev/null || true
say "已追加 [[servers]] 到:$CONFIG_PATH"
@@ -227,12 +239,21 @@ main() {
aether_url=$(prompt_if_empty AETHER_TUNNEL_AETHER_URL "${AETHER_TUNNEL_AETHER_URL:-}" "Aether URL: ")
management_token=$(prompt_if_empty AETHER_TUNNEL_MANAGEMENT_TOKEN "${AETHER_TUNNEL_MANAGEMENT_TOKEN:-}" "Management token (ae_xxx): ")
node_name=$(prompt_if_empty AETHER_TUNNEL_NODE_NAME "${AETHER_TUNNEL_NODE_NAME:-}" "Node name: ")
tunnel_security="${AETHER_TUNNEL_SECURITY:-}"
tunnel_encryption_key="${AETHER_TUNNEL_ENCRYPTION_KEY:-}"
case "$tunnel_security" in
""|off|non_tls_required) ;;
*) fail "AETHER_TUNNEL_SECURITY 必须是 off 或 non_tls_required" ;;
esac
if [ "$tunnel_security" = "non_tls_required" ] && [ -z "$tunnel_encryption_key" ]; then
fail "AETHER_TUNNEL_SECURITY=non_tls_required 时必须设置 AETHER_TUNNEL_ENCRYPTION_KEY"
fi
tag=$(resolve_latest_tunnel_tag)
[ -n "$tag" ] || fail "没有找到可用的 tunnel-v* release"
asset=$(detect_asset)
install_binary "$tag" "$asset"
append_server_config "$aether_url" "$management_token" "$node_name"
append_server_config "$aether_url" "$management_token" "$node_name" "$tunnel_security" "$tunnel_encryption_key"
say "完成。运行以下命令启动/配置服务:"
say " $INSTALL_DIR/aether-tunnel setup $CONFIG_PATH"

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,
@@ -987,6 +1018,8 @@ mod tests {
aether_url: gateway_base_url.clone(),
management_token: "token".to_string(),
node_name: Some("node-recovery".to_string()),
tunnel_security: None,
tunnel_encryption_key: None,
},
)];
let (shutdown_tx, shutdown_rx) = watch::channel(false);
@@ -1289,6 +1322,8 @@ mod tests {
aether_url: state.config.aether_url.clone(),
management_token: state.config.management_token.clone(),
node_name: Some(state.config.node_name.clone()),
tunnel_security: Some(state.config.tunnel_security),
tunnel_encryption_key: state.config.tunnel_encryption_key.clone(),
};
let client = Arc::new(AetherClient::new(
&state.config,
@@ -1311,6 +1346,8 @@ mod tests {
management_token: "token".to_string(),
public_ip: None,
node_name: "tunnel-test".to_string(),
tunnel_security: crate::config::TunnelSecurity::Off,
tunnel_encryption_key: None,
node_region: None,
heartbeat_interval: 1,
allowed_ports: vec![80, 443],

View File

@@ -1,6 +1,7 @@
use std::fmt;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use aether_runtime::{FileLoggingConfig, LogDestination, LogRotation, ServiceRuntimeConfig};
@@ -247,6 +248,63 @@ impl From<TunnelLogRotationArg> for LogRotation {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TunnelSecurity {
Off,
NonTlsRequired,
}
impl fmt::Display for TunnelSecurity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
TunnelSecurity::Off => "off",
TunnelSecurity::NonTlsRequired => "non_tls_required",
})
}
}
impl FromStr for TunnelSecurity {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim() {
"off" => Ok(Self::Off),
"non_tls_required" | "non-tls-required" => Ok(Self::NonTlsRequired),
other => Err(format!(
"invalid tunnel_security {other:?}; expected off or non_tls_required"
)),
}
}
}
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 {
match configured {
Some(TunnelSecurity::NonTlsRequired) => return TunnelSecurity::NonTlsRequired,
Some(TunnelSecurity::Off) => return TunnelSecurity::Off,
None => {}
}
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
@@ -271,6 +329,18 @@ pub struct Config {
#[arg(long, env = "AETHER_TUNNEL_NODE_NAME")]
pub node_name: String,
/// Application-layer tunnel security mode.
#[arg(
long,
env = "AETHER_TUNNEL_SECURITY",
default_value_t = TunnelSecurity::Off
)]
pub tunnel_security: TunnelSecurity,
/// Base64-encoded 32-byte PSK used when tunnel_security=non_tls_required.
#[arg(long, env = "AETHER_TUNNEL_ENCRYPTION_KEY")]
pub tunnel_encryption_key: Option<String>,
/// Region label (e.g. ap-northeast-1)
#[arg(long, env = "AETHER_TUNNEL_NODE_REGION")]
pub node_region: Option<String>,
@@ -670,6 +740,14 @@ impl Config {
if self.node_name.trim().is_empty() {
anyhow::bail!("node_name must not be empty");
}
if self.tunnel_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 {
anyhow::bail!("allowed_ports: port 0 is not valid");
@@ -896,6 +974,12 @@ pub struct ServerEntry {
pub management_token: String,
/// Per-server node name override. Falls back to the global `node_name`.
pub node_name: Option<String>,
/// Per-server tunnel security mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_security: Option<TunnelSecurity>,
/// Per-server PSK for secure non-TLS tunnel handshakes.
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_encryption_key: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -1066,6 +1150,9 @@ impl ConfigFile {
let first_server = self.servers.first();
let aether_url = first_server.map(|s| s.aether_url.as_str());
let management_token = first_server.map(|s| s.management_token.as_str());
let tunnel_security =
first_server.map(|s| s.tunnel_security.unwrap_or(TunnelSecurity::Off));
let tunnel_encryption_key = first_server.and_then(|s| s.tunnel_encryption_key.as_deref());
let node_name = self
.node_name
.as_deref()
@@ -1073,6 +1160,8 @@ impl ConfigFile {
set!("AETHER_TUNNEL_AETHER_URL", aether_url);
set!("AETHER_TUNNEL_MANAGEMENT_TOKEN", management_token);
set!("AETHER_TUNNEL_SECURITY", tunnel_security);
set!("AETHER_TUNNEL_ENCRYPTION_KEY", tunnel_encryption_key);
set!("AETHER_TUNNEL_PUBLIC_IP", self.public_ip);
set!("AETHER_TUNNEL_NODE_NAME", node_name);
set!("AETHER_TUNNEL_NODE_REGION", self.node_region);
@@ -1395,6 +1484,31 @@ tunnel_ipv6_only = false
assert_eq!(cfg.tunnel_ipv6_only, Some(false));
}
#[test]
fn config_file_deserializes_server_tunnel_security_fields() {
let cfg: ConfigFile = toml::from_str(
r#"
[[servers]]
aether_url = "http://aether.example.com"
management_token = "ae_test"
node_name = "jp-proxy-01"
tunnel_security = "non_tls_required"
tunnel_encryption_key = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
"#,
)
.expect("server tunnel security TOML");
assert_eq!(cfg.servers.len(), 1);
assert_eq!(
cfg.servers[0].tunnel_security,
Some(TunnelSecurity::NonTlsRequired)
);
assert_eq!(
cfg.servers[0].tunnel_encryption_key.as_deref(),
Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=")
);
}
#[test]
fn config_file_deserializes_upstream_proxy_url() {
let cfg: ConfigFile = toml::from_str("upstream_proxy_url = \"http://proxy.example:8080\"")
@@ -1608,6 +1722,117 @@ node_name = "tunnel-test"
);
}
#[test]
fn cli_defaults_tunnel_security_to_off() {
let config = Config::parse_from([
"aether-tunnel",
"--aether-url",
"https://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
]);
assert_eq!(config.tunnel_security, TunnelSecurity::Off);
assert!(config.tunnel_encryption_key.is_none());
}
#[test]
fn validate_requires_encryption_key_for_non_tls_security() {
let config = Config::parse_from([
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-security",
"non_tls_required",
]);
let error = config
.validate()
.expect_err("non_tls_required should require a PSK");
assert!(error.to_string().contains("tunnel_encryption_key"));
let with_key = Config::parse_from([
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-security",
"non_tls_required",
"--tunnel-encryption-key",
"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,
None,
config.tunnel_encryption_key.as_deref(),
),
TunnelSecurity::NonTlsRequired
);
assert_eq!(
effective_tunnel_security(
&config.aether_url,
Some(TunnelSecurity::Off),
config.tunnel_encryption_key.as_deref(),
),
TunnelSecurity::Off
);
config
.validate()
.expect("http URL with PSK should validate when tunnel_security is off");
}
#[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-security",
"non_tls_required",
"--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

@@ -15,9 +15,9 @@ mod upstream_client;
use std::path::PathBuf;
use clap::{CommandFactory, FromArgMatches, Parser};
use clap::{parser::ValueSource, CommandFactory, FromArgMatches, Parser};
use config::Config;
use config::{Config, ServerEntry, TunnelSecurity};
/// Default config file name.
const DEFAULT_CONFIG: &str = "aether-tunnel.toml";
@@ -102,7 +102,8 @@ async fn main() -> anyhow::Result<()> {
None => {
// No subcommand: run the tunnel with parsed config.
let config = Config::from_arg_matches(&matches)?;
run_tunnel(config).await
let tunnel_security = configured_tunnel_security_from_matches(&matches, &config);
run_tunnel(config, tunnel_security).await
}
},
Err(e) => {
@@ -139,7 +140,7 @@ async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()>
let config = Config::try_parse_from(["aether-tunnel"])
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
eprintln!(" Starting tunnel...\n");
run_tunnel(config).await
run_tunnel(config, None).await
}
setup::SetupOutcome::Cancelled => {
eprintln!(" Setup cancelled.");
@@ -148,8 +149,31 @@ async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()>
}
}
fn configured_tunnel_security_from_matches(
matches: &clap::ArgMatches,
config: &Config,
) -> Option<TunnelSecurity> {
matches
.value_source("tunnel_security")
.filter(|source| *source != ValueSource::DefaultValue)
.map(|_| config.tunnel_security)
}
fn single_server_entry(config: &Config, tunnel_security: Option<TunnelSecurity>) -> ServerEntry {
ServerEntry {
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
node_name: None,
tunnel_security,
tunnel_encryption_key: config.tunnel_encryption_key.clone(),
}
}
/// Start the tunnel agent, checking for managed-service conflicts first.
async fn run_tunnel(config: Config) -> anyhow::Result<()> {
async fn run_tunnel(
config: Config,
single_server_tunnel_security: Option<TunnelSecurity>,
) -> anyhow::Result<()> {
// Warn if a managed service is already running (would cause conflicts).
if std::env::var_os("AETHER_TUNNEL_SERVICE_MANAGER").is_none()
&& std::env::var_os("INVOCATION_ID").is_none()
@@ -178,12 +202,76 @@ async fn run_tunnel(config: Config) -> anyhow::Result<()> {
}
file_cfg.servers.clone()
} else {
vec![config::ServerEntry {
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
node_name: None,
}]
vec![single_server_entry(&config, single_server_tunnel_security)]
};
app::run(config, servers).await
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_config_and_security(args: &[&str]) -> (Config, Option<TunnelSecurity>) {
let matches = build_command()
.try_get_matches_from(args)
.expect("arguments should parse");
let config = Config::from_arg_matches(&matches).expect("config should parse");
let tunnel_security = configured_tunnel_security_from_matches(&matches, &config);
(config, tunnel_security)
}
#[test]
fn single_server_entry_omits_default_tunnel_security_for_auto_inference() {
let (config, tunnel_security) = parse_config_and_security(&[
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-encryption-key",
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
]);
let entry = single_server_entry(&config, tunnel_security);
assert_eq!(entry.tunnel_security, None);
assert_eq!(
config::effective_tunnel_security(
&entry.aether_url,
entry.tunnel_security,
entry.tunnel_encryption_key.as_deref(),
),
TunnelSecurity::NonTlsRequired
);
}
#[test]
fn single_server_entry_preserves_explicit_tunnel_security_off() {
let (config, tunnel_security) = parse_config_and_security(&[
"aether-tunnel",
"--aether-url",
"http://example.com",
"--management-token",
"ae_test",
"--node-name",
"tunnel-test",
"--tunnel-security",
"off",
"--tunnel-encryption-key",
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
]);
let entry = single_server_entry(&config, tunnel_security);
assert_eq!(entry.tunnel_security, Some(TunnelSecurity::Off));
assert_eq!(
config::effective_tunnel_security(
&entry.aether_url,
entry.tunnel_security,
entry.tunnel_encryption_key.as_deref(),
),
TunnelSecurity::Off
);
}
}

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

@@ -93,6 +93,22 @@ impl ServerTab {
required: true,
help: "Node name for identification in Aether dashboard",
},
Field {
label: "Tunnel Security",
key: "tunnel_security",
value: String::new(),
kind: FieldKind::Text,
required: false,
help: "off or non_tls_required; omit to auto-enable for http:// plus a key",
},
Field {
label: "Tunnel Encryption Key",
key: "tunnel_encryption_key",
value: String::new(),
kind: FieldKind::Secret,
required: false,
help: "Base64 32-byte PSK; required when Tunnel Security is non_tls_required",
},
],
}
}
@@ -104,6 +120,12 @@ impl ServerTab {
if let Some(ref name) = entry.node_name {
tab.fields[2].value = name.clone();
}
if let Some(security) = entry.tunnel_security {
tab.fields[3].value = security.to_string();
}
if let Some(ref key) = entry.tunnel_encryption_key {
tab.fields[4].value = key.clone();
}
tab
}
}
@@ -416,12 +438,33 @@ impl App {
cfg.servers = self
.server_tabs
.iter()
.map(|tab| ServerEntry {
aether_url: get_tab(tab, "aether_url").unwrap_or_default(),
management_token: get_tab(tab, "management_token").unwrap_or_default(),
node_name: get_tab(tab, "node_name"),
.map(|tab| {
let tunnel_security = get_tab(tab, "tunnel_security")
.map(|value| value.parse().map_err(anyhow::Error::msg))
.transpose()?;
Ok(ServerEntry {
aether_url: get_tab(tab, "aether_url").unwrap_or_default(),
management_token: get_tab(tab, "management_token").unwrap_or_default(),
node_name: get_tab(tab, "node_name"),
tunnel_security,
tunnel_encryption_key: get_tab(tab, "tunnel_encryption_key"),
})
})
.collect();
.collect::<anyhow::Result<Vec<_>>>()?;
for server in &cfg.servers {
if server.tunnel_security == Some(crate::config::TunnelSecurity::NonTlsRequired)
&& server
.tunnel_encryption_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
{
anyhow::bail!(
"Tunnel Encryption Key is required when Tunnel Security is non_tls_required"
);
}
}
Ok(cfg)
}
@@ -1108,11 +1151,55 @@ mod tests {
assert_eq!(
server_keys,
vec!["aether_url", "management_token", "node_name"]
vec![
"aether_url",
"management_token",
"node_name",
"tunnel_security",
"tunnel_encryption_key"
]
);
assert_eq!(global_keys.first().copied(), Some("upstream_proxy_url"));
}
#[test]
fn to_config_persists_tunnel_security_fields_per_server() {
let mut app = sample_app();
set_server_field(&mut app, "tunnel_security", "non_tls_required");
set_server_field(&mut app, "tunnel_encryption_key", "base64-32-bytes");
let cfg = app.to_config().expect("config should serialize");
assert_eq!(cfg.servers.len(), 1);
assert_eq!(
cfg.servers[0].tunnel_security,
Some(crate::config::TunnelSecurity::NonTlsRequired)
);
assert_eq!(
cfg.servers[0].tunnel_encryption_key.as_deref(),
Some("base64-32-bytes")
);
}
#[test]
fn to_config_omits_blank_tunnel_security_for_auto_inference() {
let app = sample_app();
let cfg = app.to_config().expect("config should serialize");
assert_eq!(cfg.servers.len(), 1);
assert_eq!(cfg.servers[0].tunnel_security, None);
}
#[test]
fn to_config_rejects_non_tls_security_without_key() {
let mut app = sample_app();
set_server_field(&mut app, "tunnel_security", "non_tls_required");
let error = app
.to_config()
.expect_err("secure non-TLS mode should require a key");
assert!(error.to_string().contains("Tunnel Encryption Key"));
}
#[test]
fn to_config_enables_pretty_file_logging_with_defaults() {
let mut app = sample_app();
@@ -1226,6 +1313,8 @@ mod tests {
aether_url: "https://aether-2.example.com".to_string(),
management_token: "ae_test_2".to_string(),
node_name: Some("jp-proxy-02".to_string()),
tunnel_security: None,
tunnel_encryption_key: None,
}));
app.active_tab = 1;
@@ -1245,6 +1334,8 @@ mod tests {
aether_url: "https://aether-2.example.com".to_string(),
management_token: "ae_test_2".to_string(),
node_name: Some("jp-proxy-02".to_string()),
tunnel_security: None,
tunnel_encryption_key: None,
}));
app.active_tab = 0;

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,29 @@ 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)?);
let security_session = uuid::Uuid::new_v4().simple().to_string();
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(&security_session)?,
);
}
// 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 +136,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,
&security_session,
TunnelSecurityRole::Client,
)?))
} else {
None
};
let stale_timeout = state
.config
.tunnel_stale_timeout()
@@ -146,10 +176,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 +205,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(
@@ -496,6 +498,8 @@ mod tests {
management_token: "token".to_string(),
public_ip: None,
node_name: "tunnel-test".to_string(),
tunnel_security: crate::config::TunnelSecurity::Off,
tunnel_encryption_key: None,
node_region: None,
heartbeat_interval: 1,
allowed_ports: vec![80, 443],

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(
@@ -2511,6 +2513,8 @@ mod tests {
management_token: "token".to_string(),
public_ip: None,
node_name: "tunnel-test".to_string(),
tunnel_security: crate::config::TunnelSecurity::Off,
tunnel_encryption_key: None,
node_region: None,
heartbeat_interval: 30,
allowed_ports: vec![80, 443],

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

View File

@@ -7,8 +7,12 @@ repository.workspace = true
description = "Shared contracts for Python and Rust Aether components"
[dependencies]
aes-gcm.workspace = true
base64.workspace = true
bytes.workspace = true
flate2.workspace = true
hmac.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true

View File

@@ -3,6 +3,7 @@ mod frame;
mod plan;
mod result;
pub mod tunnel;
pub mod tunnel_security;
mod usage;
pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};

View File

@@ -15,6 +15,7 @@ pub const CURRENT_TUNNEL_PROTOCOL_VERSION_STR: &str = "2";
pub mod flags {
pub const END_STREAM: u8 = 0x01;
pub const GZIP_COMPRESSED: u8 = 0x02;
pub const ENCRYPTED: u8 = crate::tunnel_security::FLAG_ENCRYPTED;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -65,6 +66,7 @@ pub const HEARTBEAT_DATA: u8 = MsgType::HeartbeatData as u8;
pub const HEARTBEAT_ACK: u8 = MsgType::HeartbeatAck as u8;
pub const FLAG_END_STREAM: u8 = flags::END_STREAM;
pub const FLAG_GZIP_COMPRESSED: u8 = flags::GZIP_COMPRESSED;
pub const FLAG_ENCRYPTED: u8 = flags::ENCRYPTED;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FrameHeader {

View File

@@ -0,0 +1,266 @@
use aes_gcm::aead::{Aead, Payload};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use base64::Engine;
use bytes::{Buf, BufMut, Bytes, BytesMut};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::tunnel::{Frame, MsgType, HEADER_SIZE};
type HmacSha256 = Hmac<Sha256>;
pub const TUNNEL_SECURITY_HEADER: &str = "x-aether-tunnel-security";
pub const TUNNEL_SECURITY_SESSION_HEADER: &str = "x-aether-tunnel-security-session";
pub const TUNNEL_SECURITY_NON_TLS_REQUIRED: &str = "non_tls_required";
pub const FLAG_ENCRYPTED: u8 = 0x04;
const CONTEXT: &[u8] = b"aether-tunnel-secure-v1";
const CLIENT_TO_SERVER_LABEL: &[u8] = b"client-to-server";
const SERVER_TO_CLIENT_LABEL: &[u8] = b"server-to-client";
const CLIENT_TO_SERVER_NONCE_PREFIX: [u8; 4] = *b"c2s1";
const SERVER_TO_CLIENT_NONCE_PREFIX: [u8; 4] = *b"s2c1";
const SEQUENCE_LEN: usize = 8;
const NONCE_LEN: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TunnelSecurityRole {
Client,
Server,
}
#[derive(Debug, thiserror::Error)]
pub enum TunnelSecurityError {
#[error("tunnel_encryption_key must be base64-encoded 32 bytes")]
InvalidKey,
#[error("tunnel security session id must not be empty")]
InvalidSession,
#[error("secure tunnel frame is missing encrypted flag")]
MissingEncryptedFlag,
#[error("secure tunnel frame payload is too short")]
PayloadTooShort,
#[error("secure tunnel frame encryption failed")]
Encrypt,
#[error("secure tunnel frame decryption failed")]
Decrypt,
}
pub struct SecureFrameCodec {
seal: Aes256Gcm,
open: Aes256Gcm,
seal_prefix: [u8; 4],
open_prefix: [u8; 4],
next_sequence: AtomicU64,
}
impl SecureFrameCodec {
pub fn new(
key: &str,
session_id: &str,
role: TunnelSecurityRole,
) -> Result<Self, TunnelSecurityError> {
let psk = decode_psk(key)?;
let session_id = session_id.trim();
if session_id.is_empty() {
return Err(TunnelSecurityError::InvalidSession);
}
let client_to_server = derive_key(&psk, session_id.as_bytes(), CLIENT_TO_SERVER_LABEL);
let server_to_client = derive_key(&psk, session_id.as_bytes(), SERVER_TO_CLIENT_LABEL);
let (seal_key, open_key, seal_prefix, open_prefix) = match role {
TunnelSecurityRole::Client => (
client_to_server,
server_to_client,
CLIENT_TO_SERVER_NONCE_PREFIX,
SERVER_TO_CLIENT_NONCE_PREFIX,
),
TunnelSecurityRole::Server => (
server_to_client,
client_to_server,
SERVER_TO_CLIENT_NONCE_PREFIX,
CLIENT_TO_SERVER_NONCE_PREFIX,
),
};
Ok(Self {
seal: Aes256Gcm::new_from_slice(&seal_key)
.map_err(|_| TunnelSecurityError::InvalidKey)?,
open: Aes256Gcm::new_from_slice(&open_key)
.map_err(|_| TunnelSecurityError::InvalidKey)?,
seal_prefix,
open_prefix,
next_sequence: AtomicU64::new(0),
})
}
pub fn encrypt_frame(&self, frame: Frame) -> Result<Bytes, TunnelSecurityError> {
let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed);
let nonce_bytes = nonce_bytes(self.seal_prefix, sequence);
let nonce = Nonce::from_slice(&nonce_bytes);
let clear_flags = frame.flags & !FLAG_ENCRYPTED;
let aad = frame_aad(frame.stream_id, frame.msg_type, clear_flags);
let ciphertext = self
.seal
.encrypt(
nonce,
Payload {
msg: &frame.payload,
aad: &aad,
},
)
.map_err(|_| TunnelSecurityError::Encrypt)?;
let mut payload = BytesMut::with_capacity(SEQUENCE_LEN + ciphertext.len());
payload.put_u64(sequence);
payload.extend_from_slice(&ciphertext);
Ok(Frame::new(
frame.stream_id,
frame.msg_type,
clear_flags | FLAG_ENCRYPTED,
payload.freeze(),
)
.encode())
}
pub fn decrypt_frame(&self, frame: Frame) -> Result<Frame, TunnelSecurityError> {
if frame.flags & FLAG_ENCRYPTED == 0 {
return Err(TunnelSecurityError::MissingEncryptedFlag);
}
if frame.payload.len() < SEQUENCE_LEN {
return Err(TunnelSecurityError::PayloadTooShort);
}
let mut payload = frame.payload.clone();
let sequence = payload.get_u64();
let nonce_bytes = nonce_bytes(self.open_prefix, sequence);
let nonce = Nonce::from_slice(&nonce_bytes);
let clear_flags = frame.flags & !FLAG_ENCRYPTED;
let aad = frame_aad(frame.stream_id, frame.msg_type, clear_flags);
let plaintext = self
.open
.decrypt(
nonce,
Payload {
msg: &payload,
aad: &aad,
},
)
.map_err(|_| TunnelSecurityError::Decrypt)?;
Ok(Frame::new(
frame.stream_id,
frame.msg_type,
clear_flags,
Bytes::from(plaintext),
))
}
}
pub fn decode_psk(key: &str) -> Result<[u8; 32], TunnelSecurityError> {
let decoded = base64::engine::general_purpose::STANDARD
.decode(key.trim())
.map_err(|_| TunnelSecurityError::InvalidKey)?;
decoded
.try_into()
.map_err(|_| TunnelSecurityError::InvalidKey)
}
fn derive_key(psk: &[u8; 32], session_id: &[u8], label: &[u8]) -> [u8; 32] {
let mut mac = <HmacSha256 as Mac>::new_from_slice(psk).expect("HMAC accepts 32-byte PSK");
mac.update(CONTEXT);
mac.update(&[0]);
mac.update(session_id);
mac.update(&[0]);
mac.update(label);
mac.finalize().into_bytes().into()
}
fn nonce_bytes(prefix: [u8; 4], sequence: u64) -> [u8; NONCE_LEN] {
let mut nonce = [0_u8; NONCE_LEN];
nonce[..4].copy_from_slice(&prefix);
nonce[4..].copy_from_slice(&sequence.to_be_bytes());
nonce
}
fn frame_aad(stream_id: u32, msg_type: MsgType, clear_flags: u8) -> [u8; HEADER_SIZE - 4] {
let mut aad = [0_u8; HEADER_SIZE - 4];
aad[..4].copy_from_slice(&stream_id.to_be_bytes());
aad[4] = msg_type as u8;
aad[5] = clear_flags;
aad
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tunnel::{Frame, MsgType};
fn test_key() -> String {
base64::engine::general_purpose::STANDARD.encode([7_u8; 32])
}
#[test]
fn secure_frame_round_trips_between_roles() {
let client = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Client)
.expect("client codec");
let server = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Server)
.expect("server codec");
let frame = Frame::new(3, MsgType::RequestBody, 0, Bytes::from_static(b"secret"));
let encrypted = client.encrypt_frame(frame).expect("encrypt");
assert!(!encrypted.windows(b"secret".len()).any(|w| w == b"secret"));
let wire = Frame::decode(encrypted).expect("wire frame");
assert_ne!(wire.payload, Bytes::from_static(b"secret"));
assert_ne!(wire.flags & FLAG_ENCRYPTED, 0);
let decrypted = server.decrypt_frame(wire).expect("decrypt");
assert_eq!(decrypted.stream_id, 3);
assert_eq!(decrypted.msg_type, MsgType::RequestBody);
assert_eq!(decrypted.flags, 0);
assert_eq!(decrypted.payload, Bytes::from_static(b"secret"));
}
#[test]
fn secure_frame_rejects_wrong_session() {
let client = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Client)
.expect("client codec");
let server = SecureFrameCodec::new(&test_key(), "session-2", TunnelSecurityRole::Server)
.expect("server codec");
let encrypted = client
.encrypt_frame(Frame::new(
1,
MsgType::RequestBody,
0,
Bytes::from_static(b"secret"),
))
.expect("encrypt");
let wire = Frame::decode(encrypted).expect("wire frame");
assert!(matches!(
server.decrypt_frame(wire),
Err(TunnelSecurityError::Decrypt)
));
}
#[test]
fn secure_frame_uses_session_in_key_derivation() {
let session_a = "node-1:connection-a";
let session_b = "node-1:connection-b";
let client_a = SecureFrameCodec::new(&test_key(), session_a, TunnelSecurityRole::Client)
.expect("client codec a");
let client_b = SecureFrameCodec::new(&test_key(), session_b, TunnelSecurityRole::Client)
.expect("client codec b");
let frame = Frame::new(
7,
MsgType::RequestBody,
0,
Bytes::from_static(b"same payload"),
);
let encrypted_a = client_a.encrypt_frame(frame.clone()).expect("encrypt a");
let encrypted_b = client_b.encrypt_frame(frame).expect("encrypt b");
assert_ne!(encrypted_a, encrypted_b);
}
}