mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Complete secure tunnel encryption support
This commit is contained in:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -123,10 +123,14 @@ version = "0.1.0"
|
|||||||
name = "aether-contracts"
|
name = "aether-contracts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"hmac",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ aether-http = { path = "crates/aether-http" }
|
|||||||
aether-runtime = { path = "crates/aether-runtime" }
|
aether-runtime = { path = "crates/aether-runtime" }
|
||||||
aether-testkit = { path = "crates/aether-testkit" }
|
aether-testkit = { path = "crates/aether-testkit" }
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
|
aes-gcm = "0.10"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ aether-task-runtime.workspace = true
|
|||||||
aether-usage-runtime.workspace = true
|
aether-usage-runtime.workspace = true
|
||||||
aether-video-tasks-core.workspace = true
|
aether-video-tasks-core.workspace = true
|
||||||
aether-wallet.workspace = true
|
aether-wallet.workspace = true
|
||||||
aes-gcm = "0.10"
|
aes-gcm.workspace = true
|
||||||
async-stream.workspace = true
|
async-stream.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ struct ProxyNodeRegisterRequest {
|
|||||||
proxy_version: Option<String>,
|
proxy_version: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
tunnel_mode: Option<bool>,
|
tunnel_mode: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
tunnel_security: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tunnel_encryption_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -349,9 +353,21 @@ pub(crate) async fn maybe_build_local_admin_proxy_nodes_response(
|
|||||||
Ok(mutation) => mutation,
|
Ok(mutation) => mutation,
|
||||||
Err(response) => return Ok(Some(response)),
|
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 {
|
let Some(node) = state.register_proxy_node(&mutation).await? else {
|
||||||
return Ok(Some(build_admin_proxy_nodes_data_unavailable_response()));
|
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(
|
return Ok(Some(
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"node_id": node.id,
|
"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.hardware_info.as_ref(), "hardware_info")?;
|
||||||
validate_optional_object(input.proxy_metadata.as_ref(), "proxy_metadata")?;
|
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
|
let registered_by = request_context
|
||||||
.decision()
|
.decision()
|
||||||
.and_then(|decision| decision.admin_principal.as_ref())
|
.and_then(|decision| decision.admin_principal.as_ref())
|
||||||
.map(|principal| principal.user_id.clone());
|
.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(
|
Ok(
|
||||||
aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation {
|
aether_data::repository::proxy_nodes::ProxyNodeRegistrationMutation {
|
||||||
name,
|
name,
|
||||||
@@ -1438,7 +1485,7 @@ fn validate_register_request(
|
|||||||
avg_latency_ms: input.avg_latency_ms,
|
avg_latency_ms: input.avg_latency_ms,
|
||||||
hardware_info: input.hardware_info,
|
hardware_info: input.hardware_info,
|
||||||
estimated_max_concurrency: input.estimated_max_concurrency,
|
estimated_max_concurrency: input.estimated_max_concurrency,
|
||||||
proxy_metadata: input.proxy_metadata,
|
proxy_metadata,
|
||||||
proxy_version: normalize_optional_string(
|
proxy_version: normalize_optional_string(
|
||||||
input.proxy_version.as_deref(),
|
input.proxy_version.as_deref(),
|
||||||
"proxy_version",
|
"proxy_version",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use axum::http::HeaderMap;
|
|||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use dashmap::DashMap;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use crate::{data::GatewayDataState, middleware};
|
use crate::{data::GatewayDataState, middleware};
|
||||||
@@ -34,6 +35,7 @@ pub struct AppState {
|
|||||||
data: Arc<GatewayDataState>,
|
data: Arc<GatewayDataState>,
|
||||||
request_gate: Option<Arc<ConcurrencyGate>>,
|
request_gate: Option<Arc<ConcurrencyGate>>,
|
||||||
distributed_request_gate: Option<Arc<RuntimeSemaphore>>,
|
distributed_request_gate: Option<Arc<RuntimeSemaphore>>,
|
||||||
|
secure_tunnel_keys: Arc<DashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -55,9 +57,48 @@ impl AppState {
|
|||||||
data: Arc::new(GatewayDataState::disabled()),
|
data: Arc::new(GatewayDataState::disabled()),
|
||||||
request_gate: None,
|
request_gate: None,
|
||||||
distributed_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 {
|
pub(crate) fn with_data(mut self, data: Arc<GatewayDataState>) -> Self {
|
||||||
self.data = data;
|
self.data = data;
|
||||||
self
|
self
|
||||||
@@ -212,11 +253,42 @@ pub async fn ws_proxy(
|
|||||||
|
|
||||||
let max_streams = resolve_proxy_max_streams(&headers, state.max_streams);
|
let max_streams = resolve_proxy_max_streams(&headers, state.max_streams);
|
||||||
let protocol_version = resolve_proxy_protocol_version(&headers);
|
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())
|
||||||
|
.unwrap_or(node_id.as_str())
|
||||||
|
.to_string();
|
||||||
|
|
||||||
if node_id.is_empty() {
|
if node_id.is_empty() {
|
||||||
warn!("proxy connection rejected: missing X-Node-ID header");
|
warn!("proxy connection rejected: missing X-Node-ID header");
|
||||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||||
}
|
}
|
||||||
|
let stored_security_key = state.secure_tunnel_key_for_node(&node_id).await;
|
||||||
|
let security_key = match tunnel_security.as_deref() {
|
||||||
|
Some(aether_contracts::tunnel_security::TUNNEL_SECURITY_NON_TLS_REQUIRED) => {
|
||||||
|
match stored_security_key {
|
||||||
|
Some(key) => Some(key),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
let request_permit = match state.try_acquire_request_permit().await {
|
let request_permit = match state.try_acquire_request_permit().await {
|
||||||
Ok(permit) => permit,
|
Ok(permit) => permit,
|
||||||
@@ -253,6 +325,8 @@ pub async fn ws_proxy(
|
|||||||
node_name,
|
node_name,
|
||||||
max_streams,
|
max_streams,
|
||||||
protocol_version,
|
protocol_version,
|
||||||
|
security_key,
|
||||||
|
security_session,
|
||||||
state.proxy_conn_cfg,
|
state.proxy_conn_cfg,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use tracing::{debug, info, warn};
|
|||||||
|
|
||||||
use super::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
|
use super::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
|
||||||
use super::protocol;
|
use super::protocol;
|
||||||
|
use aether_contracts::tunnel::Frame;
|
||||||
|
use aether_contracts::tunnel_security::{SecureFrameCodec, TunnelSecurityRole};
|
||||||
|
|
||||||
/// Maximum single frame size: 64 MB
|
/// Maximum single frame size: 64 MB
|
||||||
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
|
||||||
@@ -24,6 +26,8 @@ pub async fn handle_proxy_connection(
|
|||||||
node_name: String,
|
node_name: String,
|
||||||
max_streams: usize,
|
max_streams: usize,
|
||||||
protocol_version: u8,
|
protocol_version: u8,
|
||||||
|
security_key: Option<String>,
|
||||||
|
security_session: String,
|
||||||
cfg: ConnConfig,
|
cfg: ConnConfig,
|
||||||
) {
|
) {
|
||||||
let conn_id = hub.alloc_conn_id();
|
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 (tx, mut rx) = bounded_queue::<Message>(cfg.outbound_queue_capacity);
|
||||||
let (close_tx, mut close_rx) = watch::channel(false);
|
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(
|
let conn = Arc::new(ProxyConn::new(
|
||||||
conn_id,
|
conn_id,
|
||||||
@@ -46,6 +62,7 @@ pub async fn handle_proxy_connection(
|
|||||||
|
|
||||||
let writer_conn_id = conn_id;
|
let writer_conn_id = conn_id;
|
||||||
let writer_conn = conn.clone();
|
let writer_conn = conn.clone();
|
||||||
|
let writer_security = security.clone();
|
||||||
let writer = tokio::spawn(async move {
|
let writer = tokio::spawn(async move {
|
||||||
let mut frames_sent: u64 = 0;
|
let mut frames_sent: u64 = 0;
|
||||||
loop {
|
loop {
|
||||||
@@ -58,6 +75,13 @@ pub async fn handle_proxy_connection(
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
let send_started_at = std::time::Instant::now();
|
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(
|
let send_result = tokio::time::timeout(
|
||||||
Duration::from_secs(15),
|
Duration::from_secs(15),
|
||||||
ws_tx.send(msg),
|
ws_tx.send(msg),
|
||||||
@@ -194,7 +218,7 @@ pub async fn handle_proxy_connection(
|
|||||||
let reader_hub = hub.clone();
|
let reader_hub = hub.clone();
|
||||||
let reader_conn = conn.clone();
|
let reader_conn = conn.clone();
|
||||||
let reader = tokio::spawn(async move {
|
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;
|
let _ = reader.await;
|
||||||
@@ -223,6 +247,7 @@ async fn run_proxy_reader(
|
|||||||
hub: Arc<HubRouter>,
|
hub: Arc<HubRouter>,
|
||||||
conn: Arc<ProxyConn>,
|
conn: Arc<ProxyConn>,
|
||||||
idle_timeout: Duration,
|
idle_timeout: Duration,
|
||||||
|
security: Option<Arc<SecureFrameCodec>>,
|
||||||
) {
|
) {
|
||||||
let idle_enabled = !idle_timeout.is_zero();
|
let idle_enabled = !idle_timeout.is_zero();
|
||||||
let mut oversized_count = 0u32;
|
let mut oversized_count = 0u32;
|
||||||
@@ -245,7 +270,14 @@ async fn run_proxy_reader(
|
|||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Binary(data))) => {
|
Some(Ok(Message::Binary(data))) => {
|
||||||
frames_received += 1;
|
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 {
|
if data.len() > MAX_FRAME_SIZE {
|
||||||
oversized_count += 1;
|
oversized_count += 1;
|
||||||
warn!(
|
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)?.into()))
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -455,6 +455,14 @@ impl EmbeddedTunnelState {
|
|||||||
self.inner.clone()
|
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 {
|
pub(crate) fn has_local_proxy(&self, node_id: &str) -> bool {
|
||||||
self.inner.hub.has_local_proxy(node_id)
|
self.inner.hub.has_local_proxy(node_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ AETHER_TUNNEL_MANAGEMENT_TOKEN=ae_xxxxx
|
|||||||
# Node identification
|
# Node identification
|
||||||
AETHER_TUNNEL_NODE_NAME=jp-proxy-01
|
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_SECURITY=off
|
||||||
# AETHER_TUNNEL_ENCRYPTION_KEY=base64-32-bytes
|
# AETHER_TUNNEL_ENCRYPTION_KEY=base64-32-bytes
|
||||||
|
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ sudo aether-tunnel uninstall
|
|||||||
| `--aether-url` | `AETHER_TUNNEL_AETHER_URL` | **必填** | Aether 服务器地址 |
|
| `--aether-url` | `AETHER_TUNNEL_AETHER_URL` | **必填** | Aether 服务器地址 |
|
||||||
| `--management-token` | `AETHER_TUNNEL_MANAGEMENT_TOKEN` | **必填** | 管理员 Token(`ae_xxx` 格式) |
|
| `--management-token` | `AETHER_TUNNEL_MANAGEMENT_TOKEN` | **必填** | 管理员 Token(`ae_xxx` 格式) |
|
||||||
| `--node-name` | `AETHER_TUNNEL_NODE_NAME` | **必填** | 节点名称标识 |
|
| `--node-name` | `AETHER_TUNNEL_NODE_NAME` | **必填** | 节点名称标识 |
|
||||||
| `--tunnel-security` | `AETHER_TUNNEL_SECURITY` | `off` | Aether ↔ tunnel 通道安全模式;MVP 仅支持 `off` / `non_tls_required` |
|
| `--tunnel-security` | `AETHER_TUNNEL_SECURITY` | `off` | Aether ↔ tunnel 通道安全模式;支持 `off` / `non_tls_required`;`http://` 且提供 key 时会自动按 `non_tls_required` 生效 |
|
||||||
| `--tunnel-encryption-key` | `AETHER_TUNNEL_ENCRYPTION_KEY` | 空 | `non_tls_required` 使用的长期 PSK(base64 32-byte),每个 `[[servers]]` 节点独立配置 |
|
| `--tunnel-encryption-key` | `AETHER_TUNNEL_ENCRYPTION_KEY` | 空 | secure tunnel 使用的长期 PSK(base64 32-byte),每个 `[[servers]]` 节点独立配置 |
|
||||||
| `--public-ip` | `AETHER_TUNNEL_PUBLIC_IP` | 自动检测 | 公网 IP |
|
| `--public-ip` | `AETHER_TUNNEL_PUBLIC_IP` | 自动检测 | 公网 IP |
|
||||||
| `--node-region` | `AETHER_TUNNEL_NODE_REGION` | 自动检测 | 地区标识 |
|
| `--node-region` | `AETHER_TUNNEL_NODE_REGION` | 自动检测 | 地区标识 |
|
||||||
| `--heartbeat-interval` | `AETHER_TUNNEL_HEARTBEAT_INTERVAL` | `5` | 心跳间隔(秒) |
|
| `--heartbeat-interval` | `AETHER_TUNNEL_HEARTBEAT_INTERVAL` | `5` | 心跳间隔(秒) |
|
||||||
@@ -226,12 +226,13 @@ tunnel_security = "off"
|
|||||||
aether_url = "http://aether-2.example.com"
|
aether_url = "http://aether-2.example.com"
|
||||||
management_token = "ae_yyy"
|
management_token = "ae_yyy"
|
||||||
node_name = "jp-proxy-02"
|
node_name = "jp-proxy-02"
|
||||||
tunnel_security = "non_tls_required"
|
|
||||||
tunnel_encryption_key = "base64-32-bytes"
|
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 这段链路。
|
`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*` 格式的 tag,GitHub Actions 会自动:
|
推送 `tunnel-v*` 格式的 tag,GitHub Actions 会自动:
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ use tokio::sync::{watch, Mutex};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{error, info, warn};
|
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::net;
|
||||||
use crate::registration::client::AetherClient;
|
use crate::registration::client::AetherClient;
|
||||||
use crate::runtime::{self, DynamicConfig};
|
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.aether_url,
|
||||||
&entry.management_token,
|
&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
|
match client
|
||||||
.register(&config, &node_name, &public_ip, Some(&hw_info))
|
.register(&config, entry, &node_name, &public_ip, Some(&hw_info))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(node_id) => {
|
Ok(node_id) => {
|
||||||
@@ -603,7 +622,13 @@ async fn retry_failed_registration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match client
|
match client
|
||||||
.register(&state.config, &node_name, &public_ip, Some(&hw_info))
|
.register(
|
||||||
|
&state.config,
|
||||||
|
&entry,
|
||||||
|
&node_name,
|
||||||
|
&public_ip,
|
||||||
|
Some(&hw_info),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(node_id) => {
|
Ok(node_id) => {
|
||||||
@@ -681,6 +706,12 @@ fn build_server_context(
|
|||||||
server_label: label.to_string(),
|
server_label: label.to_string(),
|
||||||
aether_url: entry.aether_url.clone(),
|
aether_url: entry.aether_url.clone(),
|
||||||
management_token: entry.management_token.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_name: node_name.to_string(),
|
||||||
node_id: Arc::new(RwLock::new(node_id)),
|
node_id: Arc::new(RwLock::new(node_id)),
|
||||||
aether_client: client,
|
aether_client: client,
|
||||||
|
|||||||
@@ -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.
|
/// Aether tunnel agent.
|
||||||
///
|
///
|
||||||
/// Deployed on overseas VPS to relay API traffic for Aether instances
|
/// Deployed on overseas VPS to relay API traffic for Aether instances
|
||||||
@@ -713,12 +738,18 @@ impl Config {
|
|||||||
if self.node_name.trim().is_empty() {
|
if self.node_name.trim().is_empty() {
|
||||||
anyhow::bail!("node_name must not be empty");
|
anyhow::bail!("node_name must not be empty");
|
||||||
}
|
}
|
||||||
if self.tunnel_security == TunnelSecurity::NonTlsRequired
|
let effective_security = effective_tunnel_security(
|
||||||
&& normalized_proxy_url(&self.tunnel_encryption_key).is_none()
|
&self.aether_url,
|
||||||
{
|
Some(self.tunnel_security),
|
||||||
anyhow::bail!(
|
self.tunnel_encryption_key.as_deref(),
|
||||||
"tunnel_encryption_key must be set when tunnel_security=non_tls_required"
|
);
|
||||||
);
|
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 {
|
for &port in &self.allowed_ports {
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
@@ -1465,7 +1496,7 @@ aether_url = "http://aether.example.com"
|
|||||||
management_token = "ae_test"
|
management_token = "ae_test"
|
||||||
node_name = "jp-proxy-01"
|
node_name = "jp-proxy-01"
|
||||||
tunnel_security = "non_tls_required"
|
tunnel_security = "non_tls_required"
|
||||||
tunnel_encryption_key = "base64-32-bytes"
|
tunnel_encryption_key = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("server tunnel security TOML");
|
.expect("server tunnel security TOML");
|
||||||
@@ -1477,7 +1508,7 @@ tunnel_encryption_key = "base64-32-bytes"
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.servers[0].tunnel_encryption_key.as_deref(),
|
cfg.servers[0].tunnel_encryption_key.as_deref(),
|
||||||
Some("base64-32-bytes")
|
Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,13 +1771,61 @@ node_name = "tunnel-test"
|
|||||||
"--tunnel-security",
|
"--tunnel-security",
|
||||||
"non_tls_required",
|
"non_tls_required",
|
||||||
"--tunnel-encryption-key",
|
"--tunnel-encryption-key",
|
||||||
"base64-32-bytes",
|
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
|
||||||
]);
|
]);
|
||||||
with_key
|
with_key
|
||||||
.validate()
|
.validate()
|
||||||
.expect("non_tls_required with a PSK should 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]
|
#[test]
|
||||||
fn cli_accepts_tunnel_ipv4_only() {
|
fn cli_accepts_tunnel_ipv4_only() {
|
||||||
let config = Config::parse_from([
|
let config = Config::parse_from([
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{debug, error, info};
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::{effective_tunnel_security, Config, ServerEntry, TunnelSecurity};
|
||||||
use crate::hardware::HardwareInfo;
|
use crate::hardware::HardwareInfo;
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -22,6 +22,10 @@ struct RegisterRequest {
|
|||||||
estimated_max_concurrency: Option<u64>,
|
estimated_max_concurrency: Option<u64>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
proxy_metadata: Option<serde_json::Value>,
|
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,
|
tunnel_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +99,17 @@ impl AetherClient {
|
|||||||
pub async fn register(
|
pub async fn register(
|
||||||
&self,
|
&self,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
server: &ServerEntry,
|
||||||
node_name: &str,
|
node_name: &str,
|
||||||
public_ip: &str,
|
public_ip: &str,
|
||||||
hw: Option<&HardwareInfo>,
|
hw: Option<&HardwareInfo>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
|
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 {
|
let body = RegisterRequest {
|
||||||
name: node_name.to_string(),
|
name: node_name.to_string(),
|
||||||
ip: public_ip.to_string(),
|
ip: public_ip.to_string(),
|
||||||
@@ -111,6 +121,11 @@ impl AetherClient {
|
|||||||
proxy_metadata: Some(serde_json::json!({
|
proxy_metadata: Some(serde_json::json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"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,
|
tunnel_mode: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ impl ServerTab {
|
|||||||
value: "off".into(),
|
value: "off".into(),
|
||||||
kind: FieldKind::Text,
|
kind: FieldKind::Text,
|
||||||
required: false,
|
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 {
|
Field {
|
||||||
label: "Tunnel Encryption Key",
|
label: "Tunnel Encryption Key",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use aether_runtime::{
|
|||||||
use aether_runtime_state::{RuntimeSemaphore, RuntimeSemaphoreError, RuntimeSemaphoreSnapshot};
|
use aether_runtime_state::{RuntimeSemaphore, RuntimeSemaphoreError, RuntimeSemaphoreSnapshot};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::config::TunnelSecurity;
|
||||||
use crate::hardware::RuntimeResourceMonitor;
|
use crate::hardware::RuntimeResourceMonitor;
|
||||||
use crate::registration::client::AetherClient;
|
use crate::registration::client::AetherClient;
|
||||||
use crate::runtime::SharedDynamicConfig;
|
use crate::runtime::SharedDynamicConfig;
|
||||||
@@ -44,6 +45,10 @@ pub struct ServerContext {
|
|||||||
pub aether_url: String,
|
pub aether_url: String,
|
||||||
/// Management token for this server.
|
/// Management token for this server.
|
||||||
pub management_token: String,
|
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).
|
/// 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).
|
/// After startup, the active node_name is read from `dynamic` (may be updated remotely).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ use crate::egress_proxy::{
|
|||||||
};
|
};
|
||||||
use crate::state::{AppState, ServerContext};
|
use crate::state::{AppState, ServerContext};
|
||||||
use aether_contracts::tunnel::{CURRENT_TUNNEL_PROTOCOL_VERSION, TUNNEL_PROTOCOL_VERSION_HEADER};
|
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};
|
use super::{dispatcher, heartbeat, writer};
|
||||||
|
|
||||||
@@ -45,16 +49,28 @@ pub async fn connect_and_run(
|
|||||||
// Build WebSocket request with auth headers
|
// Build WebSocket request with auth headers
|
||||||
let mut request = ws_url.clone().into_client_request()?;
|
let mut request = ws_url.clone().into_client_request()?;
|
||||||
let headers = request.headers_mut();
|
let headers = request.headers_mut();
|
||||||
headers.insert(
|
if server.tunnel_security != crate::config::TunnelSecurity::NonTlsRequired {
|
||||||
"Authorization",
|
headers.insert(
|
||||||
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
|
"Authorization",
|
||||||
);
|
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
headers.insert(
|
headers.insert(
|
||||||
TUNNEL_PROTOCOL_VERSION_HEADER,
|
TUNNEL_PROTOCOL_VERSION_HEADER,
|
||||||
http::HeaderValue::from_str(&CURRENT_TUNNEL_PROTOCOL_VERSION.to_string())?,
|
http::HeaderValue::from_str(&CURRENT_TUNNEL_PROTOCOL_VERSION.to_string())?,
|
||||||
);
|
);
|
||||||
let node_id = server.node_id.read().unwrap().clone();
|
let node_id = server.node_id.read().unwrap().clone();
|
||||||
headers.insert("X-Node-Id", http::HeaderValue::from_str(&node_id)?);
|
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
|
// Use dynamic node_name (may be updated by remote config) instead of
|
||||||
// the static server.node_name, so that remote name changes take effect
|
// the static server.node_name, so that remote name changes take effect
|
||||||
// on the next reconnect.
|
// on the next reconnect.
|
||||||
@@ -119,6 +135,19 @@ pub async fn connect_and_run(
|
|||||||
handshake_timeout.as_millis()
|
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
|
let stale_timeout = state
|
||||||
.config
|
.config
|
||||||
.tunnel_stale_timeout()
|
.tunnel_stale_timeout()
|
||||||
@@ -146,10 +175,11 @@ pub async fn connect_and_run(
|
|||||||
let (ws_sink, ws_read) = futures_util::StreamExt::split(ws_stream);
|
let (ws_sink, ws_read) = futures_util::StreamExt::split(ws_stream);
|
||||||
|
|
||||||
// Spawn writer task (with WebSocket ping keepalive)
|
// 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,
|
ws_sink,
|
||||||
ping_interval,
|
ping_interval,
|
||||||
Some(Arc::clone(&server.tunnel_metrics)),
|
Some(Arc::clone(&server.tunnel_metrics)),
|
||||||
|
security.clone(),
|
||||||
);
|
);
|
||||||
let drain_signal = spawn_drain_signal(conn_idx, frame_tx.clone(), drain.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 state_clone = Arc::clone(state);
|
||||||
let server_clone = Arc::clone(server);
|
let server_clone = Arc::clone(server);
|
||||||
let outcome = tokio::select! {
|
let outcome = tokio::select! {
|
||||||
result = dispatcher::run(
|
result = dispatcher::run_with_security(
|
||||||
state_clone,
|
state_clone,
|
||||||
server_clone,
|
server_clone,
|
||||||
ws_read,
|
ws_read,
|
||||||
frame_tx.clone(),
|
frame_tx.clone(),
|
||||||
hb_handle,
|
hb_handle,
|
||||||
drain.clone(),
|
drain.clone(),
|
||||||
|
security.clone(),
|
||||||
) => {
|
) => {
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => Ok(TunnelOutcome::Disconnected),
|
Ok(()) => Ok(TunnelOutcome::Disconnected),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use super::heartbeat::HeartbeatHandle;
|
|||||||
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
|
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
|
||||||
use super::stream_handler;
|
use super::stream_handler;
|
||||||
use super::writer::FrameSender;
|
use super::writer::FrameSender;
|
||||||
|
use aether_contracts::tunnel_security::SecureFrameCodec;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum StreamDispatchStatus {
|
enum StreamDispatchStatus {
|
||||||
@@ -27,13 +28,32 @@ enum StreamDispatchStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the dispatcher loop, reading from the WebSocket stream.
|
/// Run the dispatcher loop, reading from the WebSocket stream.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn run<S>(
|
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>,
|
state: Arc<AppState>,
|
||||||
server: Arc<ServerContext>,
|
server: Arc<ServerContext>,
|
||||||
mut ws_stream: S,
|
mut ws_stream: S,
|
||||||
frame_tx: FrameSender,
|
frame_tx: FrameSender,
|
||||||
heartbeat: HeartbeatHandle,
|
heartbeat: HeartbeatHandle,
|
||||||
mut drain: watch::Receiver<bool>,
|
mut drain: watch::Receiver<bool>,
|
||||||
|
security: Option<Arc<SecureFrameCodec>>,
|
||||||
) -> Result<(), anyhow::Error>
|
) -> Result<(), anyhow::Error>
|
||||||
where
|
where
|
||||||
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
|
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
|
||||||
@@ -130,6 +150,19 @@ where
|
|||||||
continue;
|
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 {
|
match frame.msg_type {
|
||||||
MsgType::RequestHeaders => {
|
MsgType::RequestHeaders => {
|
||||||
|
|||||||
@@ -425,6 +425,8 @@ mod tests {
|
|||||||
server_label: "heartbeat-test".to_string(),
|
server_label: "heartbeat-test".to_string(),
|
||||||
aether_url: config.aether_url.clone(),
|
aether_url: config.aether_url.clone(),
|
||||||
management_token: config.management_token.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_name: config.node_name.clone(),
|
||||||
node_id: Arc::new(RwLock::new("node-123".to_string())),
|
node_id: Arc::new(RwLock::new("node-123".to_string())),
|
||||||
aether_client: Arc::new(AetherClient::new(
|
aether_client: Arc::new(AetherClient::new(
|
||||||
|
|||||||
@@ -476,6 +476,8 @@ mod tests {
|
|||||||
server_label: "gateway-owned-tunnel".to_string(),
|
server_label: "gateway-owned-tunnel".to_string(),
|
||||||
aether_url: config.aether_url.clone(),
|
aether_url: config.aether_url.clone(),
|
||||||
management_token: config.management_token.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_name: config.node_name.clone(),
|
||||||
node_id: Arc::new(std::sync::RwLock::new(node_id.to_string())),
|
node_id: Arc::new(std::sync::RwLock::new(node_id.to_string())),
|
||||||
aether_client: Arc::new(AetherClient::new(
|
aether_client: Arc::new(AetherClient::new(
|
||||||
|
|||||||
@@ -2491,6 +2491,8 @@ mod tests {
|
|||||||
server_label: "server".to_string(),
|
server_label: "server".to_string(),
|
||||||
aether_url: config.aether_url.clone(),
|
aether_url: config.aether_url.clone(),
|
||||||
management_token: config.management_token.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_name: config.node_name.clone(),
|
||||||
node_id: Arc::new(std::sync::RwLock::new("node-1".to_string())),
|
node_id: Arc::new(std::sync::RwLock::new("node-1".to_string())),
|
||||||
aether_client: Arc::new(AetherClient::new(
|
aether_client: Arc::new(AetherClient::new(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tracing::{debug, error, trace};
|
|||||||
use crate::state::TunnelMetrics;
|
use crate::state::TunnelMetrics;
|
||||||
|
|
||||||
use super::protocol::Frame;
|
use super::protocol::Frame;
|
||||||
|
use aether_contracts::tunnel_security::SecureFrameCodec;
|
||||||
|
|
||||||
const HIGH_PRIORITY_QUEUE_CAPACITY: usize = 64;
|
const HIGH_PRIORITY_QUEUE_CAPACITY: usize = 64;
|
||||||
const NORMAL_PRIORITY_QUEUE_CAPACITY: usize = 256;
|
const NORMAL_PRIORITY_QUEUE_CAPACITY: usize = 256;
|
||||||
@@ -89,10 +90,23 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn the writer task with optional tunnel metrics instrumentation.
|
/// Spawn the writer task with optional tunnel metrics instrumentation.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn spawn_writer_with_metrics<S>(
|
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,
|
mut sink: S,
|
||||||
ping_interval: Duration,
|
ping_interval: Duration,
|
||||||
tunnel_metrics: Option<Arc<TunnelMetrics>>,
|
tunnel_metrics: Option<Arc<TunnelMetrics>>,
|
||||||
|
security: Option<Arc<SecureFrameCodec>>,
|
||||||
) -> (FrameSender, JoinHandle<()>)
|
) -> (FrameSender, JoinHandle<()>)
|
||||||
where
|
where
|
||||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
|
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
|
||||||
@@ -109,7 +123,14 @@ where
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Ok(frame) = high_rx.try_recv() {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -123,7 +144,7 @@ where
|
|||||||
frame = high_rx.recv(), if high_open => {
|
frame = high_rx.recv(), if high_open => {
|
||||||
match frame {
|
match frame {
|
||||||
Some(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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +164,7 @@ where
|
|||||||
frame = normal_rx.recv(), if normal_open => {
|
frame = normal_rx.recv(), if normal_open => {
|
||||||
match frame {
|
match frame {
|
||||||
Some(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;
|
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
|
where
|
||||||
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
|
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
let stream_id = frame.stream_id;
|
let stream_id = frame.stream_id;
|
||||||
let msg_type = frame.msg_type;
|
let msg_type = frame.msg_type;
|
||||||
let flags = frame.flags;
|
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);
|
let wire_len = data.len().max(HEADER_SIZE);
|
||||||
if let Err(e) = sink.send(Message::Binary(data.into())).await {
|
if let Err(e) = sink.send(Message::Binary(data.into())).await {
|
||||||
error!(
|
error!(
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ repository.workspace = true
|
|||||||
description = "Shared contracts for Python and Rust Aether components"
|
description = "Shared contracts for Python and Rust Aether components"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aes-gcm.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
|
hmac.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod frame;
|
|||||||
mod plan;
|
mod plan;
|
||||||
mod result;
|
mod result;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
pub mod tunnel_security;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
|
pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub const CURRENT_TUNNEL_PROTOCOL_VERSION_STR: &str = "2";
|
|||||||
pub mod flags {
|
pub mod flags {
|
||||||
pub const END_STREAM: u8 = 0x01;
|
pub const END_STREAM: u8 = 0x01;
|
||||||
pub const GZIP_COMPRESSED: u8 = 0x02;
|
pub const GZIP_COMPRESSED: u8 = 0x02;
|
||||||
|
pub const ENCRYPTED: u8 = crate::tunnel_security::FLAG_ENCRYPTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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 HEARTBEAT_ACK: u8 = MsgType::HeartbeatAck as u8;
|
||||||
pub const FLAG_END_STREAM: u8 = flags::END_STREAM;
|
pub const FLAG_END_STREAM: u8 = flags::END_STREAM;
|
||||||
pub const FLAG_GZIP_COMPRESSED: u8 = flags::GZIP_COMPRESSED;
|
pub const FLAG_GZIP_COMPRESSED: u8 = flags::GZIP_COMPRESSED;
|
||||||
|
pub const FLAG_ENCRYPTED: u8 = flags::ENCRYPTED;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct FrameHeader {
|
pub struct FrameHeader {
|
||||||
|
|||||||
245
crates/aether-contracts/src/tunnel_security.rs
Normal file
245
crates/aether-contracts/src/tunnel_security.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user