Merge pull request #850 from AAEE86/ci/gateway-test-slim-batch1

ci(gateway): reduce Test (Gateway) runtime without duplicate execution
This commit is contained in:
ZheFox
2026-09-24 12:22:27 +08:00
committed by GitHub
41 changed files with 1157 additions and 764 deletions
+12 -1
View File
@@ -799,6 +799,7 @@ mod tests {
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
use aes_gcm::Aes256Gcm;
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use base64::Engine as _;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use serde_json::json;
@@ -1243,8 +1244,18 @@ mod tests {
assert_eq!(restored.key_id, None);
assert_eq!(restored.export_version.as_deref(), Some("2.3"));
// 17 个互不相同的合法 base64-32 字节直接密钥:本段只验证“legacy 候选 >16 → TooManyLegacyKeys”,
// 不测口令强度、不解密。直接密钥走 decode_direct_fernet_key(生产已支持路径),跳过 PBKDF2,
// 避免本用例为计数语义再付 17×10 万次迭代;上半段 DEVELOPMENT_ENCRYPTION_KEY 真实 v1 兼容
// 与 wrong-legacy-secret 派生路径保持不变。
let too_many: Vec<_> = (0..17)
.map(|index| BackupDecryptionKey::historical(format!("legacy-{index}")).unwrap())
.map(|index| {
let mut material = [0u8; 32];
material[0] = index as u8 + 1;
material[31] = index as u8 + 1;
let secret = base64::engine::general_purpose::STANDARD.encode(material);
BackupDecryptionKey::historical(secret).unwrap()
})
.collect();
assert!(matches!(
restore_backup_json(
@@ -5165,15 +5165,6 @@ mod tests {
))
}
fn provider_catalog_credential_state() -> AppState {
AppState::new()
.expect("credential state should build")
.with_data_state_for_tests(
GatewayDataState::disabled()
.with_encryption_key_for_tests(aether_crypto::DEVELOPMENT_ENCRYPTION_KEY),
)
}
fn large_pool_fixture(
key_count: usize,
provider_config: Option<serde_json::Value>,
@@ -5224,18 +5215,12 @@ mod tests {
)
.expect("endpoint transport should build");
let credential_state = provider_catalog_credential_state();
// 这些用例只验证池扫描、跳过计数和游标预算,不会发起请求或读取凭据。
// 留空凭据可跳过无关的 Fernet 加解密,同时避免复用绑定密文破坏 key_id AAD。
let mut keys = Vec::with_capacity(key_count);
let mut rows = Vec::with_capacity(key_count);
for index in 0..key_count {
let key_id = format!("key-{index:05}");
let encrypted_api_key = credential_state
.seal_provider_catalog_key_api_key(
"provider-pool",
&key_id,
&format!("secret-{index}"),
)
.expect("api key should encrypt");
let mut key = StoredProviderCatalogKey::new(
key_id.clone(),
"provider-pool".to_string(),
@@ -5247,7 +5232,7 @@ mod tests {
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:chat"])),
encrypted_api_key,
None,
None,
None,
None,
@@ -5382,10 +5367,8 @@ mod tests {
.expect("endpoint transport should build")
}
/// 这些测试只检查池调度状态,不涉及凭据解密,因此不构造无关的密文。
fn sample_codex_pool_key(provider_id: &str, key_id: &str) -> StoredProviderCatalogKey {
let encrypted_api_key = provider_catalog_credential_state()
.seal_provider_catalog_key_api_key(provider_id, key_id, &format!("secret-{key_id}"))
.expect("api key should encrypt");
let mut key = StoredProviderCatalogKey::new(
key_id.to_string(),
provider_id.to_string(),
@@ -5397,7 +5380,7 @@ mod tests {
.expect("key should build")
.with_transport_fields(
Some(json!(["openai:responses"])),
encrypted_api_key,
None,
None,
None,
Some(json!({"openai:responses": 1})),
@@ -44,21 +44,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(PROVIDER_KEYS_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("provider keys test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
PROVIDER_KEYS_TEST_STACK_BYTES,
make_future,
);
}
struct SummaryNullingProviderCatalogReadRepository {
@@ -59,21 +59,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(PROVIDER_QUOTA_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("provider quota test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
PROVIDER_QUOTA_TEST_STACK_BYTES,
make_future,
);
}
#[tokio::test]
@@ -56,21 +56,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(ADMIN_OAUTH_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("admin oauth test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
ADMIN_OAUTH_TEST_STACK_BYTES,
make_future,
);
}
fn decrypt_persisted_provider_api_key(key: &StoredProviderCatalogKey) -> String {
@@ -68,21 +68,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(PROVIDER_OPS_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("provider ops test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
PROVIDER_OPS_TEST_STACK_BYTES,
make_future,
);
}
async fn start_managed_redis_or_skip() -> Option<ManagedRedisServer> {
@@ -36,21 +36,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(PROVIDER_QUERY_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("provider query test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
PROVIDER_QUERY_TEST_STACK_BYTES,
make_future,
);
}
fn crc32(data: &[u8]) -> u32 {
@@ -239,11 +239,25 @@ async fn send_admin_security_request(
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value, usize) {
let path = path.to_string();
crate::tests::run_async_test_on_large_stack_with_result(
"admin-security-router-request",
16 * 1024 * 1024,
move || send_admin_security_request_on_large_stack(gateway, method, path, body),
)
}
async fn send_admin_security_request_on_large_stack(
gateway: Router,
method: reqwest::Method,
path: String,
body: Option<serde_json::Value>,
) -> (StatusCode, serde_json::Value, usize) {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
path,
&path,
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
@@ -253,26 +267,52 @@ async fn send_admin_security_request(
}),
);
let (upstream_url, upstream_handle) = start_server(upstream).await;
let (gateway_url, gateway_handle) = start_server(gateway).await;
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let client = reqwest::Client::new();
let mut request = client
.request(method, format!("{gateway_url}{path}"))
// 这些用例只验证本地安全路由和“不得转发”断言,不需要为 Gateway
// 再启动一个 TCP listener;send_request 会补齐 ConnectInfo,仍经过完整 Router。
let mut request_builder = Request::builder()
.method(method.as_str())
.uri(&path)
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123");
if let Some(body) = body {
request = request.json(&body);
request_builder = request_builder.header(http::header::CONTENT_TYPE, "application/json");
let request = request_builder
.body(Body::from(body.to_string()))
.expect("request should build");
let response = send_request(gateway, request).await;
let status = response.status();
let payload = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: serde_json::Value =
serde_json::from_slice(&payload).expect("json body should parse");
let upstream_count = *upstream_hits.lock().expect("mutex should lock");
upstream_handle.abort();
return (status, payload, upstream_count);
}
let response = request.send().await.expect("request should succeed");
let request = request_builder
.body(Body::empty())
.expect("request should build");
let response = send_request(gateway, request).await;
let status = response.status();
let payload: serde_json::Value = response.json().await.expect("json body should parse");
let payload = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: serde_json::Value =
serde_json::from_slice(&payload).expect("json body should parse");
let upstream_count = *upstream_hits.lock().expect("mutex should lock");
gateway_handle.abort();
upstream_handle.abort();
(status, payload, upstream_count)
@@ -341,21 +341,11 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(ADMIN_SYSTEM_IMPORT_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("admin system import test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(
test_name,
ADMIN_SYSTEM_IMPORT_TEST_STACK_BYTES,
make_future,
);
}
#[test]
+1 -15
View File
@@ -37,21 +37,7 @@ where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(FILES_TEST_STACK_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future());
})
.expect("files test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(test_name, FILES_TEST_STACK_BYTES, make_future);
}
fn hash_api_key(value: &str) -> String {
+1 -15
View File
@@ -39,21 +39,7 @@ fn run_frontdoor_async_test<F>(name: &'static str, future: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
let handle = std::thread::Builder::new()
.name(name.to_string())
.stack_size(16 * 1024 * 1024)
.spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("frontdoor test runtime should build")
.block_on(future);
})
.expect("large-stack frontdoor test thread should spawn");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
crate::tests::run_async_test_on_large_stack(name, 16 * 1024 * 1024, || future);
}
fn hash_api_key(value: &str) -> String {
+44 -1
View File
@@ -10,7 +10,6 @@ pub(super) use http::StatusCode;
pub(super) use serde_json::json;
mod ai_execute;
mod architecture;
mod async_task;
mod audit;
mod concurrency;
@@ -46,6 +45,50 @@ pub(super) async fn start_server(app: Router) -> (String, tokio::task::JoinHandl
(format!("http://{addr}"), handle)
}
/// 在独立的大栈线程中运行需要深调用栈的异步测试。
///
/// 这些测试仍保留 16 MiB 栈空间;这里只统一线程和 runtime 的启动逻辑,
/// 避免每个测试分区各自复制一份 helper,降低维护时误改测试执行语义的风险。
pub(crate) fn run_async_test_on_large_stack<F, Fut>(
test_name: &'static str,
stack_size: usize,
make_future: F,
) where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + 'static,
{
run_async_test_on_large_stack_with_result(test_name, stack_size, make_future);
}
/// 与上面的 helper 相同,但允许深栈测试返回结果,供公共请求 helper 使用。
pub(crate) fn run_async_test_on_large_stack_with_result<F, Fut, R>(
test_name: &'static str,
stack_size: usize,
make_future: F,
) -> R
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = R> + 'static,
R: Send + 'static,
{
let handle = std::thread::Builder::new()
.name(test_name.to_string())
.stack_size(stack_size)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime should build");
runtime.block_on(make_future())
})
.expect("large-stack test thread should spawn");
match handle.join() {
Ok(result) => result,
Err(payload) => std::panic::resume_unwind(payload),
}
}
pub(super) const OPERATIONAL_ADMIN_DEVICE_ID: &str = "device-operational-admin";
pub(super) async fn start_authenticated_operational_server(
@@ -1194,7 +1194,7 @@ fn ai_serving_planner_separates_local_candidate_resolution_from_ranking() {
let candidate_resolution =
read_workspace_file("apps/aether-gateway/src/ai_serving/planner/candidate_resolution.rs");
let ranking_call = candidate_resolution
candidate_resolution
.find("rank_eligible_local_execution_candidates(")
.expect("candidate_resolution.rs should call core-backed local candidate ranking");
assert!(
@@ -5045,7 +5045,9 @@ fn retired_api_format_occurrences_are_whitelisted() {
.expect("file should be under workspace root")
.to_string_lossy()
.replace('\\', "/");
if relative == "apps/aether-gateway/src/tests/architecture/ai_serving.rs" {
if relative == "apps/aether-gateway/tests/architecture/ai_serving.rs"
|| relative == "apps/aether-gateway/src/tests/architecture/ai_serving.rs"
{
continue;
}
@@ -1,7 +1,9 @@
use std::fs;
use std::path::{Path, PathBuf};
pub(super) fn collect_rust_files(root: &Path, files: &mut Vec<PathBuf>) {
// 架构守卫在独立 integration test 中是顶层模块;helper 统一 pub(crate),
// 子模块经 `use super::*` / `use super::{...}` 访问(与原 lib 内布局一致)。
pub(crate) fn collect_rust_files(root: &Path, files: &mut Vec<PathBuf>) {
for entry in fs::read_dir(root).expect("directory should be readable") {
let entry = entry.expect("directory entry should be readable");
let path = entry.path();
@@ -15,7 +17,7 @@ pub(super) fn collect_rust_files(root: &Path, files: &mut Vec<PathBuf>) {
}
}
pub(super) fn assert_no_sqlx_queries(root_relative_path: &str) {
pub(crate) fn assert_no_sqlx_queries(root_relative_path: &str) {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(root_relative_path);
let mut files = Vec::new();
collect_rust_files(&root, &mut files);
@@ -80,7 +82,7 @@ fn sql_pool_scan_distinguishes_pool_types_from_repository_names() {
));
}
pub(super) fn assert_no_sensitive_log_patterns(root_relative_path: &str, patterns: &[&str]) {
pub(crate) fn assert_no_sensitive_log_patterns(root_relative_path: &str, patterns: &[&str]) {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(root_relative_path);
let mut files = Vec::new();
collect_rust_files(&root, &mut files);
@@ -109,7 +111,7 @@ pub(super) fn assert_no_sensitive_log_patterns(root_relative_path: &str, pattern
);
}
pub(super) fn assert_no_module_dependency_patterns(root_relative_path: &str, patterns: &[&str]) {
pub(crate) fn assert_no_module_dependency_patterns(root_relative_path: &str, patterns: &[&str]) {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(root_relative_path);
let mut files = Vec::new();
collect_rust_files(&root, &mut files);
@@ -138,14 +140,14 @@ pub(super) fn assert_no_module_dependency_patterns(root_relative_path: &str, pat
);
}
pub(super) fn workspace_file_exists(root_relative_path: &str) -> bool {
pub(crate) fn workspace_file_exists(root_relative_path: &str) -> bool {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(root_relative_path)
.exists()
}
pub(super) fn workspace_files_with_extension(
pub(crate) fn workspace_files_with_extension(
root_relative_path: &str,
extension: &str,
) -> Vec<PathBuf> {
@@ -162,7 +164,7 @@ pub(super) fn workspace_files_with_extension(
files
}
pub(super) fn collect_workspace_rust_files(root_relative_path: &str) -> Vec<PathBuf> {
pub(crate) fn collect_workspace_rust_files(root_relative_path: &str) -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join(root_relative_path);
@@ -172,7 +174,7 @@ pub(super) fn collect_workspace_rust_files(root_relative_path: &str) -> Vec<Path
files
}
pub(super) fn read_workspace_file(path: &str) -> String {
pub(crate) fn read_workspace_file(path: &str) -> String {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
@@ -180,7 +182,7 @@ pub(super) fn read_workspace_file(path: &str) -> String {
fs::read_to_string(workspace_root.join(path)).expect("source file should be readable")
}
pub(super) fn read_workspace_module_tree(path: &str) -> String {
pub(crate) fn read_workspace_module_tree(path: &str) -> String {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use super::*;
@@ -0,0 +1,5 @@
//! 架构守卫独立测试目标。
//!
//! 从 lib 的 `cfg(test)` 巨型编译单元迁出:只做源码/manifest 字符串断言,
//! 不启动 AppState、不依赖 gateway 私有类型,用于压低 lib test 编译面与 rustc 峰值。
mod architecture;
-1
View File
@@ -46,5 +46,4 @@ webpki-roots = "0.26"
uuid.workspace = true
[dev-dependencies]
aether-gateway = { workspace = true, features = ["testkit"] }
tokio = { version = "1", features = ["test-util"] }
+1 -1
View File
@@ -885,7 +885,7 @@ impl Config {
Ok(Duration::from_millis(self.tunnel_connect_timeout_ms))
}
pub fn tunnel_ip_family(&self) -> crate::egress_proxy::IpFamily {
pub(crate) fn tunnel_ip_family(&self) -> crate::egress_proxy::IpFamily {
if self.tunnel_ipv4_only {
crate::egress_proxy::IpFamily::Ipv4Only
} else if self.tunnel_ipv6_only {
+7
View File
@@ -116,6 +116,13 @@ impl RuntimeResourceMonitor {
}
}
impl Default for RuntimeResourceMonitor {
fn default() -> Self {
// 默认构造与显式 new 保持一致,便于库目标和二进制目标共用监控器。
Self::new()
}
}
/// Collect hardware information and estimate max concurrency.
///
/// Should be called once at startup -- hardware does not change at runtime.
+17
View File
@@ -0,0 +1,17 @@
#![allow(clippy::large_enum_variant)]
// Tunnel 的运行模块作为库暴露给独立集成测试使用;生产二进制仍由
// src/main.rs 负责命令行解析,避免端到端测试把 Gateway dev-dependency
// 带进 Workspace Rest 的默认测试目标。
pub mod app;
pub mod config;
pub mod egress_proxy;
pub mod hardware;
mod net;
pub mod registration;
pub mod runtime;
pub mod setup;
pub mod state;
pub mod target_filter;
pub mod tunnel;
pub mod upstream_client;
+1 -13
View File
@@ -1,20 +1,8 @@
#![allow(clippy::large_enum_variant)]
mod app;
mod config;
mod egress_proxy;
mod hardware;
mod net;
mod registration;
mod runtime;
mod setup;
mod state;
mod target_filter;
mod tunnel;
mod upstream_client;
use std::path::PathBuf;
use aether_tunnel::{app, config, setup};
use clap::{parser::ValueSource, CommandFactory, FromArgMatches, Parser};
use config::{Config, ServerEntry, TunnelSecurity};
+2 -2
View File
@@ -1,5 +1,5 @@
pub(crate) mod service;
pub mod service;
mod tui;
pub(crate) mod upgrade;
pub mod upgrade;
pub use self::tui::{run, SetupOutcome};
+14
View File
@@ -226,6 +226,13 @@ impl TunnelRequestMetrics {
}
}
impl Default for TunnelRequestMetrics {
fn default() -> Self {
// 指标初始值全部为零,Default 与现有 new 语义完全一致。
Self::new()
}
}
const RECENT_TUNNEL_ERROR_CAPACITY: usize = 64;
const TUNNEL_ERROR_CATEGORY_MAX_CHARS: usize = 48;
const TUNNEL_ERROR_MESSAGE_MAX_CHARS: usize = 320;
@@ -534,6 +541,13 @@ impl TunnelMetrics {
}
}
impl Default for TunnelMetrics {
fn default() -> Self {
// 保留 recent_errors 的容量初始化,避免 Default 改变错误环形缓存行为。
Self::new()
}
}
fn now_unix_secs() -> u64 {
now_unix_ms() / 1_000
}
+6 -506
View File
@@ -1,10 +1,10 @@
pub mod client;
pub mod dispatcher;
pub mod heartbeat;
mod dispatcher;
mod heartbeat;
pub mod protocol;
pub mod stream_handler;
mod stream_handler;
mod task;
pub mod writer;
mod writer;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -230,34 +230,10 @@ fn mix_u64(mut x: u64) -> u64 {
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Once};
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;
use reqwest::StatusCode;
use tokio::sync::watch;
use crate::config::Config;
use crate::registration::client::AetherClient;
use crate::runtime::DynamicConfig;
use crate::state::{
AppState as TunnelAppState, ServerContext, TunnelMetrics, TunnelRequestMetrics,
};
use crate::target_filter::DnsCache;
use crate::tunnel::protocol;
use crate::upstream_client;
use std::time::Duration;
use super::{
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger, run,
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger,
MAX_STARTUP_STAGGER_MS, RECONNECT_PROBE_MAX_DELAY_MS, STARTUP_STAGGER_STEP_MS,
};
@@ -299,480 +275,4 @@ mod tests {
let d = compute_reconnect_delay(500, 45_000, 100, 12345);
assert!(d <= Duration::from_millis(RECONNECT_PROBE_MAX_DELAY_MS));
}
#[tokio::test]
async fn tunnel_reconnects_after_gateway_restart() {
ensure_rustls_provider();
let gateway_port = reserve_local_port().expect("gateway port should reserve");
let gateway_base_url = format!("http://127.0.0.1:{gateway_port}");
let (gateway_state, mut gateway_handle) = start_gateway_on_port(gateway_port)
.await
.expect("gateway should start");
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({
let state = Arc::clone(&state);
let server = Arc::clone(&server);
let (_drain_tx, drain_rx) = watch::channel(false);
async move {
run(&state, &server, 0, shutdown_rx, drain_rx).await;
}
});
wait_until_relay_status(
&gateway_base_url,
"node-recovery",
StatusCode::GATEWAY_TIMEOUT,
)
.await;
gateway_handle.abort();
let _ = (&mut gateway_handle).await;
assert_eq!(gateway_state.force_close_all_tunnel_proxies(), 1);
let (_restarted_gateway_state, restarted_gateway_handle) =
start_gateway_on_port_retry(gateway_port)
.await
.expect("gateway should restart on fixed port");
gateway_handle = restarted_gateway_handle;
wait_until_relay_status(
&gateway_base_url,
"node-recovery",
StatusCode::GATEWAY_TIMEOUT,
)
.await;
assert!(server.tunnel_metrics.snapshot().connect_successes >= 2);
let _ = shutdown_tx.send(true);
tokio::time::timeout(Duration::from_secs(5), tunnel_task)
.await
.expect("tunnel task should stop")
.expect("tunnel task should join");
gateway_handle.abort();
}
async fn wait_until_relay_status(gateway_base_url: &str, node_id: &str, expected: StatusCode) {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
let mut last_observed = None::<String>;
loop {
if let Some((status, body)) = probe_relay_status(gateway_base_url, node_id).await {
last_observed = Some(format!("{status} body={body}"));
if status == expected {
return;
}
}
assert!(
tokio::time::Instant::now() < deadline,
"relay status did not become {expected} within timeout; last={:?}",
last_observed
);
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
async fn probe_relay_status(
gateway_base_url: &str,
node_id: &str,
) -> Option<(StatusCode, String)> {
let response = relay_response(gateway_base_url, node_id, relay_probe_envelope()).await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
Some((status, body))
}
async fn relay_response(
gateway_base_url: &str,
node_id: &str,
payload: Vec<u8>,
) -> Option<reqwest::Response> {
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,
);
reqwest::Client::new()
.post(format!(
"{gateway_base_url}/api/internal/tunnel/relay/{node_id}"
))
.header("content-type", "application/octet-stream")
.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()
}
fn relay_probe_envelope() -> Vec<u8> {
let meta = protocol::RequestMeta {
provider_id: None,
endpoint_id: None,
key_id: None,
method: "GET".to_string(),
url: "http://127.0.0.1:80/blocked".to_string(),
headers: std::collections::HashMap::new(),
stream: false,
request_timeout_ms: None,
stream_first_byte_timeout_ms: None,
timeout: 5,
follow_redirects: None,
http1_only: false,
transport_profile: None,
};
let meta_json =
serde_json::to_vec(&meta).expect("tunnel relay probe metadata should serialize");
let mut envelope = Vec::with_capacity(4 + meta_json.len());
envelope.extend_from_slice(&(meta_json.len() as u32).to_be_bytes());
envelope.extend_from_slice(&meta_json);
envelope
}
async fn start_gateway_on_port(
port: u16,
) -> Result<(GatewayAppState, tokio::task::JoinHandle<()>), std::io::Error> {
// The embedded gateway now fails closed when relay authentication is
// not configured. Keep this integration fixture explicitly authenticated.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let state = {
let _guard = ENV_LOCK.lock().unwrap();
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);
state
};
let router = build_router_with_state(state.clone());
let handle = spawn_router_on_port(port, router).await?;
Ok((state, handle))
}
#[tokio::test]
async fn negotiated_small_window_streams_large_responses_and_cancels_idle_upstream() {
use axum::body::{Body, Bytes};
use axum::routing::get;
use futures_util::StreamExt;
ensure_rustls_provider();
let upstream_port = reserve_local_port().unwrap();
let upstream = Router::new()
.route(
"/large",
get(|| async { Body::from(vec![b'x'; 2 * 1024 * 1024]) }),
)
.route(
"/idle",
get(|| async {
let first = futures_util::stream::once(async {
Ok::<_, std::io::Error>(Bytes::from_static(b"data: started\n\n"))
});
(
[("content-type", "text/event-stream")],
Body::from_stream(first.chain(futures_util::stream::pending())),
)
}),
);
let upstream_task = super::task::SessionTask::new(
spawn_router_on_port(upstream_port, upstream).await.unwrap(),
);
let gateway_port = reserve_local_port().unwrap();
let gateway_url = format!("http://127.0.0.1:{gateway_port}");
let (_, gateway_task) = start_gateway_on_port(gateway_port).await.unwrap();
let gateway_task = super::task::SessionTask::new(gateway_task);
let mut config = sample_config(&gateway_url);
config.tunnel_security = crate::config::TunnelSecurity::NonTlsRequired;
config.tunnel_encryption_key = Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=".into());
config.tunnel_stream_initial_window_bytes = 512 * 1024;
config.tunnel_drain_deadline_ms = 100;
config.allow_private_targets = true;
config.allowed_ports.push(upstream_port);
let state = sample_state(config);
let server = sample_server(&state, "node-recovery");
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let (_drain_tx, drain_rx) = watch::channel(false);
let tunnel_task = super::task::SessionTask::new(tokio::spawn({
let state = Arc::clone(&state);
let server = Arc::clone(&server);
async move {
run(&state, &server, 0, shutdown_rx, drain_rx).await;
}
}));
wait_until_relay_status(&gateway_url, "node-recovery", StatusCode::GATEWAY_TIMEOUT).await;
let envelope = |path: &str| {
let mut meta: protocol::RequestMeta =
serde_json::from_slice(&relay_probe_envelope()[4..]).unwrap();
meta.url = format!("http://127.0.0.1:{upstream_port}/{path}");
meta.stream = true;
meta.timeout = 10;
meta.stream_first_byte_timeout_ms = Some(10_000);
let encoded = serde_json::to_vec(&meta).unwrap();
let mut result = (encoded.len() as u32).to_be_bytes().to_vec();
result.extend(encoded);
result
};
let response = relay_response(&gateway_url, "node-recovery", envelope("large"))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = tokio::time::timeout(Duration::from_secs(10), response.bytes())
.await
.unwrap()
.unwrap();
assert_eq!(body.len(), 2 * 1024 * 1024);
assert!(body.iter().all(|byte| *byte == b'x'));
let mut response = relay_response(&gateway_url, "node-recovery", envelope("idle"))
.await
.unwrap();
assert_eq!(
response.chunk().await.unwrap().unwrap(),
"data: started\n\n"
);
drop(response);
tokio::time::timeout(Duration::from_secs(3), async {
while server
.active_connections
.load(std::sync::atomic::Ordering::Acquire)
!= 0
{
tokio::task::yield_now().await;
}
})
.await
.expect("cancelled SSE must release the upstream handler");
let mut response = relay_response(&gateway_url, "node-recovery", envelope("idle"))
.await
.unwrap();
assert!(response.chunk().await.unwrap().is_some());
shutdown_tx.send(true).unwrap();
tokio::time::timeout(Duration::from_secs(3), tunnel_task)
.await
.unwrap()
.unwrap();
assert_eq!(
server
.active_connections
.load(std::sync::atomic::Ordering::Acquire),
0
);
drop(response);
drop(gateway_task);
drop(upstream_task);
}
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> {
let mut attempts = 0usize;
loop {
match start_gateway_on_port(port).await {
Ok(server) => return Ok(server),
Err(err) => {
attempts += 1;
if attempts >= 20 {
return Err(err);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
async fn spawn_router_on_port(
port: u16,
app: Router,
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
Ok(tokio::spawn(async move {
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await
.expect("gateway test server should run");
}))
}
fn reserve_local_port() -> Result<u16, std::io::Error> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
fn sample_state(config: Config) -> Arc<TunnelAppState> {
let config = Arc::new(config);
let dns_cache = Arc::new(DnsCache::new(Duration::from_secs(60), 128));
let upstream_client_pool =
upstream_client::UpstreamClientPool::new(Arc::clone(&config), Arc::clone(&dns_cache));
Arc::new(TunnelAppState {
config,
dns_cache,
upstream_client_pool,
tunnel_tls_config: Arc::new(crate::tunnel::client::build_tls_config()),
resource_monitor: Arc::new(crate::hardware::RuntimeResourceMonitor::new()),
stream_gate: None,
distributed_stream_gate: None,
})
}
fn sample_server(state: &Arc<TunnelAppState>, node_id: &str) -> Arc<ServerContext> {
let config = Arc::clone(&state.config);
Arc::new(ServerContext {
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())),
tunnel_generation: "test-generation-1".to_string(),
aether_client: Arc::new(AetherClient::new(
&config,
&config.aether_url,
&config.management_token,
)),
dynamic: Arc::new(ArcSwap::from_pointee(DynamicConfig::from_config(&config))),
active_connections: Arc::new(AtomicU64::new(0)),
metrics: Arc::new(TunnelRequestMetrics::new()),
tunnel_metrics: Arc::new(TunnelMetrics::new()),
})
}
fn sample_config(aether_url: &str) -> Config {
Config {
aether_url: aether_url.to_string(),
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],
allow_private_targets: false,
aether_request_timeout_secs: 10,
aether_connect_timeout_secs: 2,
aether_pool_max_idle_per_host: 8,
aether_pool_idle_timeout_secs: 90,
aether_tcp_keepalive_secs: 60,
aether_tcp_nodelay: true,
aether_http2: true,
aether_outbound_proxy_url: None,
aether_retry_max_attempts: 1,
aether_retry_base_delay_ms: 50,
aether_retry_max_delay_ms: 100,
diagnostics_bind: None,
max_concurrent_connections: None,
max_in_flight_streams: None,
distributed_stream_limit: None,
distributed_stream_redis_url: None,
distributed_stream_redis_key_prefix: None,
distributed_stream_lease_ttl_ms: 30_000,
distributed_stream_renew_interval_ms: 10_000,
distributed_stream_command_timeout_ms: 1_000,
dns_cache_ttl_secs: 60,
dns_cache_capacity: 128,
upstream_connect_timeout_secs: 30,
upstream_pool_max_idle_per_host: 4,
upstream_pool_idle_timeout_secs: 60,
upstream_client_pool_capacity: crate::config::DEFAULT_UPSTREAM_CLIENT_POOL_CAPACITY,
upstream_tcp_keepalive_secs: 60,
upstream_tcp_nodelay: true,
upstream_proxy_url: None,
upstream_proxy_remote_dns: false,
legacy_redirect_replay_budget_bytes_ignored: None,
emit_proxy_timing_header: true,
log_level: "info".to_string(),
log_destination: crate::config::TunnelLogDestinationArg::Stdout,
log_dir: None,
log_rotation: crate::config::TunnelLogRotationArg::Daily,
log_retention_days: 7,
log_max_files: 30,
tunnel_reconnect_base_ms: 50,
tunnel_reconnect_max_ms: 250,
tunnel_ping_interval_ms: 1_000,
tunnel_max_streams: Some(8),
tunnel_profile: crate::config::TunnelProfileArg::Lite,
tunnel_stream_initial_window_bytes:
crate::config::DEFAULT_TUNNEL_STREAM_INITIAL_WINDOW_BYTES,
tunnel_drain_deadline_ms: crate::config::DEFAULT_TUNNEL_DRAIN_DEADLINE_MS,
tunnel_connect_timeout_ms: 2_000,
tunnel_ipv4_only: false,
tunnel_ipv6_only: false,
tunnel_tcp_keepalive_secs: 30,
tunnel_tcp_nodelay: true,
tunnel_stale_timeout_ms: 5_000,
tunnel_connections: Some(1),
tunnel_connections_max: Some(1),
tunnel_scale_check_interval_ms: 1_000,
tunnel_scale_up_threshold_percent: 70,
tunnel_scale_down_threshold_percent: 35,
tunnel_scale_down_grace_secs: 15,
}
}
fn ensure_rustls_provider() {
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
}