mirror of https://github.com/telemt/telemt.git
1325 lines
44 KiB
Rust
1325 lines
44 KiB
Rust
use super::*;
|
|
use crate::config::{UpstreamConfig, UpstreamType};
|
|
use crate::crypto::{AesCtr, SecureRandom};
|
|
use crate::protocol::constants::ProtoTag;
|
|
use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController};
|
|
use crate::stats::Stats;
|
|
use crate::stream::{BufferPool, CryptoReader, CryptoWriter};
|
|
use crate::transport::UpstreamManager;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::time::Duration;
|
|
use tokio::io::duplex;
|
|
use tokio::net::TcpListener;
|
|
|
|
fn make_crypto_reader<R>(reader: R) -> CryptoReader<R>
|
|
where
|
|
R: tokio::io::AsyncRead + Unpin,
|
|
{
|
|
let key = [0u8; 32];
|
|
let iv = 0u128;
|
|
CryptoReader::new(reader, AesCtr::new(&key, iv))
|
|
}
|
|
|
|
fn make_crypto_writer<W>(writer: W) -> CryptoWriter<W>
|
|
where
|
|
W: tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
let key = [0u8; 32];
|
|
let iv = 0u128;
|
|
CryptoWriter::new(writer, AesCtr::new(&key, iv), 8 * 1024)
|
|
}
|
|
|
|
fn nonempty_line_count(text: &str) -> usize {
|
|
text.lines().filter(|line| !line.trim().is_empty()).count()
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_is_deduplicated_per_dc_idx() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
assert!(should_log_unknown_dc(777));
|
|
assert!(
|
|
!should_log_unknown_dc(777),
|
|
"same unknown dc_idx must not be logged repeatedly"
|
|
);
|
|
assert!(
|
|
should_log_unknown_dc(778),
|
|
"different unknown dc_idx must still be loggable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_respects_distinct_limit() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
for dc in 1..=UNKNOWN_DC_LOG_DISTINCT_LIMIT {
|
|
assert!(
|
|
should_log_unknown_dc(dc as i16),
|
|
"expected first-time unknown dc_idx to be loggable"
|
|
);
|
|
}
|
|
|
|
assert!(
|
|
!should_log_unknown_dc(i16::MAX),
|
|
"distinct unknown dc_idx entries above limit must not be logged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_fails_closed_when_dedup_lock_is_poisoned() {
|
|
let poisoned = Arc::new(std::sync::Mutex::new(std::collections::HashSet::<i16>::new()));
|
|
let poisoned_for_thread = poisoned.clone();
|
|
|
|
let _ = std::thread::spawn(move || {
|
|
let _guard = poisoned_for_thread
|
|
.lock()
|
|
.expect("poison setup lock must be available");
|
|
panic!("intentional poison for fail-closed regression");
|
|
})
|
|
.join();
|
|
|
|
assert!(
|
|
!should_log_unknown_dc_with_set(poisoned.as_ref(), 4242),
|
|
"poisoned unknown-DC dedup lock must fail closed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsafe_unknown_dc_log_path_does_not_consume_dedup_slot() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let dc_idx: i16 = 31_123;
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some("../telemt-unknown-dc-unsafe.log".to_string());
|
|
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
|
|
assert!(
|
|
should_log_unknown_dc(dc_idx),
|
|
"rejected unsafe log path must not consume unknown-dc dedup entry"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stress_unknown_dc_log_concurrent_unique_churn_respects_cap() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let accepted = Arc::new(AtomicUsize::new(0));
|
|
let mut workers = Vec::new();
|
|
|
|
// Adversarial model: many concurrent peers rotate dc_idx values rapidly.
|
|
for worker in 0..16usize {
|
|
let accepted = Arc::clone(&accepted);
|
|
workers.push(std::thread::spawn(move || {
|
|
let base = (worker * 2048) as i32;
|
|
for offset in 0..512i32 {
|
|
let raw = base + offset;
|
|
let dc = (raw % i16::MAX as i32) as i16;
|
|
if should_log_unknown_dc(dc) {
|
|
accepted.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
for worker in workers {
|
|
worker.join().expect("worker thread must not panic");
|
|
}
|
|
|
|
assert_eq!(
|
|
accepted.load(Ordering::Relaxed),
|
|
UNKNOWN_DC_LOG_DISTINCT_LIMIT,
|
|
"concurrent unique churn must never admit more than the configured distinct cap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn light_fuzz_unknown_dc_log_mixed_duplicates_never_exceeds_cap() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
// Deterministic xorshift sequence for reproducible mixed duplicate fuzzing.
|
|
let mut s: u64 = 0xA5A5_5A5A_C3C3_3C3C;
|
|
let mut admitted = 0usize;
|
|
|
|
for _ in 0..20_000 {
|
|
s ^= s << 7;
|
|
s ^= s >> 9;
|
|
s ^= s << 8;
|
|
|
|
let dc = (s as i16).wrapping_sub(i16::MAX / 2);
|
|
if should_log_unknown_dc(dc) {
|
|
admitted += 1;
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
admitted <= UNKNOWN_DC_LOG_DISTINCT_LIMIT,
|
|
"mixed-duplicate fuzzed inputs must not admit more than cap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scope_hint_accepts_ascii_alnum_and_dash_within_limit() {
|
|
assert_eq!(validated_scope_hint("scope_alpha-1"), Some("alpha-1"));
|
|
assert_eq!(validated_scope_hint("scope_AZ09"), Some("AZ09"));
|
|
}
|
|
|
|
#[test]
|
|
fn scope_hint_rejects_invalid_or_oversized_values() {
|
|
assert_eq!(validated_scope_hint("plain_user"), None);
|
|
assert_eq!(validated_scope_hint("scope_"), None);
|
|
assert_eq!(validated_scope_hint("scope_a/b"), None);
|
|
assert_eq!(validated_scope_hint("scope_bad space"), None);
|
|
assert_eq!(validated_scope_hint("scope_bad.dot"), None);
|
|
|
|
let oversized = format!("scope_{}", "a".repeat(MAX_SCOPE_HINT_LEN + 1));
|
|
assert_eq!(validated_scope_hint(&oversized), None);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_rejects_parent_traversal_inputs() {
|
|
assert!(
|
|
sanitize_unknown_dc_log_path("../unknown-dc.txt").is_none(),
|
|
"parent traversal paths must be rejected"
|
|
);
|
|
assert!(
|
|
sanitize_unknown_dc_log_path("logs/../unknown-dc.txt").is_none(),
|
|
"embedded parent traversal must be rejected"
|
|
);
|
|
assert!(
|
|
sanitize_unknown_dc_log_path("./../unknown-dc.txt").is_none(),
|
|
"relative parent traversal must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_absolute_paths_with_existing_parent() {
|
|
let absolute = std::env::temp_dir().join("unknown-dc.txt");
|
|
let absolute_str = absolute
|
|
.to_str()
|
|
.expect("temp absolute path must be valid UTF-8");
|
|
|
|
let sanitized = sanitize_unknown_dc_log_path(absolute_str)
|
|
.expect("absolute paths with existing parent must be accepted");
|
|
assert_eq!(sanitized.resolved_path, absolute);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_rejects_absolute_parent_traversal() {
|
|
assert!(
|
|
sanitize_unknown_dc_log_path("/tmp/../etc/passwd").is_none(),
|
|
"absolute parent traversal must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_safe_relative_path() {
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-log-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("temp test directory must be creatable");
|
|
|
|
let candidate = base.join("unknown-dc.txt");
|
|
let candidate_relative = format!("target/telemt-unknown-dc-log-{}/unknown-dc.txt", std::process::id());
|
|
|
|
let sanitized = sanitize_unknown_dc_log_path(&candidate_relative)
|
|
.expect("safe relative path with existing parent must be accepted");
|
|
assert_eq!(sanitized.resolved_path, candidate);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_rejects_empty_or_dot_only_inputs() {
|
|
assert!(
|
|
sanitize_unknown_dc_log_path("").is_none(),
|
|
"empty path must be rejected"
|
|
);
|
|
assert!(
|
|
sanitize_unknown_dc_log_path(".").is_none(),
|
|
"dot-only path without filename must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_directory_only_as_filename_projection() {
|
|
let sanitized = sanitize_unknown_dc_log_path("target/")
|
|
.expect("directory-only input is interpreted as filename projection in current sanitizer");
|
|
assert!(
|
|
sanitized.resolved_path.ends_with("target"),
|
|
"directory-only input should resolve to canonical parent plus filename projection"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_dot_prefixed_relative_path() {
|
|
let rel_dir = format!("target/telemt-unknown-dc-dot-{}", std::process::id());
|
|
let abs_dir = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join(&rel_dir);
|
|
fs::create_dir_all(&abs_dir).expect("dot-prefixed test directory must be creatable");
|
|
|
|
let rel_candidate = format!("./{rel_dir}/unknown-dc.log");
|
|
let expected = abs_dir.join("unknown-dc.log");
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("dot-prefixed safe path must be accepted");
|
|
assert_eq!(sanitized.resolved_path, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn light_fuzz_unknown_dc_path_parentdir_inputs_always_rejected() {
|
|
let mut s: u64 = 0xD00D_BAAD_1234_5678;
|
|
for _ in 0..4096 {
|
|
s ^= s << 7;
|
|
s ^= s >> 9;
|
|
s ^= s << 8;
|
|
let a = (s as usize) % 32;
|
|
let b = ((s >> 8) as usize) % 32;
|
|
let candidate = format!("target/{a}/../{b}/unknown-dc.log");
|
|
assert!(
|
|
sanitize_unknown_dc_log_path(&candidate).is_none(),
|
|
"parent-dir candidate must be rejected: {candidate}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_rejects_nonexistent_parent_directory() {
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-missing-{}/nested/unknown-dc.txt",
|
|
std::process::id()
|
|
);
|
|
|
|
assert!(
|
|
sanitize_unknown_dc_log_path(&rel_candidate).is_none(),
|
|
"path with missing parent must be rejected to avoid implicit directory creation"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_symlinked_parent_inside_workspace() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-log-symlink-internal-{}", std::process::id()));
|
|
let real_parent = base.join("real_parent");
|
|
fs::create_dir_all(&real_parent).expect("real parent dir must be creatable");
|
|
|
|
let symlink_parent = base.join("internal_link");
|
|
let _ = fs::remove_file(&symlink_parent);
|
|
symlink(&real_parent, &symlink_parent).expect("internal symlink must be creatable");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-log-symlink-internal-{}/internal_link/unknown-dc.txt",
|
|
std::process::id()
|
|
);
|
|
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("symlinked parent that resolves inside workspace must be accepted");
|
|
assert!(
|
|
sanitized.resolved_path.starts_with(&real_parent),
|
|
"sanitized path must resolve to canonical internal parent"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_log_path_sanitizer_accepts_symlink_parent_escape_as_canonical_path() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-log-symlink-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("symlink test directory must be creatable");
|
|
|
|
let symlink_parent = base.join("escape_link");
|
|
let _ = fs::remove_file(&symlink_parent);
|
|
symlink("/tmp", &symlink_parent).expect("symlink parent must be creatable");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-log-symlink-{}/escape_link/unknown-dc.txt",
|
|
std::process::id()
|
|
);
|
|
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("symlinked parent must canonicalize to target path");
|
|
assert!(
|
|
sanitized.resolved_path.starts_with(Path::new("/tmp")),
|
|
"sanitized path must resolve to canonical symlink target"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_log_path_revalidation_rejects_symlinked_target_escape() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-target-link-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("target-link base must be creatable");
|
|
|
|
let outside = std::env::temp_dir().join(format!("telemt-outside-{}", std::process::id()));
|
|
let _ = fs::remove_file(&outside);
|
|
fs::write(&outside, "outside").expect("outside file must be writable");
|
|
|
|
let linked_target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&linked_target);
|
|
symlink(&outside, &linked_target).expect("target symlink must be creatable");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-target-link-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("candidate should sanitize before final revalidation");
|
|
|
|
assert!(
|
|
!unknown_dc_log_path_is_still_safe(&sanitized),
|
|
"final revalidation must reject symlinked target escape"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_open_append_rejects_symlink_target_with_nofollow() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-nofollow-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("nofollow base must be creatable");
|
|
|
|
let outside = std::env::temp_dir().join(format!(
|
|
"telemt-unknown-dc-nofollow-outside-{}.log",
|
|
std::process::id()
|
|
));
|
|
let _ = fs::remove_file(&outside);
|
|
fs::write(&outside, "outside\n").expect("outside file must be writable");
|
|
|
|
let linked_target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&linked_target);
|
|
symlink(&outside, &linked_target).expect("symlink target must be creatable");
|
|
|
|
let err = open_unknown_dc_log_append(&linked_target)
|
|
.expect_err("O_NOFOLLOW open must fail for symlink target");
|
|
assert_eq!(
|
|
err.raw_os_error(),
|
|
Some(libc::ELOOP),
|
|
"symlink target must be rejected with ELOOP when O_NOFOLLOW is applied"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_open_append_rejects_broken_symlink_target_with_nofollow() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-broken-link-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("broken-link base must be creatable");
|
|
|
|
let linked_target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&linked_target);
|
|
symlink(base.join("missing-target.log"), &linked_target)
|
|
.expect("broken symlink target must be creatable");
|
|
|
|
let err = open_unknown_dc_log_append(&linked_target)
|
|
.expect_err("O_NOFOLLOW open must fail for broken symlink target");
|
|
assert_eq!(
|
|
err.raw_os_error(),
|
|
Some(libc::ELOOP),
|
|
"broken symlink target must be rejected with ELOOP when O_NOFOLLOW is applied"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn adversarial_unknown_dc_open_append_symlink_flip_never_writes_outside_file() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-symlink-flip-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("symlink-flip base must be creatable");
|
|
|
|
let outside = std::env::temp_dir().join(format!(
|
|
"telemt-unknown-dc-symlink-flip-outside-{}.log",
|
|
std::process::id()
|
|
));
|
|
fs::write(&outside, "outside-baseline\n").expect("outside baseline file must be writable");
|
|
let outside_before = fs::read_to_string(&outside).expect("outside baseline must be readable");
|
|
|
|
let target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&target);
|
|
|
|
for step in 0..1024usize {
|
|
let _ = fs::remove_file(&target);
|
|
if step % 2 == 0 {
|
|
symlink(&outside, &target).expect("symlink creation in flip loop must succeed");
|
|
}
|
|
if let Ok(mut file) = open_unknown_dc_log_append(&target) {
|
|
writeln!(file, "dc_idx={step}").expect("append on regular file must succeed");
|
|
}
|
|
}
|
|
|
|
let outside_after = fs::read_to_string(&outside).expect("outside file must remain readable");
|
|
assert_eq!(
|
|
outside_after, outside_before,
|
|
"outside file must never be modified under symlink-flip adversarial churn"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_open_append_creates_regular_file() {
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-open-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("open test base must be creatable");
|
|
|
|
let target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&target);
|
|
|
|
{
|
|
let mut file = open_unknown_dc_log_append(&target)
|
|
.expect("regular target must be creatable with append open");
|
|
writeln!(file, "dc_idx=1234").expect("append write must succeed");
|
|
}
|
|
|
|
let meta = fs::symlink_metadata(&target).expect("created target metadata must be readable");
|
|
assert!(meta.file_type().is_file(), "target must be a regular file");
|
|
assert!(
|
|
!meta.file_type().is_symlink(),
|
|
"regular target open path must not produce symlink artifacts"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stress_unknown_dc_open_append_regular_file_preserves_line_integrity() {
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-open-stress-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("stress open base must be creatable");
|
|
|
|
let target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&target);
|
|
|
|
let writes = 2048usize;
|
|
for idx in 0..writes {
|
|
let mut file = open_unknown_dc_log_append(&target)
|
|
.expect("stress append open on regular file must succeed");
|
|
writeln!(file, "dc_idx={idx}").expect("stress append write must succeed");
|
|
}
|
|
|
|
let content = fs::read_to_string(&target).expect("stress output file must be readable");
|
|
assert_eq!(
|
|
nonempty_line_count(&content),
|
|
writes,
|
|
"regular-file append stress must preserve one logical line per write"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_revalidation_accepts_regular_existing_target() {
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-safe-target-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("safe target base must be creatable");
|
|
|
|
let target = base.join("unknown-dc.log");
|
|
fs::write(&target, "seed\n").expect("safe target seed write must succeed");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-safe-target-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("safe candidate must sanitize");
|
|
assert!(
|
|
unknown_dc_log_path_is_still_safe(&sanitized),
|
|
"revalidation must allow safe existing regular files"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_dc_log_path_revalidation_rejects_deleted_parent_after_sanitize() {
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-vanish-parent-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("vanish-parent base must be creatable");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-vanish-parent-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("candidate must sanitize before parent deletion");
|
|
|
|
fs::remove_dir_all(&base).expect("test parent directory must be removable");
|
|
assert!(
|
|
!unknown_dc_log_path_is_still_safe(&sanitized),
|
|
"revalidation must fail when sanitized parent disappears before write"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn unknown_dc_log_path_revalidation_rejects_parent_swapped_to_symlink() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let parent = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-parent-swap-{}", std::process::id()));
|
|
fs::create_dir_all(&parent).expect("parent-swap test parent must be creatable");
|
|
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-parent-swap-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("candidate must sanitize before parent swap");
|
|
|
|
let moved = parent.with_extension("bak");
|
|
let _ = fs::remove_dir_all(&moved);
|
|
fs::rename(&parent, &moved).expect("parent must be movable for swap simulation");
|
|
symlink("/tmp", &parent).expect("symlink replacement for parent must be creatable");
|
|
|
|
assert!(
|
|
!unknown_dc_log_path_is_still_safe(&sanitized),
|
|
"revalidation must fail when canonical parent is swapped to a symlinked target"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn adversarial_check_then_symlink_flip_is_blocked_by_nofollow_open() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let parent = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-check-open-race-{}", std::process::id()));
|
|
fs::create_dir_all(&parent).expect("check-open-race parent must be creatable");
|
|
|
|
let target = parent.join("unknown-dc.log");
|
|
fs::write(&target, "seed\n").expect("seed target file must be writable");
|
|
let rel_candidate = format!(
|
|
"target/telemt-unknown-dc-check-open-race-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let sanitized = sanitize_unknown_dc_log_path(&rel_candidate)
|
|
.expect("candidate must sanitize");
|
|
|
|
assert!(
|
|
unknown_dc_log_path_is_still_safe(&sanitized),
|
|
"precondition: target should initially pass revalidation"
|
|
);
|
|
|
|
let outside = std::env::temp_dir().join(format!(
|
|
"telemt-unknown-dc-check-open-race-outside-{}.log",
|
|
std::process::id()
|
|
));
|
|
fs::write(&outside, "outside\n").expect("outside file must be writable");
|
|
fs::remove_file(&target).expect("target removal before flip must succeed");
|
|
symlink(&outside, &target).expect("target symlink flip must be creatable");
|
|
|
|
let err = open_unknown_dc_log_append(&sanitized.resolved_path)
|
|
.expect_err("nofollow open must fail after symlink flip between check and open");
|
|
assert_eq!(
|
|
err.raw_os_error(),
|
|
Some(libc::ELOOP),
|
|
"symlink flip in check/open window must be neutralized by O_NOFOLLOW"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_dc_absolute_log_path_writes_one_entry() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let dc_idx: i16 = 31_001;
|
|
let file_path = std::env::temp_dir().join(format!(
|
|
"telemt-unknown-dc-abs-{}-{}.log",
|
|
std::process::id(),
|
|
dc_idx
|
|
));
|
|
let _ = fs::remove_file(&file_path);
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some(
|
|
file_path
|
|
.to_str()
|
|
.expect("temp file path must be valid UTF-8")
|
|
.to_string(),
|
|
);
|
|
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
|
|
let mut content = None;
|
|
for _ in 0..20 {
|
|
if let Ok(text) = fs::read_to_string(&file_path) {
|
|
content = Some(text);
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(15)).await;
|
|
}
|
|
|
|
let text = content.expect("absolute unknown-DC log path must produce exactly one log write");
|
|
assert!(
|
|
text.contains(&format!("dc_idx={dc_idx}")),
|
|
"absolute unknown-DC integration log must contain requested dc_idx"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_dc_safe_relative_log_path_writes_one_entry() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let dc_idx: i16 = 31_002;
|
|
let rel_dir = format!("target/telemt-unknown-dc-int-{}", std::process::id());
|
|
let rel_file = format!("{rel_dir}/unknown-dc.log");
|
|
let abs_dir = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join(&rel_dir);
|
|
fs::create_dir_all(&abs_dir).expect("integration test log directory must be creatable");
|
|
let abs_file = abs_dir.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&abs_file);
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some(rel_file);
|
|
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
|
|
let mut content = None;
|
|
for _ in 0..20 {
|
|
if let Ok(text) = fs::read_to_string(&abs_file) {
|
|
content = Some(text);
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(15)).await;
|
|
}
|
|
|
|
let text = content.expect("safe relative path must produce exactly one log write");
|
|
assert!(
|
|
text.contains(&format!("dc_idx={dc_idx}")),
|
|
"unknown-DC integration log must contain requested dc_idx"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_dc_same_index_burst_writes_only_once() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let dc_idx: i16 = 31_010;
|
|
let rel_dir = format!("target/telemt-unknown-dc-same-{}", std::process::id());
|
|
let rel_file = format!("{rel_dir}/unknown-dc.log");
|
|
let abs_dir = std::env::current_dir().unwrap().join(&rel_dir);
|
|
fs::create_dir_all(&abs_dir).expect("same-index log directory must be creatable");
|
|
let abs_file = abs_dir.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&abs_file);
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some(rel_file);
|
|
|
|
for _ in 0..64 {
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
}
|
|
|
|
let mut content = None;
|
|
for _ in 0..30 {
|
|
if let Ok(text) = fs::read_to_string(&abs_file) {
|
|
content = Some(text);
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
|
|
let text = content.expect("same-index burst must produce at least one log write");
|
|
assert_eq!(
|
|
nonempty_line_count(&text),
|
|
1,
|
|
"same unknown dc index must be deduplicated to one file line"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_dc_distinct_burst_is_hard_capped_on_file_writes() {
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let rel_dir = format!("target/telemt-unknown-dc-cap-{}", std::process::id());
|
|
let rel_file = format!("{rel_dir}/unknown-dc.log");
|
|
let abs_dir = std::env::current_dir().unwrap().join(&rel_dir);
|
|
fs::create_dir_all(&abs_dir).expect("cap log directory must be creatable");
|
|
let abs_file = abs_dir.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&abs_file);
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some(rel_file);
|
|
|
|
for i in 0..(UNKNOWN_DC_LOG_DISTINCT_LIMIT + 128) {
|
|
let dc_idx = 20_000i16.wrapping_add(i as i16);
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
}
|
|
|
|
let mut final_text = String::new();
|
|
for _ in 0..80 {
|
|
if let Ok(text) = fs::read_to_string(&abs_file) {
|
|
final_text = text;
|
|
if nonempty_line_count(&final_text) >= UNKNOWN_DC_LOG_DISTINCT_LIMIT {
|
|
break;
|
|
}
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
|
|
let line_count = nonempty_line_count(&final_text);
|
|
assert!(
|
|
line_count > 0,
|
|
"distinct unknown-dc burst must write at least one line"
|
|
);
|
|
assert!(
|
|
line_count <= UNKNOWN_DC_LOG_DISTINCT_LIMIT,
|
|
"distinct unknown-dc writes must stay within dedup hard cap"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn unknown_dc_symlinked_target_escape_is_not_written_integration() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let _guard = unknown_dc_test_lock()
|
|
.lock()
|
|
.expect("unknown dc test lock must be available");
|
|
clear_unknown_dc_log_cache_for_testing();
|
|
|
|
let base = std::env::current_dir()
|
|
.expect("cwd must be available")
|
|
.join("target")
|
|
.join(format!("telemt-unknown-dc-no-write-link-{}", std::process::id()));
|
|
fs::create_dir_all(&base).expect("integration symlink base must be creatable");
|
|
|
|
let outside = std::env::temp_dir().join(format!(
|
|
"telemt-unknown-dc-outside-{}.log",
|
|
std::process::id()
|
|
));
|
|
fs::write(&outside, "baseline\n").expect("outside baseline file must be writable");
|
|
|
|
let linked_target = base.join("unknown-dc.log");
|
|
let _ = fs::remove_file(&linked_target);
|
|
symlink(&outside, &linked_target).expect("symlink target must be creatable");
|
|
|
|
let rel_file = format!(
|
|
"target/telemt-unknown-dc-no-write-link-{}/unknown-dc.log",
|
|
std::process::id()
|
|
);
|
|
let dc_idx: i16 = 31_050;
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.general.unknown_dc_file_log_enabled = true;
|
|
cfg.general.unknown_dc_log_path = Some(rel_file);
|
|
|
|
let before = fs::read_to_string(&outside).expect("must read baseline outside file");
|
|
let _ = get_dc_addr_static(dc_idx, &cfg).expect("fallback routing must still work");
|
|
tokio::time::sleep(Duration::from_millis(80)).await;
|
|
let after = fs::read_to_string(&outside).expect("must read outside file after attempt");
|
|
|
|
assert_eq!(
|
|
after, before,
|
|
"symlink target escape must not be written by unknown-DC logging"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fallback_dc_never_panics_with_single_dc_list() {
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.network.prefer = 6;
|
|
cfg.network.ipv6 = Some(true);
|
|
cfg.default_dc = Some(42);
|
|
|
|
let addr = get_dc_addr_static(999, &cfg).expect("fallback dc must resolve safely");
|
|
let expected = SocketAddr::new(TG_DATACENTERS_V6[0], TG_DATACENTER_PORT);
|
|
assert_eq!(addr, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_relay_abort_midflight_releases_route_gauge() {
|
|
let tg_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let tg_addr = tg_listener.local_addr().unwrap();
|
|
|
|
let tg_accept_task = tokio::spawn(async move {
|
|
let (stream, _) = tg_listener.accept().await.unwrap();
|
|
let _hold_stream = stream;
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
});
|
|
|
|
let stats = Arc::new(Stats::new());
|
|
let mut config = ProxyConfig::default();
|
|
config
|
|
.dc_overrides
|
|
.insert("2".to_string(), vec![tg_addr.to_string()]);
|
|
let config = Arc::new(config);
|
|
|
|
let upstream_manager = Arc::new(UpstreamManager::new(
|
|
vec![UpstreamConfig {
|
|
upstream_type: UpstreamType::Direct {
|
|
interface: None,
|
|
bind_addresses: None,
|
|
},
|
|
weight: 1,
|
|
enabled: true,
|
|
scopes: String::new(),
|
|
selected_scope: String::new(),
|
|
}],
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
false,
|
|
stats.clone(),
|
|
));
|
|
|
|
let rng = Arc::new(SecureRandom::new());
|
|
let buffer_pool = Arc::new(BufferPool::new());
|
|
let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct));
|
|
let route_snapshot = route_runtime.snapshot();
|
|
|
|
let (server_side, client_side) = duplex(64 * 1024);
|
|
let (server_reader, server_writer) = tokio::io::split(server_side);
|
|
let client_reader = make_crypto_reader(server_reader);
|
|
let client_writer = make_crypto_writer(server_writer);
|
|
|
|
let success = HandshakeSuccess {
|
|
user: "abort-direct-user".to_string(),
|
|
dc_idx: 2,
|
|
proto_tag: ProtoTag::Intermediate,
|
|
dec_key: [0u8; 32],
|
|
dec_iv: 0,
|
|
enc_key: [0u8; 32],
|
|
enc_iv: 0,
|
|
peer: "127.0.0.1:50000".parse().unwrap(),
|
|
is_tls: false,
|
|
};
|
|
|
|
let relay_task = tokio::spawn(handle_via_direct(
|
|
client_reader,
|
|
client_writer,
|
|
success,
|
|
upstream_manager,
|
|
stats.clone(),
|
|
config,
|
|
buffer_pool,
|
|
rng,
|
|
route_runtime.subscribe(),
|
|
route_snapshot,
|
|
0xabad1dea,
|
|
));
|
|
|
|
let started = tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if stats.get_current_connections_direct() == 1 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await;
|
|
assert!(started.is_ok(), "direct relay must increment route gauge before abort");
|
|
|
|
relay_task.abort();
|
|
let joined = relay_task.await;
|
|
assert!(joined.is_err(), "aborted direct relay task must return join error");
|
|
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
assert_eq!(
|
|
stats.get_current_connections_direct(),
|
|
0,
|
|
"route gauge must be released when direct relay task is aborted mid-flight"
|
|
);
|
|
|
|
drop(client_side);
|
|
tg_accept_task.abort();
|
|
let _ = tg_accept_task.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_relay_cutover_midflight_releases_route_gauge() {
|
|
let tg_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let tg_addr = tg_listener.local_addr().unwrap();
|
|
|
|
let tg_accept_task = tokio::spawn(async move {
|
|
let (stream, _) = tg_listener.accept().await.unwrap();
|
|
let _hold_stream = stream;
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
});
|
|
|
|
let stats = Arc::new(Stats::new());
|
|
let mut config = ProxyConfig::default();
|
|
config
|
|
.dc_overrides
|
|
.insert("2".to_string(), vec![tg_addr.to_string()]);
|
|
let config = Arc::new(config);
|
|
|
|
let upstream_manager = Arc::new(UpstreamManager::new(
|
|
vec![UpstreamConfig {
|
|
upstream_type: UpstreamType::Direct {
|
|
interface: None,
|
|
bind_addresses: None,
|
|
},
|
|
weight: 1,
|
|
enabled: true,
|
|
scopes: String::new(),
|
|
selected_scope: String::new(),
|
|
}],
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
false,
|
|
stats.clone(),
|
|
));
|
|
|
|
let rng = Arc::new(SecureRandom::new());
|
|
let buffer_pool = Arc::new(BufferPool::new());
|
|
let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct));
|
|
let route_snapshot = route_runtime.snapshot();
|
|
|
|
let (server_side, client_side) = duplex(64 * 1024);
|
|
let (server_reader, server_writer) = tokio::io::split(server_side);
|
|
let client_reader = make_crypto_reader(server_reader);
|
|
let client_writer = make_crypto_writer(server_writer);
|
|
|
|
let success = HandshakeSuccess {
|
|
user: "cutover-direct-user".to_string(),
|
|
dc_idx: 2,
|
|
proto_tag: ProtoTag::Intermediate,
|
|
dec_key: [0u8; 32],
|
|
dec_iv: 0,
|
|
enc_key: [0u8; 32],
|
|
enc_iv: 0,
|
|
peer: "127.0.0.1:50002".parse().unwrap(),
|
|
is_tls: false,
|
|
};
|
|
|
|
let relay_task = tokio::spawn(handle_via_direct(
|
|
client_reader,
|
|
client_writer,
|
|
success,
|
|
upstream_manager,
|
|
stats.clone(),
|
|
config,
|
|
buffer_pool,
|
|
rng,
|
|
route_runtime.subscribe(),
|
|
route_snapshot,
|
|
0xface_cafe,
|
|
));
|
|
|
|
tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if stats.get_current_connections_direct() == 1 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("direct relay must increment route gauge before cutover");
|
|
|
|
assert!(
|
|
route_runtime.set_mode(RelayRouteMode::Middle).is_some(),
|
|
"cutover must advance route generation"
|
|
);
|
|
|
|
let relay_result = tokio::time::timeout(Duration::from_secs(6), relay_task)
|
|
.await
|
|
.expect("direct relay must terminate after cutover")
|
|
.expect("direct relay task must not panic");
|
|
assert!(
|
|
relay_result.is_err(),
|
|
"cutover should terminate direct relay session"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
relay_result,
|
|
Err(ProxyError::Proxy(ref msg)) if msg == ROUTE_SWITCH_ERROR_MSG
|
|
),
|
|
"client-visible cutover error must stay generic and avoid route-internal metadata"
|
|
);
|
|
|
|
assert_eq!(
|
|
stats.get_current_connections_direct(),
|
|
0,
|
|
"route gauge must be released when direct relay exits on cutover"
|
|
);
|
|
|
|
drop(client_side);
|
|
tg_accept_task.abort();
|
|
let _ = tg_accept_task.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_relay_cutover_storm_multi_session_keeps_generic_errors_and_releases_gauge() {
|
|
let session_count = 6usize;
|
|
let tg_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let tg_addr = tg_listener.local_addr().unwrap();
|
|
|
|
let tg_accept_task = tokio::spawn(async move {
|
|
let mut held_streams = Vec::with_capacity(session_count);
|
|
for _ in 0..session_count {
|
|
let (stream, _) = tg_listener.accept().await.unwrap();
|
|
held_streams.push(stream);
|
|
}
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
drop(held_streams);
|
|
});
|
|
|
|
let stats = Arc::new(Stats::new());
|
|
let mut config = ProxyConfig::default();
|
|
config
|
|
.dc_overrides
|
|
.insert("2".to_string(), vec![tg_addr.to_string()]);
|
|
let config = Arc::new(config);
|
|
|
|
let upstream_manager = Arc::new(UpstreamManager::new(
|
|
vec![UpstreamConfig {
|
|
upstream_type: UpstreamType::Direct {
|
|
interface: None,
|
|
bind_addresses: None,
|
|
},
|
|
weight: 1,
|
|
enabled: true,
|
|
scopes: String::new(),
|
|
selected_scope: String::new(),
|
|
}],
|
|
1,
|
|
1,
|
|
1,
|
|
1,
|
|
false,
|
|
stats.clone(),
|
|
));
|
|
|
|
let rng = Arc::new(SecureRandom::new());
|
|
let buffer_pool = Arc::new(BufferPool::new());
|
|
let route_runtime = Arc::new(RouteRuntimeController::new(RelayRouteMode::Direct));
|
|
let route_snapshot = route_runtime.snapshot();
|
|
|
|
let mut relay_tasks = Vec::with_capacity(session_count);
|
|
let mut client_sides = Vec::with_capacity(session_count);
|
|
|
|
for idx in 0..session_count {
|
|
let (server_side, client_side) = duplex(64 * 1024);
|
|
client_sides.push(client_side);
|
|
let (server_reader, server_writer) = tokio::io::split(server_side);
|
|
let client_reader = make_crypto_reader(server_reader);
|
|
let client_writer = make_crypto_writer(server_writer);
|
|
|
|
let success = HandshakeSuccess {
|
|
user: format!("cutover-storm-direct-user-{idx}"),
|
|
dc_idx: 2,
|
|
proto_tag: ProtoTag::Intermediate,
|
|
dec_key: [0u8; 32],
|
|
dec_iv: 0,
|
|
enc_key: [0u8; 32],
|
|
enc_iv: 0,
|
|
peer: SocketAddr::new(
|
|
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
|
|
51000 + idx as u16,
|
|
),
|
|
is_tls: false,
|
|
};
|
|
|
|
relay_tasks.push(tokio::spawn(handle_via_direct(
|
|
client_reader,
|
|
client_writer,
|
|
success,
|
|
upstream_manager.clone(),
|
|
stats.clone(),
|
|
config.clone(),
|
|
buffer_pool.clone(),
|
|
rng.clone(),
|
|
route_runtime.subscribe(),
|
|
route_snapshot,
|
|
0xA000_0000 + idx as u64,
|
|
)));
|
|
}
|
|
|
|
tokio::time::timeout(Duration::from_secs(4), async {
|
|
loop {
|
|
if stats.get_current_connections_direct() == session_count as u64 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("all direct sessions must become active before cutover storm");
|
|
|
|
let route_runtime_flipper = route_runtime.clone();
|
|
let flipper = tokio::spawn(async move {
|
|
for step in 0..64u32 {
|
|
let mode = if (step & 1) == 0 {
|
|
RelayRouteMode::Middle
|
|
} else {
|
|
RelayRouteMode::Direct
|
|
};
|
|
let _ = route_runtime_flipper.set_mode(mode);
|
|
tokio::time::sleep(Duration::from_millis(15)).await;
|
|
}
|
|
});
|
|
|
|
for relay_task in relay_tasks {
|
|
let relay_result = tokio::time::timeout(Duration::from_secs(10), relay_task)
|
|
.await
|
|
.expect("direct relay task must finish under cutover storm")
|
|
.expect("direct relay task must not panic");
|
|
|
|
assert!(
|
|
matches!(
|
|
relay_result,
|
|
Err(ProxyError::Proxy(ref msg)) if msg == ROUTE_SWITCH_ERROR_MSG
|
|
),
|
|
"storm-cutover termination must remain generic for all direct sessions"
|
|
);
|
|
}
|
|
|
|
flipper.abort();
|
|
let _ = flipper.await;
|
|
|
|
assert_eq!(
|
|
stats.get_current_connections_direct(),
|
|
0,
|
|
"direct route gauge must return to zero after cutover storm"
|
|
);
|
|
|
|
drop(client_sides);
|
|
tg_accept_task.abort();
|
|
let _ = tg_accept_task.await;
|
|
}
|
|
|
|
#[test]
|
|
fn prefer_v6_override_matrix_prefers_matching_family_then_degrades_safely() {
|
|
let dc_idx: i16 = 2;
|
|
|
|
let mut cfg_a = ProxyConfig::default();
|
|
cfg_a.network.prefer = 6;
|
|
cfg_a.network.ipv6 = Some(true);
|
|
cfg_a.dc_overrides.insert(
|
|
dc_idx.to_string(),
|
|
vec![
|
|
"203.0.113.90:443".to_string(),
|
|
"[2001:db8::90]:443".to_string(),
|
|
],
|
|
);
|
|
let a = get_dc_addr_static(dc_idx, &cfg_a).expect("v6+v4 override set must resolve");
|
|
assert!(a.is_ipv6(), "prefer_v6 should choose v6 override when present");
|
|
|
|
let mut cfg_b = ProxyConfig::default();
|
|
cfg_b.network.prefer = 6;
|
|
cfg_b.network.ipv6 = Some(true);
|
|
cfg_b.dc_overrides
|
|
.insert(dc_idx.to_string(), vec!["203.0.113.91:443".to_string()]);
|
|
let b = get_dc_addr_static(dc_idx, &cfg_b).expect("v4-only override must still resolve");
|
|
assert!(b.is_ipv4(), "when no v6 override exists, v4 override must be used");
|
|
|
|
let mut cfg_c = ProxyConfig::default();
|
|
cfg_c.network.prefer = 6;
|
|
cfg_c.network.ipv6 = Some(true);
|
|
let c = get_dc_addr_static(dc_idx, &cfg_c).expect("table fallback must resolve");
|
|
assert_eq!(
|
|
c,
|
|
SocketAddr::new(TG_DATACENTERS_V6[(dc_idx as usize) - 1], TG_DATACENTER_PORT),
|
|
"without overrides, prefer_v6 path must resolve from static v6 datacenter table"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prefer_v6_override_matrix_ignores_invalid_entries_and_keeps_fail_closed_fallback() {
|
|
let dc_idx: i16 = 3;
|
|
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.network.prefer = 6;
|
|
cfg.network.ipv6 = Some(true);
|
|
cfg.dc_overrides.insert(
|
|
dc_idx.to_string(),
|
|
vec![
|
|
"not-an-addr".to_string(),
|
|
"also:bad".to_string(),
|
|
"203.0.113.55:443".to_string(),
|
|
],
|
|
);
|
|
|
|
let addr = get_dc_addr_static(dc_idx, &cfg).expect("at least one valid override must keep resolution alive");
|
|
assert_eq!(addr, "203.0.113.55:443".parse::<SocketAddr>().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn stress_prefer_v6_override_matrix_is_deterministic_under_mixed_inputs() {
|
|
for idx in 1..=5i16 {
|
|
let mut cfg = ProxyConfig::default();
|
|
cfg.network.prefer = 6;
|
|
cfg.network.ipv6 = Some(true);
|
|
cfg.dc_overrides.insert(
|
|
idx.to_string(),
|
|
vec![
|
|
format!("203.0.113.{}:443", 100 + idx),
|
|
format!("[2001:db8::{}]:443", 100 + idx),
|
|
],
|
|
);
|
|
|
|
let first = get_dc_addr_static(idx, &cfg).expect("first lookup must resolve");
|
|
let second = get_dc_addr_static(idx, &cfg).expect("second lookup must resolve");
|
|
assert_eq!(first, second, "override resolution must stay deterministic for dc {idx}");
|
|
assert!(first.is_ipv6(), "dc {idx}: v6 override should be preferred");
|
|
}
|
|
}
|