feat(security): harden gateway boundaries and usage policies

Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change.

Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
This commit is contained in:
elky
2026-09-04 03:45:52 +08:00
parent ddcbeb3ae9
commit 579f2c7cc1
1019 changed files with 190437 additions and 26080 deletions
+136 -20
View File
@@ -3,7 +3,7 @@
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use tokio::net::TcpStream;
@@ -13,6 +13,7 @@ use tokio_tungstenite::tungstenite::http;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tracing::{debug, info, warn};
use crate::config::aether_url_for_log;
use crate::egress_proxy::{
connect_target_via_proxy, IpFamily, ProxyConnectOptions, UpstreamProxyConfig,
};
@@ -22,8 +23,10 @@ use aether_contracts::tunnel::{
TUNNEL_PROTOCOL_VERSION_HEADER,
};
use aether_contracts::tunnel_security::{
SecureFrameCodec, TunnelSecurityRole, TUNNEL_SECURITY_HEADER, TUNNEL_SECURITY_NON_TLS_REQUIRED,
TUNNEL_SECURITY_SESSION_HEADER,
sign_tunnel_security_handshake_for_generation, SecureFrameCodec, TunnelSecurityRole,
TUNNEL_GENERATION_HEADER, TUNNEL_SECURITY_HEADER, TUNNEL_SECURITY_NON_TLS_REQUIRED,
TUNNEL_SECURITY_PROOF_NONCE_HEADER, TUNNEL_SECURITY_PROOF_SIGNATURE_HEADER,
TUNNEL_SECURITY_PROOF_TIMESTAMP_HEADER, TUNNEL_SECURITY_SESSION_HEADER,
};
use super::{dispatcher, heartbeat, writer};
@@ -48,7 +51,7 @@ pub async fn connect_and_run(
drain: watch::Receiver<bool>,
) -> Result<TunnelOutcome, anyhow::Error> {
let ws_url = build_tunnel_url(server);
debug!(url = %ws_url, conn = conn_idx, "connecting tunnel");
debug!(url = %aether_url_for_log(&ws_url), conn = conn_idx, "connecting tunnel");
// Build WebSocket request with auth headers
let mut request = ws_url.clone().into_client_request()?;
@@ -67,19 +70,38 @@ pub async fn connect_and_run(
);
let node_id = server.node_id.read().unwrap().clone();
insert_ascii_header(headers, "X-Node-Id", &node_id, "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),
);
insert_ascii_header(
headers,
TUNNEL_SECURITY_SESSION_HEADER,
&security_session,
"tunnel security session",
)?;
}
insert_ascii_header(
headers,
TUNNEL_GENERATION_HEADER,
&server.tunnel_generation,
"tunnel_generation",
)?;
let security_session =
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"))?;
let session = uuid::Uuid::new_v4().simple().to_string();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| anyhow::anyhow!("system clock is before the Unix epoch"))?
.as_secs();
insert_tunnel_security_handshake_headers(
headers,
key,
&node_id,
&server.tunnel_generation,
&session,
CURRENT_TUNNEL_PROTOCOL_VERSION,
timestamp,
&nonce,
)?;
session
} else {
String::new()
};
// 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.
@@ -449,9 +471,10 @@ async fn connect_direct_tunnel_tcp(
port: u16,
ip_family: IpFamily,
) -> io::Result<TcpStream> {
let resolved = tokio::net::lookup_host((host, port))
.await
.map_err(|err| io::Error::other(format!("tunnel DNS failed: {err}")))?;
let resolved =
aether_http::lookup_host_with_limits(host, port, aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT)
.await
.map_err(|err| io::Error::other(format!("tunnel DNS failed: {err}")))?;
let addrs = filter_socket_addrs(resolved, ip_family);
if addrs.is_empty() {
@@ -540,6 +563,57 @@ fn insert_ascii_header(
Ok(())
}
fn insert_tunnel_security_handshake_headers(
headers: &mut http::HeaderMap,
key: &str,
node_id: &str,
tunnel_generation: &str,
session: &str,
protocol_version: u8,
timestamp_unix_secs: u64,
nonce: &str,
) -> anyhow::Result<()> {
let signature = sign_tunnel_security_handshake_for_generation(
key,
node_id,
tunnel_generation,
TUNNEL_SECURITY_NON_TLS_REQUIRED,
session,
protocol_version,
timestamp_unix_secs,
nonce,
)?;
headers.insert(
TUNNEL_SECURITY_HEADER,
http::HeaderValue::from_static(TUNNEL_SECURITY_NON_TLS_REQUIRED),
);
insert_ascii_header(
headers,
TUNNEL_SECURITY_SESSION_HEADER,
session,
"tunnel security session",
)?;
insert_ascii_header(
headers,
TUNNEL_SECURITY_PROOF_TIMESTAMP_HEADER,
&timestamp_unix_secs.to_string(),
"tunnel security proof timestamp",
)?;
insert_ascii_header(
headers,
TUNNEL_SECURITY_PROOF_NONCE_HEADER,
nonce,
"tunnel security proof nonce",
)?;
insert_ascii_header(
headers,
TUNNEL_SECURITY_PROOF_SIGNATURE_HEADER,
&signature,
"tunnel security proof signature",
)?;
Ok(())
}
fn insert_node_name_headers(headers: &mut http::HeaderMap, node_name: &str) -> anyhow::Result<()> {
if node_name.is_ascii() {
return insert_ascii_header(headers, "X-Node-Name", node_name, "node_name");
@@ -620,4 +694,46 @@ mod tests {
.expect("encoded value should decode");
assert_eq!(decoded, "日本节点".as_bytes());
}
#[test]
fn tunnel_security_headers_include_verifiable_psk_proof() {
let key = base64::engine::general_purpose::STANDARD.encode([7_u8; 32]);
let mut headers = http::HeaderMap::new();
insert_tunnel_security_handshake_headers(
&mut headers,
&key,
"node-1",
"generation-1",
"0123456789abcdef0123456789abcdef",
CURRENT_TUNNEL_PROTOCOL_VERSION,
1_700_000_000,
"abcdef0123456789abcdef0123456789",
)
.expect("security proof headers");
let signature = headers[TUNNEL_SECURITY_PROOF_SIGNATURE_HEADER]
.to_str()
.expect("signature header");
assert_eq!(
headers[TUNNEL_SECURITY_HEADER],
TUNNEL_SECURITY_NON_TLS_REQUIRED
);
assert_eq!(
headers[TUNNEL_SECURITY_PROOF_TIMESTAMP_HEADER],
"1700000000"
);
assert!(
aether_contracts::tunnel_security::verify_tunnel_security_handshake_for_generation(
&key,
"node-1",
"generation-1",
TUNNEL_SECURITY_NON_TLS_REQUIRED,
"0123456789abcdef0123456789abcdef",
CURRENT_TUNNEL_PROTOCOL_VERSION,
1_700_000_000,
"abcdef0123456789abcdef0123456789",
signature,
)
);
}
}
+223 -15
View File
@@ -1,13 +1,14 @@
//! Frame dispatcher: reads incoming WebSocket frames and routes them.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::mem::size_of;
use std::sync::Arc;
use std::sync::LazyLock;
use std::time::Duration;
use bytes::Bytes;
use futures_util::StreamExt;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, Semaphore};
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, error, info, warn};
@@ -15,12 +16,27 @@ use tracing::{debug, error, info, warn};
use crate::state::{AppState, ServerContext};
use super::heartbeat::HeartbeatHandle;
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
use super::protocol::{decompress_if_gzip_with_limit, Frame, MsgType, RequestMeta};
use super::stream_handler;
use super::stream_handler::StreamSendWindow;
use super::writer::FrameSender;
use aether_contracts::tunnel_security::SecureFrameCodec;
const REQUEST_BODY_QUEUE_BUDGET_BYTES: usize = 256 * 1024 * 1024;
static REQUEST_BODY_QUEUE_BUDGET: LazyLock<Arc<Semaphore>> =
LazyLock::new(|| Arc::new(Semaphore::new(REQUEST_BODY_QUEUE_BUDGET_BYTES)));
struct BudgetedFramePayload {
bytes: Bytes,
_permit: OwnedSemaphorePermit,
}
impl AsRef<[u8]> for BudgetedFramePayload {
fn as_ref(&self) -> &[u8] {
self.bytes.as_ref()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StreamDispatchStatus {
Delivered,
@@ -34,6 +50,24 @@ struct StreamDispatchTarget {
response_window: Arc<StreamSendWindow>,
}
/// A request stream is identified by a non-zero id and may only be opened
/// once while its handler is active. Replacing an entry in `streams` would
/// orphan the old body channel while still spawning another handler, making
/// the active-stream limit ineffective and allowing unbounded task growth.
fn validate_request_stream_id(
streams: &HashMap<u32, StreamDispatchTarget>,
active_handler_ids: &HashSet<u32>,
stream_id: u32,
) -> Result<(), &'static str> {
if stream_id == 0 {
return Err("invalid stream id");
}
if streams.contains_key(&stream_id) || active_handler_ids.contains(&stream_id) {
return Err("duplicate stream id");
}
Ok(())
}
/// Run the dispatcher loop, reading from the WebSocket stream.
#[allow(dead_code)]
pub async fn run<S>(
@@ -70,6 +104,10 @@ where
{
// Active streams: stream_id -> body sender + response flow-control window.
let mut streams: HashMap<u32, StreamDispatchTarget> = HashMap::new();
// A handler can outlive its routing entry when body dispatch fails. Keep
// its id reserved until the handler reports completion so a peer cannot
// reopen the same id and bypass the stream admission limit.
let mut active_handler_ids: HashSet<u32> = HashSet::new();
// Track spawned stream handlers so we can wait for them on shutdown
let mut handler_handles: Vec<JoinHandle<()>> = Vec::new();
let (handler_finished_tx, mut handler_finished_rx) = mpsc::unbounded_channel::<u32>();
@@ -85,7 +123,7 @@ where
let mut draining = *drain.borrow();
let read_err = loop {
if draining && streams.is_empty() {
if draining && streams.is_empty() && active_handler_ids.is_empty() {
info!("tunnel drained after in-flight streams completed");
break None;
}
@@ -109,8 +147,9 @@ where
}
finished = handler_finished_rx.recv() => {
if let Some(stream_id) = finished {
active_handler_ids.remove(&stream_id);
streams.remove(&stream_id);
if draining && streams.is_empty() {
if draining && streams.is_empty() && active_handler_ids.is_empty() {
info!("tunnel drained after stream handler completion");
break None;
}
@@ -184,6 +223,20 @@ where
match frame.msg_type {
MsgType::RequestHeaders => {
if let Err(reason) =
validate_request_stream_id(&streams, &active_handler_ids, frame.stream_id)
{
warn!(
stream_id = frame.stream_id,
reason, "rejecting request headers with invalid stream id"
);
// Zero is reserved for connection-level control frames,
// so do not emit a stream-scoped error using that id.
if frame.stream_id != 0 {
try_send_stream_error(&frame_tx, frame.stream_id, reason);
}
continue;
}
if draining {
if frame_tx
.try_send(Frame::new(
@@ -203,7 +256,10 @@ where
}
// Decompress if the frame is gzip-compressed, then parse metadata
let payload = match decompress_if_gzip(&frame) {
let payload = match decompress_if_gzip_with_limit(
&frame,
aether_contracts::tunnel::MAX_TUNNEL_RELAY_META_LEN,
) {
Ok(p) => p,
Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "frame decompress failed");
@@ -233,7 +289,7 @@ where
}
};
if streams.len() >= max_streams {
if active_handler_ids.len() >= max_streams {
warn!(
stream_id = frame.stream_id,
"max concurrent streams reached"
@@ -267,6 +323,7 @@ where
response_window: Arc::clone(&response_window),
},
);
active_handler_ids.insert(frame.stream_id);
let request_headers_end_stream = frame.is_end_stream();
let state_clone = Arc::clone(&state);
@@ -321,7 +378,8 @@ where
"tunnel request body dispatch stalled",
);
}
if is_end && draining && streams.is_empty() {
if is_end && draining && streams.is_empty() && active_handler_ids.is_empty()
{
info!("tunnel drained after request body completion");
break None;
}
@@ -333,7 +391,7 @@ where
// Client-side cancellation or end
if let Some(target) = streams.remove(&frame.stream_id) {
let _ = dispatch_stream_frame(&target.body_tx, frame).await;
if draining && streams.is_empty() {
if draining && streams.is_empty() && active_handler_ids.is_empty() {
info!("tunnel drained after stream termination");
break None;
}
@@ -399,7 +457,7 @@ where
if frames_since_cleanup >= 64 || handler_handles.len() > max_streams {
handler_handles.retain(|h| !h.is_finished());
frames_since_cleanup = 0;
if draining && streams.is_empty() {
if draining && streams.is_empty() && active_handler_ids.is_empty() {
info!("tunnel drained after cleanup");
break None;
}
@@ -421,12 +479,18 @@ where
async fn dispatch_stream_frame(tx: &mpsc::Sender<Frame>, frame: Frame) -> StreamDispatchStatus {
let stream_id = frame.stream_id;
match tokio::time::timeout(stream_frame_dispatch_timeout(), tx.send(frame)).await {
Ok(Ok(())) => StreamDispatchStatus::Delivered,
Ok(Err(_)) => {
let dispatched = tokio::time::timeout(stream_frame_dispatch_timeout(), async {
let frame = attach_request_body_queue_budget(frame).await?;
tx.send(frame).await.ok()?;
Some(())
})
.await;
match dispatched {
Ok(Some(())) => StreamDispatchStatus::Delivered,
Ok(None) => {
warn!(
stream_id,
"stream handler channel closed while dispatching tunnel frame"
"stream handler channel or request body budget closed while dispatching tunnel frame"
);
StreamDispatchStatus::Closed
}
@@ -441,6 +505,50 @@ async fn dispatch_stream_frame(tx: &mpsc::Sender<Frame>, frame: Frame) -> Stream
}
}
async fn attach_request_body_queue_budget(frame: Frame) -> Option<Frame> {
attach_request_body_queue_budget_with(
frame,
Arc::clone(&REQUEST_BODY_QUEUE_BUDGET),
REQUEST_BODY_QUEUE_BUDGET_BYTES,
)
.await
}
async fn attach_request_body_queue_budget_with(
mut frame: Frame,
budget: Arc<Semaphore>,
budget_bytes: usize,
) -> Option<Frame> {
if frame.msg_type != MsgType::RequestBody {
return Some(frame);
}
let permits = request_body_queue_permits(&frame, budget_bytes)?;
let permit = budget.acquire_many_owned(permits).await.ok()?;
frame.payload = Bytes::from_owner(BudgetedFramePayload {
bytes: frame.payload,
_permit: permit,
});
Some(frame)
}
fn request_body_queue_permits(frame: &Frame, budget_bytes: usize) -> Option<u32> {
let decoded_budget = if frame.is_gzip() {
aether_contracts::tunnel::MAX_TUNNEL_DECOMPRESSED_PAYLOAD_BYTES
} else {
0
};
let retained_bytes = frame
.payload
.len()
.checked_add(decoded_budget)?
.checked_add(size_of::<Frame>())?
.max(1);
if retained_bytes > budget_bytes {
return None;
}
u32::try_from(retained_bytes).ok()
}
/// Bound how long a single stream handler is allowed to block the shared
/// WebSocket read loop while receiving request-body frames.
fn stream_frame_dispatch_timeout() -> Duration {
@@ -497,6 +605,7 @@ async fn drain_handlers(handles: Vec<JoinHandle<()>>) {
#[cfg(test)]
mod tests {
use super::*;
use aether_contracts::tunnel::{compress_payload, flags};
use aether_runtime::bounded_queue;
#[tokio::test]
@@ -534,6 +643,70 @@ mod tests {
assert_eq!(retained.payload, Bytes::from_static(b"first"));
}
#[tokio::test]
async fn request_body_queue_budget_releases_when_frame_is_dropped() {
const BUDGET_BYTES: usize = 4096;
let budget = Arc::new(Semaphore::new(BUDGET_BYTES));
let frame = Frame::new(
7,
MsgType::RequestBody,
0,
Bytes::from_static(b"request body"),
);
let permits = request_body_queue_permits(&frame, BUDGET_BYTES).expect("permit count");
let frame = attach_request_body_queue_budget_with(frame, Arc::clone(&budget), BUDGET_BYTES)
.await
.expect("frame should fit the queue budget");
assert_eq!(budget.available_permits(), BUDGET_BYTES - permits as usize);
drop(frame);
assert_eq!(budget.available_permits(), BUDGET_BYTES);
}
#[tokio::test]
async fn gzip_request_body_budget_follows_decoded_payload_lifetime() {
let (payload, frame_flags) = compress_payload(Bytes::from(vec![b'x'; 1024]));
assert_eq!(frame_flags, flags::GZIP_COMPRESSED);
let frame = Frame::new(7, MsgType::RequestBody, frame_flags, payload);
let required = request_body_queue_permits(&frame, REQUEST_BODY_QUEUE_BUDGET_BYTES)
.expect("gzip frame should fit the queue budget") as usize;
let budget = Arc::new(Semaphore::new(required));
let frame = attach_request_body_queue_budget_with(frame, Arc::clone(&budget), required)
.await
.expect("frame should acquire the entire local budget");
assert_eq!(budget.available_permits(), 0);
let decoded = stream_handler::decode_request_body_frame(frame)
.expect("gzip request body should decode");
assert_eq!(decoded, Bytes::from(vec![b'x'; 1024]));
assert_eq!(budget.available_permits(), 0);
drop(decoded);
assert_eq!(budget.available_permits(), required);
}
#[tokio::test]
async fn gzip_request_body_budget_releases_after_decode_error() {
let frame = Frame::new(
7,
MsgType::RequestBody,
flags::GZIP_COMPRESSED,
Bytes::from_static(b"not gzip"),
);
let required = request_body_queue_permits(&frame, REQUEST_BODY_QUEUE_BUDGET_BYTES)
.expect("gzip frame should fit the queue budget") as usize;
let budget = Arc::new(Semaphore::new(required));
let frame = attach_request_body_queue_budget_with(frame, Arc::clone(&budget), required)
.await
.expect("frame should acquire the entire local budget");
assert_eq!(budget.available_permits(), 0);
stream_handler::decode_request_body_frame(frame)
.expect_err("invalid gzip request body should fail");
assert_eq!(budget.available_permits(), required);
}
#[tokio::test]
async fn try_send_stream_error_emits_stream_error_frame() {
let (high_tx, mut high_rx) = bounded_queue::<Frame>(4);
@@ -581,4 +754,39 @@ mod tests {
assert!(!streams.contains_key(&7));
assert!(streams.contains_key(&9));
}
#[test]
fn request_stream_id_rejects_zero_and_active_duplicates() {
let (tx, _rx) = mpsc::channel::<Frame>(1);
let streams = HashMap::from([(
7,
StreamDispatchTarget {
body_tx: tx,
response_window: Arc::new(StreamSendWindow::new(1024)),
},
)]);
let mut active_handler_ids = HashSet::from([7]);
assert_eq!(
validate_request_stream_id(&streams, &active_handler_ids, 0),
Err("invalid stream id")
);
assert_eq!(
validate_request_stream_id(&streams, &active_handler_ids, 7),
Err("duplicate stream id")
);
assert_eq!(
validate_request_stream_id(&streams, &active_handler_ids, 9),
Ok(())
);
// The routing entry may be removed after a dispatch failure while the
// handler is still running; its reservation must continue to reject
// a new request with the same id.
active_handler_ids.insert(11);
assert_eq!(
validate_request_stream_id(&HashMap::new(), &active_handler_ids, 11),
Err("duplicate stream id")
);
}
}
+27 -3
View File
@@ -346,10 +346,12 @@ fn normalize_upgrade_target(raw: String) -> Option<String> {
.strip_prefix("tunnel-v")
.or_else(|| trimmed.strip_prefix("proxy-v"))
.unwrap_or(trimmed);
if normalized == CURRENT_VERSION {
let target = semver::Version::parse(normalized).ok()?;
let current = semver::Version::parse(CURRENT_VERSION).ok()?;
if target <= current {
return None;
}
Some(normalized.to_string())
Some(target.to_string())
}
fn maybe_trigger_upgrade(version: Option<String>) {
@@ -402,7 +404,10 @@ mod tests {
use arc_swap::ArcSwap;
use clap::Parser;
use super::{build_heartbeat_payload, handle_ack, AckDecision, HeartbeatSnapshot};
use super::{
build_heartbeat_payload, handle_ack, normalize_upgrade_target, AckDecision,
HeartbeatSnapshot, CURRENT_VERSION,
};
use crate::registration::client::AetherClient;
use crate::runtime::DynamicConfig;
use crate::state::{AppState, ServerContext, TunnelMetrics, TunnelRequestMetrics};
@@ -429,6 +434,7 @@ mod tests {
tunnel_encryption_key: config.tunnel_encryption_key.clone(),
node_name: config.node_name.clone(),
node_id: Arc::new(RwLock::new("node-123".to_string())),
tunnel_generation: "test-generation-1".to_string(),
aether_client: Arc::new(AetherClient::new(
&config,
&config.aether_url,
@@ -489,6 +495,24 @@ mod tests {
assert_eq!(server.dynamic.load().heartbeat_interval, 9);
}
#[test]
fn remote_upgrade_accepts_only_strict_semver_upgrades() {
let current = semver::Version::parse(CURRENT_VERSION).expect("package version is semver");
let target = semver::Version::new(current.major + 1, 0, 0);
assert_eq!(
normalize_upgrade_target(format!("tunnel-v{target}")),
Some(target.to_string())
);
assert_eq!(normalize_upgrade_target(CURRENT_VERSION.to_string()), None);
assert_eq!(normalize_upgrade_target("0.0.1".to_string()), None);
assert_eq!(
normalize_upgrade_target("1.2.3/../../payload".to_string()),
None
);
assert_eq!(normalize_upgrade_target("latest".to_string()), None);
}
#[tokio::test]
async fn heartbeat_payload_reports_resource_usage_and_tunnel_error_diagnostics() {
let config = sample_config();
+76 -4
View File
@@ -231,8 +231,14 @@ fn mix_u64(mut x: u64) -> u64 {
mod tests {
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Once};
use std::time::Duration;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aether_contracts::tunnel::{
sign_tunnel_relay_request, tunnel_relay_payload_digest, TUNNEL_RELAY_AUTH_NONCE_HEADER,
TUNNEL_RELAY_AUTH_PAYLOAD_HEADER, TUNNEL_RELAY_AUTH_SENDER_HEADER,
TUNNEL_RELAY_AUTH_SIGNATURE_HEADER, TUNNEL_RELAY_AUTH_TIMESTAMP_HEADER,
TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
};
use aether_gateway::{build_router_with_state, AppState as GatewayAppState};
use arc_swap::ArcSwap;
use axum::Router;
@@ -303,7 +309,11 @@ mod tests {
.await
.expect("gateway should start");
let state = sample_state(sample_config(&gateway_base_url));
let mut tunnel_config = sample_config(&gateway_base_url);
tunnel_config.tunnel_security = crate::config::TunnelSecurity::NonTlsRequired;
tunnel_config.tunnel_encryption_key =
Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=".to_string());
let state = sample_state(tunnel_config);
let server = sample_server(&state, "node-recovery");
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let tunnel_task = tokio::spawn({
@@ -370,12 +380,45 @@ mod tests {
gateway_base_url: &str,
node_id: &str,
) -> Option<(StatusCode, String)> {
let payload = relay_probe_envelope();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("test clock should be after epoch")
.as_secs();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let digest = tunnel_relay_payload_digest(&payload, &[]);
let signature = sign_tunnel_relay_request(
b"tunnel-reconnect-test-secret-at-least-32-bytes",
"tunnel-reconnect-test-client",
"tunnel-reconnect-test-gateway",
node_id,
"",
false,
timestamp,
&nonce,
&digest,
);
let response = reqwest::Client::new()
.post(format!(
"{gateway_base_url}/api/internal/tunnel/relay/{node_id}"
))
.header("content-type", "application/octet-stream")
.body(relay_probe_envelope())
.header(
TUNNEL_RELAY_AUTH_SENDER_HEADER,
"tunnel-reconnect-test-client",
)
.header(
TUNNEL_RELAY_OWNER_INSTANCE_HEADER,
"tunnel-reconnect-test-gateway",
)
.header(TUNNEL_RELAY_AUTH_TIMESTAMP_HEADER, timestamp)
.header(TUNNEL_RELAY_AUTH_NONCE_HEADER, nonce)
.header(
TUNNEL_RELAY_AUTH_PAYLOAD_HEADER,
digest.encode_header_value(),
)
.header(TUNNEL_RELAY_AUTH_SIGNATURE_HEADER, signature)
.body(payload)
.send()
.await
.ok()?;
@@ -411,12 +454,40 @@ mod tests {
async fn start_gateway_on_port(
port: u16,
) -> Result<(GatewayAppState, tokio::task::JoinHandle<()>), std::io::Error> {
let state = GatewayAppState::new().expect("gateway test state should build");
// The embedded gateway now fails closed when relay authentication is
// not configured. Keep this integration fixture explicitly authenticated.
let previous_secret = std::env::var_os("AETHER_TUNNEL_RELAY_AUTH_SECRET");
let previous_instance = std::env::var_os("AETHER_GATEWAY_INSTANCE_ID");
std::env::set_var(
"AETHER_TUNNEL_RELAY_AUTH_SECRET",
"tunnel-reconnect-test-secret-at-least-32-bytes",
);
std::env::set_var(
"AETHER_GATEWAY_INSTANCE_ID",
"tunnel-reconnect-test-gateway",
);
let mut state = GatewayAppState::new().expect("gateway test state should build");
aether_gateway::configure_test_tunnel_security(
&mut state,
"node-recovery",
"test-generation-1",
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
);
restore_test_env("AETHER_TUNNEL_RELAY_AUTH_SECRET", previous_secret);
restore_test_env("AETHER_GATEWAY_INSTANCE_ID", previous_instance);
let router = build_router_with_state(state.clone());
let handle = spawn_router_on_port(port, router).await?;
Ok((state, handle))
}
fn restore_test_env(key: &str, value: Option<std::ffi::OsString>) {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
async fn start_gateway_on_port_retry(
port: u16,
) -> Result<(GatewayAppState, tokio::task::JoinHandle<()>), std::io::Error> {
@@ -483,6 +554,7 @@ mod tests {
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())),
tunnel_generation: "test-generation-1".to_string(),
aether_client: Arc::new(AetherClient::new(
&config,
&config.aether_url,
File diff suppressed because it is too large Load Diff