diff --git a/Cargo.lock b/Cargo.lock index 3cb8a51..691d53f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2900,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.5.0" +version = "3.5.1" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 729a12d..9fc99ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.5.0" +version = "3.5.1" edition = "2024" [features] @@ -72,7 +72,7 @@ anyhow = "1.0.102" reqwest = { version = "0.13.4", features = ["rustls"], default-features = false } notify = "8.2.0" ipnetwork = { version = "0.21.1", features = ["serde"] } -hyper = { version = "1.10.1", features = ["server", "http1"] } +hyper = { version = "1.10.1", features = ["client", "server", "http1"] } hyper-util = { version = "0.1.20", features = ["tokio", "server-auto"] } http-body-util = "0.1.3" httpdate = "1.0.3" diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index c1769df..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,2035 +0,0 @@ -# Telemt Relay Hardening — Implementation Plan - -## Ground Rules - -Every workstream follows this mandatory sequence: - -1. Write the full test suite first. Tests must fail on the current code for the reason being fixed. -2. Implement production changes until all new tests pass and no existing test regresses. -3. A failing red test is evidence of a real bug or gap. Never relax a test assertion — fix the code. -4. All test code goes in dedicated files under `src/proxy/tests/` (or the owning module's `#[cfg(test)]` block via `#[path]`). No inline `#[cfg(test)]` inside production code. -5. No PR lands in a non-compiling state. Every diff must be self-contained and `cargo test`-green. - -## Agreed Decisions - -| Topic | Decision | -|---|---| -| Item 3 `_buffer_pool` | Option B — repurpose the parameter for adaptive startup buffer sizes, not remove it | -| Item 4b in-session adaptation | Decision-gate phase: run experiment, measure, then choose one path | -| Item 1 Level 1 log-normal | Independent of PR-B and PR-C — can land after PR-A only | -| Scope | All items (1, 2, 3, 4a, 4b, 5) in one master plan, separate PRs | - ---- - -## PR Dependency Graph - -``` -PR-A (baseline test harness) - ├─► PR-C (Item 5: DRS — independent of DI, self-contained) - ├─► PR-F (Item 1 Level 1: log-normal — independent, no shared-state changes) - └─► PR-B (Item 2: DI migration — high-risk blast radius) - └─► PR-D (Items 3+4a: adaptive startup) - └─► PR-E (Item 4b: decision gate) - └─► PR-G (Item 1 Level 2: state-aware IPT) -PR-H (docs + release gate) -``` - -**NEW ORDERING RATIONALE** (per audit recommendations): -- **PR-C before PR-B**: DRS is self-contained, needs only `is_tls` flag (already in `HandshakeSuccess`) and a new `drs_enabled` config field. No dependency on the large DI refactor. Delivers anti-censorship value immediately. Reduces risk of a stuck dependency chain if PR-B becomes complicated. -- **PR-F independent**: Log-normal replacement modifies only two `rng.random_range()` call sites in `masking.rs` and `handshake.rs`. Zero dependency on DI or DRS. Can be parallelized with PR-C and PR-B. -- **PR-B then PR-D**: DI must be complete before adaptive startup wiring, as both involve injecting state. -- **PR-A first, always**: Baseline gates must lock before any code changes. - -Parallelization: PR-C and PR-B test-writing can happen in parallel once PR-A is done; production code integration is sequential. - ---- - -## PR-A — Baseline Test Harness (Phase 1) - -**Goal**: Establish regression gates and shared test utilities that all subsequent PRs depend on. No runtime behavior changes. - -**TDD compatibility note**: Phase 1 is a characterization and invariant-lock phase. Its baseline tests are intentionally green on current code and exist to freeze security-critical behavior before refactors. This does **not** waive red-first TDD for later phases: every behavior-changing PR after Phase 1 must begin with red tests that fail on then-current code. - -**Security objective for Phase 1**: lock anti-probing and anti-fingerprinting behavior before protocol-shape changes. Phase 1 tests must include positive, negative, edge, and adversarial scanner cases with deterministic CI execution and strict fail-closed oracles. - -**Split into two sub-phases** (reduces risk: if test utilities need iteration, baseline tests aren't blocked): - -- **PR-A.1**: Shared test utilities only. Zero behavior assertions. Merge gate: compiles. -- **PR-A.2**: Baseline invariant tests. All green on current code. Depends on PR-A.1. - -### PR-A.1: Shared test utilities - -#### New file: `src/proxy/tests/test_harness_common.rs` - -**MODULE DECLARATION**: Declare **once** in `src/proxy/mod.rs` as: -```rust -#[cfg(test)] -#[path = "tests/test_harness_common.rs"] -mod test_harness_common; -``` - -**DO NOT** declare via `#[path]` in relay.rs, handshake.rs, or middle_relay.rs. Including the same file via `#[path]` in multiple modules duplicates all definitions and causes compilation errors (see F15). Consuming test modules import via `use crate::proxy::test_harness_common::*;` (or selective imports). - -**NOTE**: Existing 104 test files already define ad-hoc test utilities inline (e.g., `ScriptedWriter` in `relay_atomic_quota_invariant_tests.rs`, `PendingWriter` in `masking_security_tests.rs`, `seeded_rng` in `masking_lognormal_timing_security_tests.rs`, `test_config_with_secret_hex` in `handshake_security_tests.rs`). The harness consolidates these for reuse but does **not** retroactively migrate existing files — that would inflate PR-A's blast radius for zero safety gain. - -Contents: - -```rust -use crate::config::ProxyConfig; -use rand::rngs::StdRng; -use rand::SeedableRng; -use std::io; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context, Poll}; -use tokio::io::AsyncWrite; - -// ── RecordingWriter ───────────────────────────────────────────────── -// In-memory AsyncWrite that records both per-write and per-flush granularity. -// -// `writes`: one entry per poll_write call (records write-call boundaries). -// `flushed`: one entry per poll_flush call (records record/TLS-frame boundaries). -// Each entry is all bytes accumulated since the previous flush. -// -// DRS tests (PR-C) need flush-boundary tracking to verify TLS record framing. -// The dual tracking avoids needing separate writer types for different test needs. -pub struct RecordingWriter { - pub writes: Vec>, - pub flushed: Vec>, - current_record: Vec, -} - -impl RecordingWriter { - pub fn new() -> Self { - Self { - writes: Vec::new(), - flushed: Vec::new(), - current_record: Vec::new(), - } - } - - /// Total bytes written across all writes. - pub fn total_bytes(&self) -> usize { - self.writes.iter().map(|w| w.len()).sum() - } -} - -impl AsyncWrite for RecordingWriter { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let me = self.as_mut().get_mut(); - me.writes.push(buf.to_vec()); - me.current_record.extend_from_slice(buf); - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - let me = self.as_mut().get_mut(); - let record = std::mem::take(&mut me.current_record); - if !record.is_empty() { - me.flushed.push(record); - } - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} - -// ── PendingCountWriter ────────────────────────────────────────────── -// Returns Poll::Pending for the first N poll_write calls, then delegates to inner. -// Also supports separate pending-count control for poll_flush calls. -// -// Needed for DRS tests (PR-C): -// - drs_pending_on_write_does_not_increment_completed_counter -// - drs_pending_on_flush_propagates_pending_without_spurious_wake -// -// Unlike the existing masking_security_tests.rs PendingWriter (which is -// unconditionally Pending forever), this supports counted transitions. -pub struct PendingCountWriter { - pub inner: W, - pub write_pending_remaining: usize, - pub flush_pending_remaining: usize, -} - -impl PendingCountWriter { - pub fn new(inner: W, write_pending: usize, flush_pending: usize) -> Self { - Self { - inner, - write_pending_remaining: write_pending, - flush_pending_remaining: flush_pending, - } - } -} - -impl AsyncWrite for PendingCountWriter { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let me = self.as_mut().get_mut(); - if me.write_pending_remaining > 0 { - me.write_pending_remaining -= 1; - cx.waker().wake_by_ref(); - return Poll::Pending; - } - Pin::new(&mut me.inner).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let me = self.as_mut().get_mut(); - if me.flush_pending_remaining > 0 { - me.flush_pending_remaining -= 1; - cx.waker().wake_by_ref(); - return Poll::Pending; - } - Pin::new(&mut me.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -// ── Deterministic seeded RNG ──────────────────────────────────────── -// Wraps StdRng::seed_from_u64 for reproducible CI runs. -// -// LIMITATION: Cannot substitute for SecureRandom in production function calls. -// Production code that accepts &SecureRandom requires a project-specific wrapper. -// Tests needing deterministic behavior of production functions that accept -// `impl Rng` (like sample_lognormal_percentile_bounded) can use this directly. -// Tests calling functions that take &SecureRandom must use SecureRandom::new(). -pub fn seeded_rng(seed: u64) -> StdRng { - StdRng::seed_from_u64(seed) -} - -// ── Config builders ───────────────────────────────────────────────── -// Builds a minimal ProxyConfig with TLS mode enabled. -// Unlike the per-test-file `test_config_with_secret_hex` helpers, this produces -// a config suitable for relay tests that need is_tls=true but don't need -// handshake secret validation. -pub fn tls_only_config() -> Arc { - let mut cfg = ProxyConfig::default(); - cfg.general.modes.tls = true; - Arc::new(cfg) -} - -// Builds a ProxyConfig with a test user and secret for handshake tests. -// Requires auth_probe, masking, and SNI configuration for full handshake paths. -pub fn handshake_test_config(secret_hex: &str) -> ProxyConfig { - let mut cfg = ProxyConfig::default(); - cfg.access.users.clear(); - cfg.access - .users - .insert("test-user".to_string(), secret_hex.to_string()); - cfg.access.ignore_time_skew = true; - cfg.censorship.mask = true; - cfg.censorship.mask_host = Some("127.0.0.1".to_string()); - cfg.censorship.mask_port = 0; // Overridden by caller with actual listener port - cfg -} -``` - -**DROPPED UTILITIES** (vs original plan): -- `SliceReader`: Unnecessary. `tokio::io::duplex()` channels (used in every existing relay test) and `std::io::Cursor>` (which implements `AsyncRead` via tokio) already solve this. Adding a `bytes`-crate-dependent `SliceReader` introduces coupling for zero gain. -- `test_stats() -> Arc`: Trivial one-liner (`Arc::new(Stats::new())`). Every existing test already constructs this inline. A wrapper adds indirection without value. -- `test_buffer_pool() -> Arc`: Same reasoning — `Arc::new(BufferPool::new())` is a one-liner already used everywhere. - -#### PR-A.1 Merge gate - -`cargo check --tests` — compiles with no errors. No behavior assertions yet. - -Determinism gate for all new Phase 1 tests: -- Seed RNG-dependent tests via `seeded_rng(...)` (or explicit fixed seeds). -- For timing-sensitive async-delay tests (for example server-hello delay or relay watchdog timing), use paused tokio time and explicit time advancement instead of wall-clock sleeps. -- Avoid shared mutable cross-test coupling except temporary helpers explicitly called out in this plan (`auth_probe_test_lock`, `relay_idle_pressure_test_scope`, `desync_dedup_test_lock`) until PR-B removes them. -- Use explicit per-test IO timeouts (`tokio::time::timeout`) on network/fallback paths to prevent deadlocks and scheduler-dependent flakes. -- Keep all adversarial corpora deterministic (fixed vectors, fixed seed order). No nondeterministic fuzz in default CI. - ---- - -### PR-A.2: Baseline invariant tests - -All tests in this sub-phase **must pass on current code** — they are regression locks, not red tests. They lock existing behavior before subsequent PRs modify it. - -**DESIGN PRINCIPLE**: Tests must be **implementation-agnostic**. Test through public/`pub(crate)` functions, not through direct static access. This ensures PR-B (which moves statics into `ProxySharedState`) does not break baseline tests. - -**SCOPE DISCIPLINE**: Phase 1 should lock boundary behavior and narrow invariants only. It must not duplicate deep transport choreography, quota accounting, or close-matrix coverage that is already exercised elsewhere unless the baseline adds a new security oracle that later PRs could realistically regress unnoticed. - -**TEST ISOLATION**: All handshake baseline tests must use the existing `auth_probe_test_lock()` / `clear_auth_probe_state_for_testing()` pattern until PR-B replaces it. Middle-relay idle tests use `relay_idle_pressure_test_scope()` / `clear_relay_idle_pressure_state_for_testing()`, while desync tests use `desync_dedup_test_lock()` / `clear_desync_dedup_for_testing()`. This is temporary coupling that PR-B will eliminate. - -**FAIL-CLOSED ASSERTION POLICY (mandatory for Phase 1)**: -- Any probe/fallback error path must assert one of: (a) transparent mask-host relay behavior or (b) silent close / generic transport failure. -- Tests must never assert proxy-identifying payloads, banners, or protocol-specific error hints. -- For "no identity leak" cases, assert observable behavior (bytes sent, connection state, error class) rather than brittle log text matching. - -#### New file: `src/proxy/tests/relay_baseline_invariant_tests.rs` - -Declared in `src/proxy/relay.rs` via: -```rust -#[cfg(test)] -#[path = "tests/relay_baseline_invariant_tests.rs"] -mod relay_baseline_invariant_tests; -``` - -**De-duplication audit**: The existing 7 relay test files cover quota boundary attacks, quota overflow, watchdog delta, and adversarial HOL blocking. The baseline tests below cover **different invariants** not locked by existing tests, verified against the existing test names: -- `relay_watchdog_delta_security_tests.rs` tests `watchdog_delta()` function exhaustively — **overlaps** with `relay_baseline_watchdog_delta_handles_wraparound_gracefully`. **DROP** the watchdog delta baseline test (existing tests already lock this behavior fully). -- `relay_adversarial_tests.rs::relay_hol_blocking_prevention_regression` exercises bidirectional transfer but does not assert symmetric byte counting. **KEEP** the symmetric-counting baseline. -- No existing test covers the zero-byte transfer case. **KEEP**. -- No existing test covers the activity timeout firing path. **KEEP**. -- Existing end-to-end quota cutoff coverage already exists in `relay_quota_boundary_blackhat_tests.rs`. **DROP** duplicate quota-cutoff baseline here. -- Existing half-close chaos coverage already exists in `relay_adversarial_tests.rs::relay_chaos_half_close_crossfire_terminates_without_hang`. **DROP** duplicate half-close baseline here. - -``` -// Positive: relay with no data flow for >ACTIVITY_TIMEOUT returns Ok. -// Verifies watchdog fires and select! cancels copy_bidirectional cleanly. -relay_baseline_activity_timeout_fires_after_inactivity - -// Positive: relay with immediate close on both sides returns Ok(()) -// and both StatsIo byte counters read zero. -relay_baseline_zero_bytes_returns_ok_and_counters_zero - -// Positive: transfer N bytes C→S and M bytes S→C simultaneously. -// Assert StatsIo counters match exactly (no double-counting or loss). -relay_baseline_bidirectional_bytes_counted_symmetrically - -// Error path: both duplex sides close simultaneously (EOF race). -// relay_bidirectional returns without panic. -relay_baseline_both_sides_close_simultaneously_no_panic - -// Error path: server-side writer returns BrokenPipe mid-transfer. -// relay_bidirectional propagates error without panic. -relay_baseline_broken_pipe_midtransfer_returns_error - -// Adversarial: single-byte writes for 10000 iterations. -// Assert counters exactly 10000 (no off-by-one in StatsIo accounting). -relay_baseline_many_small_writes_exact_counter -``` - -Oracle requirements (mandatory): -- `relay_baseline_activity_timeout_fires_after_inactivity`: use paused tokio time. Assert the relay does **not** complete before `ACTIVITY_TIMEOUT`, then does complete after advancing past `ACTIVITY_TIMEOUT + WATCHDOG_INTERVAL` with bounded slack. Do not assert a wall-clock range that ignores the watchdog cadence. -- `relay_baseline_zero_bytes_returns_ok_and_counters_zero`: assert both directions observe EOF (`read` returns `0`) and `stats.get_user_total_octets(user) == 0`. -- `relay_baseline_bidirectional_bytes_counted_symmetrically`: send fixed payload sizes `N` and `M`; assert exact byte equality on both peers and exact counter equality (`N + M` total accounted where applicable). -- `relay_baseline_both_sides_close_simultaneously_no_panic`: assert join result is `Ok(Ok(()))` (not just "did not panic"). -- `relay_baseline_broken_pipe_midtransfer_returns_error`: assert typed error class (`io::ErrorKind::BrokenPipe` or mapped proxy error) and no process crash. -- `relay_baseline_many_small_writes_exact_counter`: enforce upper runtime bound with `timeout(Duration::from_secs(3), ...)` and assert exact transferred/accounted byte count. - -#### New file: `src/proxy/tests/handshake_baseline_invariant_tests.rs` - -Declared in `src/proxy/handshake.rs` via: -```rust -#[cfg(test)] -#[path = "tests/handshake_baseline_invariant_tests.rs"] -mod handshake_baseline_invariant_tests; -``` - -**De-duplication audit**: The existing 13 handshake test files heavily test auth_probe behavior, bit-flip rejection, key zeroization, and timing. The baseline tests below lock specific invariants at the function-call boundary level: - -**LAYERING RULE**: `handle_tls_handshake(...)` is a handshake classifier/authenticator, not the masking relay itself. Handshake baseline tests must stop at the `HandshakeResult` boundary. Actual client-visible fallback relay behavior belongs in masking/client baselines, not in direct handshake tests. - -**TEST ISOLATION**: Each test acquires `auth_probe_test_lock()` / `unknown_sni_warn_test_lock()` / `warned_secrets_test_lock()` as needed. Each test calls the corresponding `clear_*_for_testing()` at the start. All tests use the existing `test_config_with_secret_hex`-style config construction (via the new `handshake_test_config` helper or inline). - -``` -// Positive: unrecognized handshake bytes classify as `BadClient` rather than -// a success path. This locks the invariant that garbage input is rejected -// without exposing proxy-specific success semantics at the handshake boundary. -handshake_baseline_probe_always_falls_back_to_masking - -// Positive: valid TLS ClientHello but wrong secret stays on the non-success -// path, not an authenticated handshake success. -handshake_baseline_invalid_secret_triggers_fallback_not_error_response - -// Positive: consecutive failed handshakes from same IP increment -// auth_probe_fail_streak for that IP. -// Tests through the public auth_probe_fail_streak_for_testing() accessor. -handshake_baseline_auth_probe_streak_increments_per_ip - -// Positive: after AUTH_PROBE_BACKOFF_START_FAILS consecutive failures, -// the IP is throttled. Tests through auth_probe_is_throttled_for_testing(). -// NOTE: AUTH_PROBE_BACKOFF_START_FAILS is a compile-time constant -// (different values for #[cfg(test)] and production). Name reflects this. -handshake_baseline_saturation_fires_at_compile_time_threshold - -// Adversarial: attacker sends 100 handshakes with distinct invalid secrets -// from the same IP. Verify auth_probe streak grows monotonically. -handshake_baseline_repeated_probes_streak_monotonic - -// Security: after throttle engages, the tracked auth-probe block window lasts -// for the computed backoff duration and then expires. -handshake_baseline_throttled_ip_incurs_backoff_delay - -// Adversarial: malformed TLS-like probe frames (truncated record header, -// impossible length fields, random high-entropy payload) never panic and -// never classify as successful handshakes. -handshake_baseline_malformed_probe_frames_fail_closed_to_masking -``` - -Oracle requirements (mandatory): -- `handshake_baseline_probe_always_falls_back_to_masking`: assert `HandshakeResult::BadClient { .. }` (or equivalent non-success fallback classification). Do **not** require direct observation of downstream mask-host IO at this layer. -- `handshake_baseline_invalid_secret_triggers_fallback_not_error_response`: assert non-success handshake classification and no success-path key material/result. Client-visible fallback behavior is covered in masking/client tests. -- `handshake_baseline_auth_probe_streak_increments_per_ip`: assert monotonic increment by exact delta `+1` per failed attempt for one IP, with no mutation for untouched IPs. -- `handshake_baseline_saturation_fires_at_compile_time_threshold`: assert transition point occurs exactly at `AUTH_PROBE_BACKOFF_START_FAILS` (not before) and remains throttled after threshold. -- `handshake_baseline_repeated_probes_streak_monotonic`: assert strictly non-decreasing streak over deterministic 100-attempt corpus. -- `handshake_baseline_throttled_ip_incurs_backoff_delay`: this Phase 1 baseline locks the **internal throttle window semantics**, not wire-visible sleep duration. Assert the tracked block window lasts at least `auth_probe_backoff(AUTH_PROBE_BACKOFF_START_FAILS)` and expires after that bound. If client-visible delay coverage is desired, add a separate async test with `server_hello_delay_min_ms == server_hello_delay_max_ms` through the client/handshake entrypoint. -- `handshake_baseline_malformed_probe_frames_fail_closed_to_masking`: run deterministic malformed corpus; assert no success result, no panic, and bounded completion per case. Do not over-assert downstream masking transport from the handshake-only boundary. - -Timing requirement for this file: -- Use paused tokio time only for tests that actually measure async sleep behavior. Tracker-only tests may use synthetic `Instant` arithmetic and must avoid wall-clock sleeps. - -#### New file: `src/proxy/tests/middle_relay_baseline_invariant_tests.rs` - -Declared in `src/proxy/middle_relay.rs` via: -```rust -#[cfg(test)] -#[path = "tests/middle_relay_baseline_invariant_tests.rs"] -mod middle_relay_baseline_invariant_tests; -``` - -**DESIGN**: Existing middle-relay suites already exercise idle/desync behavior extensively, but many assertions are tightly coupled to current internals/statics. Phase 1 only adds minimal **stable helper-boundary contract locks** that must remain stable across PR-B. - -**TEST ISOLATION**: Idle-registry tests acquire `relay_idle_pressure_test_scope()` and call `clear_relay_idle_pressure_state_for_testing()` at the start. Desync-dedup tests acquire `desync_dedup_test_lock()` and call `clear_desync_dedup_for_testing()` at the start. Do not rely on one lock to serialize the other registry. - -``` -// API contract: mark+oldest+clear round-trip through stable helper functions only, -// without direct access to internal registries/statics. -middle_relay_baseline_public_api_idle_roundtrip_contract - -// API contract: dedup suppress/allow semantics through stable helper entry, -// without asserting internal map layout. -middle_relay_baseline_public_api_desync_window_contract -``` - -Oracle requirements (mandatory): -- `middle_relay_baseline_public_api_idle_roundtrip_contract`: assert first `mark_relay_idle_candidate(conn)` returns `true`, `oldest_relay_idle_candidate() == Some(conn)`, after clear it is not `Some(conn)`, and a second mark after clear succeeds. -- `middle_relay_baseline_public_api_desync_window_contract`: through the stable helper boundary only, assert first event emits, duplicate within window suppresses, and post-rotation/window-advance emits again. - -#### New file: `src/proxy/tests/masking_baseline_invariant_tests.rs` - -Declared in `src/proxy/masking.rs` via: -```rust -#[cfg(test)] -#[path = "tests/masking_baseline_invariant_tests.rs"] -mod masking_baseline_invariant_tests; -``` - -**RATIONALE**: The masking module is the **primary anti-DPI component** — it makes the proxy appear to be a legitimate website when probed by censors. PR-F modifies `mask_outcome_target_budget` (log-normal replacement). Without baseline locks on masking timing behavior, PR-F could subtly regress the timing envelope with no detection. - -**DETERMINISM NOTE**: `mask_outcome_target_budget(...)` currently samples through an internal RNG, so Phase 1 cannot require a seeded exact output sequence from that function. Baseline tests here must assert stable invariants such as bounds and fail-closed behavior, not exact sample values or distribution shape. Distribution-quality assertions belong in PR-F once a deterministic seam exists or when tests force deterministic config such as `floor == ceiling`. - -The existing 37 masking test files cover specific attack scenarios but don't lock the **high-level behavioral contracts** that all subsequent PRs must preserve: - -``` -// Positive: mask_outcome_target_budget returns a Duration within -// [floor_ms, ceiling_ms] when normalization is enabled. -// This is the core anti-fingerprinting timing envelope. -masking_baseline_timing_normalization_budget_within_bounds - -// Positive: handle_bad_client with mask=true connects to the configured -// mask_host and forwards initial_data verbatim. Verifies the proxy -// correctly impersonates a legitimate website by relaying to the real backend. -masking_baseline_fallback_relays_to_mask_host - -// Security: mask_outcome_target_budget with timing_normalization_enabled=false -// returns the default masking budget (MASK_TIMEOUT), preserving legacy timing posture. -masking_baseline_no_normalization_returns_default_budget - -// Adversarial: mask_host is unreachable (connection refused). -// handle_bad_client must not panic and must fail closed (silent close or -// generic transport error), without proxy-identifying response bytes. -masking_baseline_unreachable_mask_host_silent_failure - -// Light fuzz: deterministic malformed initial_data corpus (length extremes, -// random binary, invalid UTF-8) must never panic. -masking_baseline_light_fuzz_initial_data_no_panic -``` - -Oracle requirements (mandatory): -- `masking_baseline_timing_normalization_budget_within_bounds`: assert every sampled budget satisfies `floor <= budget <= ceiling` across a fixed-size repeated sample loop. Do not require a seeded exact sequence from the current implementation. -- `masking_baseline_fallback_relays_to_mask_host`: assert exact byte preservation for forwarded `initial_data` and backend response relay to client. -- `masking_baseline_no_normalization_returns_default_budget`: assert exact default budget (`MASK_TIMEOUT`). -- `masking_baseline_unreachable_mask_host_silent_failure`: assert no proxy-identifying bytes are written to client and completion remains bounded. -- `masking_baseline_light_fuzz_initial_data_no_panic`: fixed malformed corpus only; assert no panic, bounded runtime per case, and no identity leak. - -De-duplication note: -- Existing masking suites already cover half-close lifecycle and bounded offline fallback timing (`masking_self_target_loop_security_tests.rs`, `masking_adversarial_tests.rs`, `masking_relay_guardrails_security_tests.rs`). -- Existing masking suites already cover strict byte-cap enforcement and cap overshoot regression (`masking_production_cap_regression_security_tests.rs`) plus broader close/failure matrices (`masking_connect_failure_close_matrix_security_tests.rs`). -- Phase 1 baseline masking tests therefore focus on top-level contracts (timing envelope bounds, fallback posture, unreachable-backend fail-closed behavior, light malformed-input robustness), not re-testing transport choreography already covered elsewhere. - -#### PR-A.2 Merge gate - -All tests pass on current code: -``` -cargo test -- relay_baseline_ -cargo test -- handshake_baseline_ -cargo test -- middle_relay_baseline_ -cargo test -- masking_baseline_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -cargo test # full suite — no regressions -``` - -Notes: -- `--test-threads=1` catches hidden ordering assumptions. -- `--test-threads=32` catches shared-state bleed and race-sensitive flakes. -- Heavy stress scenarios that are too expensive for default CI must be marked `#[ignore]` and run in dedicated security/perf pipelines, never deleted. -- Each adversarial baseline test must have an explicit upper runtime bound to keep CI deterministic. -- Any assertion that depends on wall-clock variance must use bounded ranges and paused time where applicable; exact wall-clock equality checks are forbidden. - -Phase 1 ASVS L2 alignment focus (test intent mapping): -- V1.2 / V1.4: fail-closed behavior and concurrency isolation under adversarial probe traffic. -- V7.4: cryptographic/protocol error handling does not leak identifying behavior. -- V9.1: communication behavior under malformed input is deterministic, bounded, and non-panicking. -- V13.2: degradation paths (fallback/masking) preserve security posture and do not disclose gateway identity. - ---- - -### Critical Review Issues Found and Addressed in PR-A - -| # | Severity | Issue from critique | Resolution | -|---|---|---|---| -| 1 | **Critical** | `test_harness_common.rs` has no valid declaration site; triple `#[path]` causes duplicate symbols | Declared once in `proxy/mod.rs`; consuming tests import via `use crate::proxy::test_harness_common::*` | -| 2 | **High** | `RecordingWriter` semantics ambiguous; flush-boundary tracking missing for DRS tests | Dual tracking: `writes` (per poll_write) + `flushed` (per poll_flush boundary with accumulator) | -| 3 | **High** | `SliceReader` unnecessarily requires `bytes` crate | **Dropped**. `tokio::io::duplex()` and `std::io::Cursor` already solve this | -| 4 | **Medium** | `PendingWriter` only controls `poll_write`; flush pending tests need separate control | Renamed to `PendingCountWriter` with separate `write_pending_remaining` and `flush_pending_remaining` | -| 5 | **Critical** | Baseline tests duplicate existing tests; `watchdog_delta` wraparound test trivially green | Watchdog delta baseline **dropped** (7 existing tests in `relay_watchdog_delta_security_tests.rs` cover it exhaustively). All other baselines audited against 104 existing test files. | -| 6 | **High** | Handshake baseline tests require complex scaffold not provided by `tls_only_config()` | Added `handshake_test_config(secret_hex)` builder with user, secret, auth settings, and masking config | -| 7 | **Medium** | `test_stats()` / `test_buffer_pool()` are trivial wrappers | **Dropped**. `Arc::new(Stats::new())` and `Arc::new(BufferPool::new())` are one-liners, universally inlined already | -| 8 | **High** | Middle relay baseline tests lock on global statics; PR-B removes them → guaranteed breakage | Tests call public functions (`mark_relay_idle_candidate`, `clear_relay_idle_candidate`) not statics. PR-B changes implementations, not function signatures. | -| 9 | **Medium** | `seeded_rng` returns `StdRng`, can't substitute for `SecureRandom` | Documented as explicit limitation in code comment | -| 10 | **Medium** | No test isolation strategy for auth_probe global state | Each handshake baseline test acquires `auth_probe_test_lock()` and calls `clear_auth_probe_state_for_testing()`. Documented as temporary coupling. | -| 11 | **Low** | "configured threshold" misnomer for compile-time constant | Renamed to `handshake_baseline_saturation_fires_at_compile_time_threshold` | -| 12 | **High** | Zero error-path regression locks in baseline suite | Added: `relay_baseline_both_sides_close_simultaneously_no_panic`, `relay_baseline_broken_pipe_midtransfer_returns_error` | -| 13 | **Medium** | `relay_baseline_empty_transfer_completes_without_error` is vague | Replaced with: `relay_baseline_zero_bytes_returns_ok_and_counters_zero` (sharp assertion) | -| 14 | **Medium** | No masking.rs baseline tests despite PR-F modifying masking | Added `masking_baseline_invariant_tests.rs` with timing/fallback/cap/adversarial tests | -| NEW-1 | **High** | PR-A text could be read as violating global TDD "red first" rule | Clarified Phase 1 as characterization-only; red-first remains mandatory for all behavior-changing phases | -| NEW-2 | **Medium** | "No production code changes" wording conflicts with required `#[cfg(test)]` module wiring | Corrected scope statement to "No runtime behavior changes" | -| NEW-3 | **High** | Fail-closed requirement was implicit, allowing weak "no panic"-only assertions | Added explicit fail-closed assertion policy for anti-probing paths | -| NEW-4 | **High** | Timing and network-path baselines risk CI flakiness/deadlocks | Added deterministic timeout and paused-time requirements | -| NEW-5 | **Medium** | Several proposed baselines duplicated existing relay/middle-relay/handshake coverage | Pruned duplicate cases (relay quota cutoff, relay half-close, unknown-SNI warn rate-limit) and reduced middle-relay baseline to API-contract-only tests | -| NEW-6 | **High** | Relay inactivity oracle ignored `WATCHDOG_INTERVAL`, making the timeout assertion architecturally wrong | Rewrote the oracle around paused-time advancement past `ACTIVITY_TIMEOUT + WATCHDOG_INTERVAL` | -| NEW-7 | **High** | Handshake baselines conflated `HandshakeResult::BadClient` with downstream masking relay behavior | Separated handshake-layer classification assertions from masking/client-layer fallback IO assertions | -| NEW-8 | **High** | Handshake "backoff delay" wording conflated auth-probe state tracking with wire-visible sleep latency | Re-scoped the baseline to throttle-window semantics and deferred client-visible delay checks to an explicit async entrypoint test | -| NEW-9 | **Medium** | Masking timing determinism requirement overstated what the current internal-RNG API can guarantee | Limited Phase 1 masking timing assertions to invariant bounds instead of seeded exact sequences | -| NEW-10 | **Medium** | Middle-relay isolation guidance omitted `desync_dedup_test_lock()`, leaving desync tests underspecified | Split idle-registry and desync-dedup isolation requirements by helper/lock | -| NEW-11 | **Medium** | Masking baseline list still carried redundant cases already covered by dedicated cap and close-matrix suites | Pruned duplicate cap/empty-input/partial-close baseline cases from mandatory Phase 1 scope | - ---- - -## PR-B — Item 2: Dependency Injection for Global Proxy State - -**Priority**: High. Blocks PR-D. (PR-C and PR-F are independent — see D1 below.) - -**TDD compatibility note**: PR-B cannot start with red tests that reference a non-existent `ProxySharedState` API, because that would fail at compile time rather than exposing the current runtime bug. Split PR-B into: -- **PR-B.0 (seam only, green)**: add `shared_state.rs`, define `ProxySharedState`, and thread an instance parameter through the call chain without changing storage semantics yet. -- **PR-B.1 (red)**: add isolation tests against the new seam; they must compile and fail on then-current code because the seam still routes into global state. -- **PR-B.2 (green)**: cut storage over from globals to per-instance state, then remove global reset/lock helpers. - -This keeps red-first TDD for the behavior change while allowing the minimum compile-time scaffolding needed to express the tests. - -### Problem (concrete) - -The **core blocker set** is the 12 handshake and middle-relay statics below. These are logically scoped to one running proxy instance but currently live at process scope, which forces test serialization and prevents two proxy instances in one process from remaining isolated: - -| Static | File | Line | Type | -|---|---|---|---| -| `AUTH_PROBE_STATE` | `src/proxy/handshake.rs` | 52 | `OnceLock>` | -| `AUTH_PROBE_SATURATION_STATE` | `src/proxy/handshake.rs` | 53 | `OnceLock>>` | -| `AUTH_PROBE_EVICTION_HASHER` | `src/proxy/handshake.rs` | 55 | `OnceLock` | -| `INVALID_SECRET_WARNED` | `src/proxy/handshake.rs` | 33 | `OnceLock>>` | -| `UNKNOWN_SNI_WARN_NEXT_ALLOWED` | `src/proxy/handshake.rs` | 39 | `OnceLock>>` | -| `DESYNC_DEDUP` | `src/proxy/middle_relay.rs` | 54 | `OnceLock>` | -| `DESYNC_DEDUP_PREVIOUS` | `src/proxy/middle_relay.rs` | 55 | `OnceLock>` | -| `DESYNC_HASHER` | `src/proxy/middle_relay.rs` | 56 | `OnceLock` | -| `DESYNC_FULL_CACHE_LAST_EMIT_AT` | `src/proxy/middle_relay.rs` | 57 | `OnceLock>>` | -| `DESYNC_DEDUP_ROTATION_STATE` | `src/proxy/middle_relay.rs` | 58 | `OnceLock>` | -| `RELAY_IDLE_CANDIDATE_REGISTRY` | `src/proxy/middle_relay.rs` | 61 | `OnceLock>` | -| `RELAY_IDLE_MARK_SEQ` | `src/proxy/middle_relay.rs` | 62 | `AtomicU64` (direct static) | - -**Explicitly out of core PR-B scope**: -- `USER_PROFILES` in `adaptive_buffers.rs` stays process-global for PR-D cross-session memory. It must **not** be counted as a per-instance DI blocker for PR-B. -- `LOGGED_UNKNOWN_DCS` in `direct_relay.rs` and the warning-dedup `AtomicBool` statics in `client.rs` are ancillary diagnostics caches, not core handshake/relay isolation state. Keep them for a follow-up consistency PR after the handshake and middle-relay cutover lands. - -These force a large body of tests to use `auth_probe_test_lock()`, `relay_idle_pressure_test_scope()`, and `desync_dedup_test_lock()` to stay deterministic. The current branch also has tests that read `AUTH_PROBE_STATE` and `DESYNC_DEDUP` directly, so the migration scope is larger than helper removal alone. - -### Step 1: Add seam, then write red tests (must fail on then-current code) - -**Important sequencing correction**: red tests for PR-B must be written **after** the non-behavioral seam from PR-B.0 exists, otherwise they cannot compile. They still remain red-first for the actual behavior change because the seam initially points to the old globals. - -**New file**: `src/proxy/tests/proxy_shared_state_isolation_tests.rs` -Declared in `src/proxy/mod.rs` via a single `#[cfg(test)] #[path = "tests/proxy_shared_state_isolation_tests.rs"] mod proxy_shared_state_isolation_tests;` declaration. **Do NOT declare in both handshake.rs and middle_relay.rs** — including the same file via `#[path]` in two modules duplicates all definitions and causes compilation errors. - -**TEST SCOPE**: These tests cover only the handshake and middle-relay state being migrated in core PR-B. Do not mix in `direct_relay.rs` unknown-DC logging or `client.rs` warning-dedup behavior here. - -``` -// Fails because AUTH_PROBE_STATE is global — second instance shares first's state. -proxy_shared_state_two_instances_do_not_share_auth_probe_state -// Fails because DESYNC_DEDUP is global. -proxy_shared_state_two_instances_do_not_share_desync_dedup -// Fails because RELAY_IDLE_CANDIDATE_REGISTRY is global. -proxy_shared_state_two_instances_do_not_share_idle_registry -// Fails: resetting state in instance A must not affect instance B. -proxy_shared_state_reset_in_one_instance_does_not_affect_another -// Fails: parallel tests increment the same IP counter in AUTH_PROBE_STATE. -proxy_shared_state_parallel_auth_probe_updates_stay_per_instance -// Fails: desync rotation in instance A must not advance rotation state of instance B. -proxy_shared_state_desync_window_rotation_is_per_instance -// Fails: idle seq counter is global AtomicU64, shared between instances. -proxy_shared_state_idle_mark_seq_is_per_instance -// Adversarial: attacker floods auth probe state in "proxy A" must not exhaust probe -// budget of unrelated "proxy B" sharing the process. -proxy_shared_state_auth_saturation_does_not_bleed_across_instances -``` - -**DROP from mandatory core PR-B**: -- `proxy_shared_state_poisoned_mutex_in_one_instance_does_not_panic_other`. This is too implementation-coupled for the initial red phase and is better expressed as targeted unit tests once per-instance lock recovery helpers exist. The core risk is cross-instance state bleed, not synthetic poisoning choreography. - -**New file**: `src/proxy/tests/proxy_shared_state_parallel_execution_tests.rs` - -``` -// Spawns 50 concurrent auth-probe updates against distinct ProxySharedState instances, -// asserts each instance's counter matches exactly what it received (no cross-talk). -proxy_shared_state_50_concurrent_instances_no_counter_bleed -// Desync dedup: 20 concurrent instances each performing window rotation, -// asserts rotation state is per-instance and not double-rotated. -proxy_shared_state_desync_rotation_concurrent_20_instances -// Idle registry: 10 concurrent mark+evict cycles across isolated instances, -// asserts no cross-eviction. -proxy_shared_state_idle_registry_concurrent_10_instances -``` - -### Step 2: Implement `ProxySharedState` - -**New file**: `src/proxy/shared_state.rs` - -**MUTEX TYPE**: All `Mutex` fields below are `std::sync::Mutex`, NOT `tokio::sync::Mutex`. The current codebase uses `std::sync::Mutex` for all these statics, and all critical sections are short (insert/get/retain) with no await points inside. Per Architecture.md §5: "Never hold a lock across an `await` unless atomicity explicitly requires it." Using `std::sync::Mutex` is correct here because: -1. Lock hold times are bounded (microseconds for DashMap/HashSet operations) -2. No `.await` is called while holding any of these locks -3. `tokio::sync::Mutex` would add unnecessary overhead for these synchronous operations - -```rust -use std::sync::Mutex; // NOT tokio::sync::Mutex — see note above - -pub struct HandshakeSharedState { - pub auth_probe: DashMap, - pub auth_probe_saturation: Mutex>, - pub auth_probe_eviction_hasher: RandomState, - pub invalid_secret_warned: Mutex>, - pub unknown_sni_warn_next_allowed: Mutex>, -} - -pub struct MiddleRelaySharedState { - pub desync_dedup: DashMap, - pub desync_dedup_previous: DashMap, - pub desync_hasher: RandomState, - pub desync_full_cache_last_emit_at: Mutex>, - pub desync_dedup_rotation_state: Mutex, - pub relay_idle_registry: Mutex, - // Monotonic counter; kept as AtomicU64 inside the struct, not a global. - pub relay_idle_mark_seq: AtomicU64, -} - -pub struct ProxySharedState { - pub handshake: HandshakeSharedState, - pub middle_relay: MiddleRelaySharedState, -} - -impl ProxySharedState { - pub fn new() -> Arc { ... } -} -``` - -Declare `pub mod shared_state;` in `src/proxy/mod.rs` between lines 61–69. - -`ProxySharedState` is architecturally: state that (a) must survive across multiple concurrent connections, (b) is logically scoped to one running proxy instance, not the whole process. Aligns with Architecture.md §3.1 Singleton rule: "pass shared state explicitly via `Arc`." - -**Scope correction**: `ProxySharedState` in core PR-B should contain only handshake and middle-relay shared state. Do **not** add `adaptive_buffers::USER_PROFILES` here. - -### Step 3: Thread `Arc` through the call chain - -**`src/proxy/handshake.rs`** - -Current signature of `handle_tls_handshake` (line 690): -```rust -pub async fn handle_tls_handshake( - handshake: &[u8], - reader: R, - mut writer: W, - peer: SocketAddr, - config: &ProxyConfig, - replay_checker: &ReplayChecker, - rng: &SecureRandom, - tls_cache: Option>, -) -> HandshakeResult<...> -``` - -New signature — add one parameter at the end: -```rust - shared: &ProxySharedState, // ← add as last parameter -``` - -Current signature of `handle_mtproto_handshake` (line 854): same pattern — add `shared: &ProxySharedState` as last parameter. - -All internal calls to `auth_probe_state_map()`, `auth_probe_saturation_state()`, `warn_invalid_secret_once()`, `unknown_sni_warn_state_lock()` are replaced with direct field access on `&shared.handshake`. The five accessor functions (`auth_probe_state_map`, `auth_probe_saturation_state`, `unknown_sni_warn_state_lock`) are deleted. - -**`src/proxy/middle_relay.rs`** - -Current signature of `handle_via_middle_proxy` (line 695): -```rust -pub(crate) async fn handle_via_middle_proxy( - mut crypto_reader: CryptoReader, - crypto_writer: CryptoWriter, - success: HandshakeSuccess, - me_pool: Arc, - stats: Arc, - config: Arc, - buffer_pool: Arc, - local_addr: SocketAddr, - rng: Arc, - mut route_rx: watch::Receiver, - route_snapshot: RouteCutoverState, - session_id: u64, -) -> Result<()> -``` - -New signature — add `shared: Arc` after `session_id: u64`. All `RELAY_IDLE_CANDIDATE_REGISTRY`, `DESYNC_DEDUP`, etc. accesses replaced with `shared.middle_relay.*`. `relay_idle_candidate_registry()` accessor deleted. - -**`src/proxy/client.rs`** - -The call site of `handle_tls_handshake` (line ~553) and `handle_via_middle_proxy` (line ~1289) must pass the `Arc` that is constructed once in the main startup path and passed down. Locate the top-level `handle_client_stream` function (line 317) and add `shared: Arc` to its parameters, then thread through. - -`handle_authenticated_static(...)` also needs `shared: Arc` because it dispatches to the middle-relay path after the handshake. - -**Construction site correction**: the current connection task spawn lives in `src/maestro/listeners.rs`, not `src/maestro/mod.rs` or `src/startup.rs`. Construct one `Arc` alongside the other long-lived listener resources and clone it into each `handle_client_stream(...)` task. Do **not** create a fresh shared-state instance per accepted connection. - -**Scope correction**: `handle_via_direct(...)` stays unchanged in core PR-B unless the ancillary `direct_relay.rs` unknown-DC dedup migration is explicitly pulled into scope. - -### Step 4: Remove test helpers and migrate test files - -After production code passes all new tests, remove the **global reset/lock helpers** for the migrated handshake and middle-relay state. Do **not** blindly delete every test accessor. Prefer converting narrow query helpers into instance-scoped helpers when they preserve test decoupling from internal map layout. - -Delete or replace these handshake/middle-relay globals: -- `auth_probe_test_lock()` -- `unknown_sni_warn_test_lock()` -- `warned_secrets_test_lock()` -- `relay_idle_pressure_test_scope()` -- `desync_dedup_test_lock()` -- global reset helpers that only exist to wipe process-wide state between tests - -Prefer converting, not deleting outright: -- `auth_probe_fail_streak_for_testing(...)` -- `auth_probe_is_throttled_for_testing(...)` -- similar narrow read-only helpers that can become `..._for_testing(shared, ...)` - -**Blast-radius correction**: this migration affects more than the helper users listed in the original draft. The current branch has many handshake and middle-relay tests that read `AUTH_PROBE_STATE` or `DESYNC_DEDUP` directly. Those tests must be migrated off raw statics before the statics are removed. - -Ancillary `direct_relay.rs` helpers such as `unknown_dc_test_lock()` remain out of scope unless that follow-up consistency migration is explicitly included. - -No global `Mutex<()>` test locks remain **for the migrated handshake/middle-relay state** after this PR. Do not overstate this as a repository-wide guarantee while ancillary globals still exist elsewhere. - -### Merge gate - -``` -cargo check --tests -cargo test -- proxy_shared_state_ -cargo test -- handshake_ -cargo test -- middle_relay_ -cargo test -- client_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` -All must pass. No existing test may fail. The thread-count runs are mandatory here because PR-B's entire purpose is eliminating hidden cross-test and cross-instance state bleed. - ---- - -## PR-C — Item 5: Dynamic Record Sizing (DRS) for the TLS Relay Path - -**Priority**: High (anti-censorship, TLS-mode only). - -**TDD note for PR-C**: Red tests in this phase must fail because DRS behavior is absent, not because APIs are temporarily broken. Keep baseline relay API compatibility where practical so failures remain behavioral, not compile-surface churn. - -**SCOPE LIMITATION**: This PR covers the **direct relay path only** (`direct_relay.rs` → `relay_bidirectional`). The **middle relay path** (`middle_relay.rs` → explicit ME→client flush loop) is not addressed. Since middle relay is the default when ME URLs are configured, this represents a **significant coverage gap** for those deployments. Future follow-up (PR-C.1): add DRS shaping to the middle relay's explicit flush loop. This is architecturally simpler (natural flush tick points exist) and should be prioritized immediately after PR-C. - -### Problem (concrete) - -`src/proxy/relay.rs` line 563: -```rust -result = copy_bidirectional_with_sizes( - &mut client, // client = StatsIo> - &mut server, - c2s_buf_size.max(1), - s2c_buf_size.max(1), -) => Some(result), -``` - -`client` is a `StatsIo` wrapping a `CombinedStream`. The write half of `client` (the path sending data *to* the real client) has no TLS record framing control. TLS record sizes observed by DPI are determined by tokio's internal copy buffer size — a single constant that produces a recognizable signature absent from real browser TLS sessions. - -The previous draft had three bugs (now fixed here): -1. Used 1450 byte payload → creates 1471-byte framed records → TCP splits into `[1460, 11]` signature. **Correct value: 1369 bytes.** -2. Incremented `records_completed` on every `poll_write` call, not only when a record boundary is crossed. **Fix: track `bytes_in_current_record`; only increment when a flush completes.** -3. Returned `Poll::Pending` with `wake_by_ref()` after a flush completed, causing an immediate spurious reschedule. **Fix: use `ready!` macro and `continue` in a loop — no yield between a completed flush and the next write.** - -### Step 1: Write red tests (must fail on current code) - -**New file**: `src/proxy/tests/drs_writer_unit_tests.rs` -Declared in `src/proxy/relay.rs`. - -``` -// Positive: bytes emitted to inner writer arrive in records of exactly -// target_record_size(0..=39) = 1369 before flush, then 4096, then 16384. -drs_first_40_records_are_1369_bytes_payload_each -drs_records_41_to_60_are_4096_bytes_payload_each -drs_records_above_60_are_16384_bytes_payload_each - -// Boundary/edge: a write of 1 byte completes correctly and counts toward -// bytes_in_current_record without incrementing records_completed prematurely. -drs_single_byte_write_does_not_prematurely_complete_record -// Edge: write of exactly 1369 bytes fills one record; next poll_write triggers flush. -drs_write_equal_to_record_size_requires_second_poll_for_flush -// Edge: TWO sequential poll_write calls, each crossing one record boundary, -// produce exactly two separate flushes (can't flush twice in single poll). -drs_two_sequential_writes_cross_boundary_each_produces_one_flush -// Edge: empty slice write returns Ok(0) immediately without touching inner. -drs_empty_write_returns_zero_does_not_touch_inner -// Edge: poll_shutdown delegates to inner and does not flush records. -drs_shutdown_delegates_to_inner - -// Adversarial: inner writer returns Pending on first 5 poll_write calls. -// DrsWriter must not loop-busy-poll and must not increment records_completed. -drs_pending_on_write_does_not_increment_completed_counter -// Adversarial: inner flush returns Pending. DrsWriter must propagate Pending -// without calling wake_by_ref (verified by checking waker was not called). -drs_pending_on_flush_propagates_pending_without_spurious_wake -// Adversarial: 10001 consecutive 1-byte writes; verify records_completed -// count matches expected record boundaries, no off-by-one. -drs_10001_single_byte_writes_records_count_exact - -// Stress: bounded concurrent DrsWriter instances each writing deterministic -// payloads; assert total flushed bytes equals total written bytes. -// Large-scale variants belong in ignored perf/security jobs, not default CI. -drs_concurrent_instances_no_data_loss - -// Security/anti-DPI: collect sizes of all records produced by writing 100 KB -// through DrsWriter; assert no record with size > 1369 appears in first 40. -// This is the packet-shape non-regression test. -drs_first_records_do_not_exceed_mss_safe_payload_size -// Security: non-TLS passthrough path produces no DrsWriter wrapping; -// assert that when is_tls=false the relay produces no record-size shaping. -drs_passthrough_when_not_tls_no_record_shaping - -// Overflow hardening: records_completed saturates at final phase and never -// re-enters phase 1 after saturation. -drs_records_completed_counter_does_not_wrap - -// Integration: StatsIo byte counters match actual bytes received by inner writer -// when DrsWriter limits write sizes (no data loss or double-counting). -drs_statsio_byte_count_matches_actual_written - -// Integration: copy loop handles partial writes at record boundaries without -// data loss or duplication. -drs_copy_loop_partial_write_retry -``` - -**CI policy for this file**: -- Keep default-suite tests deterministic and bounded in runtime and memory. -- Any high-cardinality stress profile (for example 1000 writers x 1 MB) must be marked ignored and run only in dedicated perf/security pipelines. - -**New file**: `src/proxy/tests/drs_integration_tests.rs` -Declared in `src/proxy/relay.rs`. - -``` -// Integration: relay_bidirectional with DRS enabled (is_tls=true) produces -// records ≤ 1369 bytes in payload size for the first 40 records to the client. -drs_relay_bidirectional_tls_first_records_bounded -// Integration: relay_bidirectional with is_tls=false produces no DrsWriter -// overhead (records sized by c2s_buf_size only). -drs_relay_bidirectional_non_tls_no_drs_overhead -// Integration: relay completes normally with DRS enabled; final byte count -// matches input byte count (no loss or duplication). -drs_relay_bidirectional_tls_no_data_loss_end_to_end - -// Integration: verify FakeTlsWriter.poll_flush produces a TLS record boundary, -// not a no-op. Otherwise DRS shaping provides no anti-DPI value. -drs_flush_is_meaningful_for_faketls -``` - -### Step 2: Implement `DrsWriter` - -**New file**: `src/proxy/drs_writer.rs` - -Declare `pub(crate) mod drs_writer;` in `src/proxy/mod.rs`. - -```rust -pub(crate) struct DrsWriter { - inner: W, - bytes_in_current_record: usize, - // Capped at DRS_PHASE_FINAL (60) to prevent overflow on long-lived connections. - // On 32-bit platforms, an uncapped usize would wrap after ~4 billion records, - // restarting the DRS ramp — a detectable signature. - records_completed: usize, -} - -const DRS_PHASE_1_END: usize = 40; -const DRS_PHASE_2_END: usize = 60; -const DRS_PHASE_FINAL: usize = DRS_PHASE_2_END; -// Safe payload for one MSS with TCP-options headroom. -// FakeTLS overhead in THIS proxy: 5 bytes (TLS record header only). -// NOTE: Unlike real TLS 1.3, FakeTlsWriter does NOT add a content-type byte -// or AEAD tag. Real TLS 1.3 overhead would be 22 bytes (5 + 1 + 16). -// We size for the FakeTLS overhead: record on wire = 1369 + 5 = 1374 bytes. -// MSS = 1460 (MTU 1500 - 40 IP+TCP); with TCP timestamps (~12 bytes) -// effective MSS ≈ 1448, leaving 74 bytes margin for path MTU variance (PPPoE, VPN). -// The value 1369 is intentionally conservative to accommodate future FakeTLS -// upgrades that may add AEAD or padding overhead. -const DRS_MSS_SAFE_PAYLOAD: usize = 1_369; -const DRS_PHASE_2_PAYLOAD: usize = 4_096; -// NOTE: FakeTlsWriter uses MAX_TLS_CIPHERTEXT_SIZE = 16_640 as its max payload. -// DRS caps at 16_384 (RFC 8446 TLS 1.3 plaintext limit). This means DRS still -// shapes records in steady-state by limiting to 16_384 instead of 16_640. -// This is intentional: real TLS 1.3 servers cap at 16_384 plaintext bytes per -// record, so DRS mimics that limit even though FakeTLS allows larger records. -const DRS_FULL_RECORD_PAYLOAD: usize = 16_384; - -impl DrsWriter { - pub(crate) fn new(inner: W) -> Self { - Self { inner, bytes_in_current_record: 0, records_completed: 0 } - } - - fn target_record_size(&self) -> usize { - match self.records_completed { - 0..DRS_PHASE_1_END => DRS_MSS_SAFE_PAYLOAD, - DRS_PHASE_1_END..DRS_PHASE_2_END => DRS_PHASE_2_PAYLOAD, - _ => DRS_FULL_RECORD_PAYLOAD, - } - } -} - -impl AsyncWrite for DrsWriter { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - if buf.is_empty() { return Poll::Ready(Ok(0)); } - loop { - let target = self.target_record_size(); - let remaining = target.saturating_sub(self.bytes_in_current_record); - if remaining == 0 { - // Record boundary reached — flush before starting the next record. - ready!(Pin::new(&mut self.inner).poll_flush(cx))?; - // Cap at DRS_PHASE_FINAL to prevent usize overflow on long-lived connections. - self.records_completed = self.records_completed.saturating_add(1).min(DRS_PHASE_FINAL + 1); - self.bytes_in_current_record = 0; - continue; - } - let limit = buf.len().min(remaining); - let n = ready!(Pin::new(&mut self.inner).poll_write(cx, &buf[..limit]))?; - self.bytes_in_current_record += n; - return Poll::Ready(Ok(n)); - } - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.inner).poll_shutdown(cx) - } -} -``` - -**State integrity requirement**: `bytes_in_current_record` must be incremented by the number of bytes actually accepted by inner writer (`n`), not requested length. This preserves correctness under partial writes. - -**Pending behavior requirement**: if inner `poll_write` or `poll_flush` returns `Pending`, propagate `Pending` without manual `wake_by_ref` calls in DRS, relying on inner writer wake semantics. - -### Step 3: Wire into relay path with compatibility - -**`src/proxy/relay.rs`** — `relay_bidirectional` currently (line 456): - -```rust -pub async fn relay_bidirectional( - client_reader: CR, - client_writer: CW, - ... - _buffer_pool: Arc, // unchanged at this stage -) -> Result<()> -``` - -**Compatibility correction**: Do not force a signature break on `relay_bidirectional(...)` for all existing tests/callers. Prefer one of: -- add `relay_bidirectional_with_opts(...)` and keep `relay_bidirectional(...)` as a passthrough wrapper with defaults; or -- introduce a small options struct with a defaulted constructor and keep a compatibility wrapper. - -This prevents unrelated relay and masking suites from becoming compile-red due to API churn and keeps PR-C failures focused on DRS behavior. - -Inside the function body, where `CombinedStream::new(client_reader, client_writer)` constructs the client combined stream (line ~481), wrap the write half conditionally with a `MaybeDrs` enum: - -**ARCHITECTURE NOTE — write-side placement**: `DrsWriter` wraps the **raw** `client_writer` *before* it enters `CombinedStream`, which is then wrapped by `StatsIo`. The resulting call chain on S→C writes is: - -``` -copy_bidirectional_with_sizes - → StatsIo.poll_write (counts bytes, quota accounting) - → CombinedStream.poll_write - → MaybeDrs.poll_write - → DrsWriter.poll_write - → CryptoWriter.poll_write (AES-CTR encryption, may buffer internally) - → FakeTlsWriter.poll_write (wraps into TLS record with 5-byte header) - → TCP socket -``` - -This is correct because: -1. `StatsIo` sees the actual bytes being written (DrsWriter doesn't change byte count, only limits write sizes and triggers flushes). `StatsIo.poll_write` counts the return value of CombinedStream.poll_write, which equals DrsWriter's return value — the actual bytes accepted. -2. **CryptoWriter buffering interaction**: CryptoWriter.poll_write encrypts and MAY buffer internally (PendingCiphertext) if FakeTlsWriter returns Pending. Crucially, CryptoWriter **always returns Ok(to_accept)** even when buffering — it never returns Pending unless its internal buffer is full. This means DrsWriter's `bytes_in_current_record` tracking is accurate; CryptoWriter accepts the full limited amount. -3. **DRS flush drains the CryptoWriter→FakeTLS→socket chain**: `DrsWriter.poll_flush` → `CryptoWriter.poll_flush` (drains pending ciphertext to FakeTlsWriter) → `FakeTlsWriter.poll_flush` (drains pending TLS record data to socket) → `socket.poll_flush`. This is what enforces TLS record boundaries on the wire. Without the flush, CryptoWriter could batch multiple DRS "records" into one FakeTLS record, defeating the purpose. -4. `copy_bidirectional_with_sizes` also calls `poll_flush` on its own schedule; double-flush is safe (idempotent on all three layers) but adds minor syscall overhead. -5. `copy_bidirectional_with_sizes`'s internal S→C buffer will be partially consumed per poll_write (DrsWriter may accept fewer bytes than offered). This is the intended mechanism — the copy loop retries with the remaining buffer. - -**IMPORTANT**: Add a red test `drs_statsio_byte_count_matches_actual_written` to verify that `StatsIo` byte counters exactly match the total bytes the inner socket received. Without this, a bug where DrsWriter eats or duplicates bytes would go undetected. - -```rust -enum MaybeDrs { - Passthrough(W), - Shaping(DrsWriter), -} - -impl AsyncWrite for MaybeDrs { - fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - match self.get_mut() { - MaybeDrs::Passthrough(w) => Pin::new(w).poll_write(cx, buf), - MaybeDrs::Shaping(w) => Pin::new(w).poll_write(cx, buf), - } - } - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - MaybeDrs::Passthrough(w) => Pin::new(w).poll_flush(cx), - MaybeDrs::Shaping(w) => Pin::new(w).poll_flush(cx), - } - } - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - MaybeDrs::Passthrough(w) => Pin::new(w).poll_shutdown(cx), - MaybeDrs::Shaping(w) => Pin::new(w).poll_shutdown(cx), - } - } -} - -let writer = if opts.is_tls && opts.drs_enabled { - MaybeDrs::Shaping(DrsWriter::new(client_writer)) -} else { - MaybeDrs::Passthrough(client_writer) -}; -let client = StatsIo::new(CombinedStream::new(client_reader, writer), ...); -``` - -**PERFORMANCE NOTE**: The `MaybeDrs::Passthrough` variant adds a single enum match dispatch per `poll_write`/`poll_flush`/`poll_shutdown` call (~3-5 cycles on modern CPUs with branch prediction, negligible for TLS overhead). This is acceptable for correctness. Do not attempt zero-overhead abstractions with generic specialization here; the dispatch overhead is unmeasurable relative to the underlying TLS crypto and I/O. - -**`src/proxy/direct_relay.rs`** — direct path call site: -Pass DRS options only from direct relay dispatch (`is_tls = success.is_tls`, `drs_enabled = config.general.drs_enabled && success.is_tls`). - -**Scope guard**: leave middle-relay call choreography untouched in this PR; this is a direct-path-only phase. - -### Step 5: Add `drs_enabled` config flag - -**`src/config/types.rs`** — inside `GeneralConfig` struct (existing struct, find existing `direct_relay_copy_buf_*` fields around line 507): - -```rust -// Controls Dynamic Record Sizing on the direct TLS relay path. -// Safe to disable for debugging; default true when tls mode is active. -#[serde(default = "default_true")] -pub drs_enabled: bool, -``` - -**IMPORTANT — serde compatibility**: New config fields in this PR must have `#[serde(default = "...")]` annotations. Without these, existing config files that lack the fields will fail to deserialize, breaking upgrades. For PR-C this applies to `drs_enabled`. - -Cross-phase note: -- `ipt_enabled` and `ipt_level` belong to later IPT phases; keep them out of PR-C to limit blast radius. - -Add helpers as needed: -```rust -fn default_true() -> bool { true } -fn default_false() -> bool { false } -fn default_ipt_level() -> u8 { 1 } -``` - -If `default_true()` already exists in defaults, reuse it instead of adding duplicates. - -Default: `true`. Validation: no range constraint needed (boolean). In `relay_bidirectional`, pass `drs_enabled: config.general.drs_enabled && is_tls` (gate on both flags at call site). - -**DO NOT** pass `Arc` into `relay_bidirectional` — this would introduce control-plane (config) reads into the data-plane hot loop. Instead, the call site in `direct_relay.rs` computes `let drs_enabled = config.general.drs_enabled && success.is_tls` and passes it as a `bool` concrete parameter. - -### Merge gate - -``` -cargo check --tests -cargo test -- drs_ -cargo test -- relay_ -cargo test -- direct_relay_ -cargo test -- masking_relay_guardrails_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` - -All tests above must pass. Any expensive stress case added in PR-C must be ignored by default and executed in dedicated perf/security pipelines. - ---- - -## PR-D — Items 3 + 4a: Adaptive Startup Buffer Sizing - -**Priority**: Medium. Depends on PR-C. - -**PREREQUISITE**: Remove `#![allow(dead_code)]` from `src/proxy/adaptive_buffers.rs` at the start of this PR. The attribute was intentional when the module had zero call sites, but PR-D adds real call sites. Keeping the attribute suppresses legitimate dead-code warnings for any functions that remain unused after wiring. - -### Problem (concrete) - -Most adaptive buffer hardening primitives are already present in `src/proxy/adaptive_buffers.rs` (key length guards, stale removal via `remove_if`, TTL eviction, saturating duration math, caps). The remaining production gap is wiring: `seed_tier_for_user`, `record_user_tier`, and `direct_copy_buffers_for_tier` are still not used by direct relay runtime paths. - -`relay_bidirectional` still accepts `_buffer_pool` only for compatibility. The effective startup sizing is still static (`config.general.direct_relay_copy_buf_*`) until direct relay applies seeded tier sizing at call time. - -`USER_PROFILES` (adaptive_buffers.rs line 233) — `OnceLock>` — is the only remaining global after PR-B. It is acceptable here because it functions as a process-wide LRU cache (cross-session user history), not as test-contaminating per-connection state. - -### Step 1: Write red tests for remaining gaps (must fail on current code) - -**Do not duplicate existing coverage**: The repository already contains extensive adaptive buffer tests (`adaptive_buffers_security_tests.rs`, `adaptive_buffers_record_race_security_tests.rs`) that validate cache bounds, key guards, TOCTOU stale removal, and concurrency behavior. PR-D red tests should focus only on missing runtime integration and throughput mapping behavior. - -**New file**: `src/proxy/tests/adaptive_startup_integration_tests.rs` -Declared in `src/proxy/direct_relay.rs` or `src/proxy/adaptive_buffers.rs` (single declaration site only). - -``` -// RED: direct relay currently ignores seeded tier and always uses static config. -// Assert selected copy buffer sizes follow direct_copy_buffers_for_tier(seed_tier_for_user(user), ...). -adaptive_startup_direct_relay_uses_seeded_tier_buffers - -// RED: no production post-session persistence currently upgrades next session. -// After first relay with high throughput, next seed should reflect recorded upgrade. -adaptive_startup_post_session_recording_upgrades_next_session - -// RED: short sessions (<1s) must never promote tier even under bursty bytes. -adaptive_startup_short_sessions_do_not_promote - -// RED: upgrade path must be monotonic per user within TTL (no downgrade on lower follow-up). -adaptive_startup_recording_remains_monotonic_within_ttl -``` - -Existing adaptive security tests already cover empty keys, oversized keys, fuzz keys, and cache cardinality attacks. Do not reintroduce duplicates in PR-D. - -### Step 2: Keep current hardening, remove outdated dead-code suppression - -Current branch already has the core hardening this step originally proposed: -- `MAX_USER_PROFILES_ENTRIES` and `MAX_USER_KEY_BYTES` -- stale purge with `remove_if` in `seed_tier_for_user` -- `saturating_duration_since`-safe TTL math -- TTL-based `retain` eviction in `record_user_tier` - -Required action in PR-D: -- remove `#![allow(dead_code)]` from `src/proxy/adaptive_buffers.rs` once direct relay wiring lands, so dead paths are visible again. - -No behavioral rewrite of existing `seed_tier_for_user` / `record_user_tier` is required unless new red tests expose regressions. - -### Step 3: Add explicit throughput mapping API - -**`src/proxy/adaptive_buffers.rs`** — new public function: - -```rust -// Computes the peak tier achieved during a session from total byte counts and -// session duration. Uses only throughput because demand-pressure metrics are -// unavailable at session end (copy_bidirectional drains everything). -// Maps average throughput over a session to an adaptive tier. Only peak direction -// (max of c2s or s2c) is considered to avoid double-counting bidir traffic. -// Note: This uses total-session average, not instantaneous peak. Bursty traffic -// (30s burst @ 100 Mbps, 9.5min idle) will compute as the average over all 10 min, -// potentially underestimating required buffers. Consider measuring peak-window -// throughput from watchdog snapshots (10s intervals) in future refinements. -pub fn average_throughput_to_tier(c2s_bytes: u64, s2c_bytes: u64, duration_secs: f64) -> AdaptiveTier { - if duration_secs < 1.0 { return AdaptiveTier::Base; } - let avg_bps = (c2s_bytes.max(s2c_bytes) as f64 * 8.0) / duration_secs; - if avg_bps >= THROUGHPUT_UP_BPS as f64 { AdaptiveTier::Tier1 } - else { AdaptiveTier::Base } -} -``` - -Naming constraint: -- Use `average_throughput_to_tier` consistently. Avoid introducing both `throughput_to_tier` and `average_throughput_to_tier` aliases in production code. - -### Step 4: Wire into `direct_relay.rs` - -**`src/proxy/direct_relay.rs`** — inside `handle_via_direct`, before the call to `relay_bidirectional` (currently line ~280): - -```rust -// Seed startup buffer sizes from cross-session user history. -let initial_tier = adaptive_buffers::seed_tier_for_user(user); -let (c2s_buf, s2c_buf) = adaptive_buffers::direct_copy_buffers_for_tier( - initial_tier, - config.general.direct_relay_copy_buf_c2s_bytes, - config.general.direct_relay_copy_buf_s2c_bytes, -); -let relay_epoch = std::time::Instant::now(); -``` - -Replace the existing `config.general.direct_relay_copy_buf_c2s_bytes` / `s2c_bytes` arguments in the `relay_bidirectional` call with `c2s_buf` / `s2c_buf`. - -After `relay_bidirectional` returns (whatever the result), record the tier: - -```rust -let duration_secs = relay_epoch.elapsed().as_secs_f64(); -let final_c2s = /* session c2s total bytes */; -let final_s2c = /* session s2c total bytes */; -let peak_tier = adaptive_buffers::average_throughput_to_tier(final_c2s, final_s2c, duration_secs); -adaptive_buffers::record_user_tier(user, peak_tier); -``` - -Implementation seam note: -- `relay_bidirectional` currently encapsulates counters internally. To avoid broad API churn, prefer returning a small relay outcome struct that includes final `c2s_bytes` and `s2c_bytes` totals while preserving existing error semantics. -- Keep this seam local to direct relay integration; do not expose `SharedCounters` internals broadly. - -`_buffer_pool` remains in the `relay_bidirectional` signature (Option B: repurposed pathway). Its role is now documented: "parameter reserved for future pool-backed buffer allocation; startup sizing is performed by the caller via `adaptive_buffers::direct_copy_buffers_for_tier`." The underscore prefix is removed (`buffer_pool`) and it is still passed as `Arc::clone(&buffer_pool)` — no functional change. - -### Merge gate - -``` -cargo check --tests -cargo test -- adaptive_buffers_ -cargo test -- adaptive_startup_ -cargo test -- direct_relay_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` - ---- - -## PR-E — Item 4b: In-Session Adaptive Architecture Decision Gate - -**Priority**: Blocks PR-G. Depends on PR-D. - -**Execution model correction**: PR-E is a decision-gate phase, so it must distinguish between: -- deterministic correctness/integration tests (required for CI and merge), and -- performance experiments (informational, ignored by default, run on dedicated hardware). - -Do not use throughput/latency benchmark thresholds as hard CI merge gates in this phase. - -### Problem (concrete) - -`SessionAdaptiveController::observe` (adaptive_buffers.rs line 121) is never called. Three structural blockers prevent in-session adaptation on the direct relay path: - -1. `copy_bidirectional_with_sizes` is opaque — no hook to observe buffering pressure mid-loop. -2. `StatsIo` wraps only the client side — no server-side write pressure signal. -3. The watchdog tick is 10 seconds — too coarse for the 250 ms EMA window `observe()` expects. - -The decision gate must produce *measured* evidence, not architectural guesses. - -**Current state note**: `SessionAdaptiveController` and `RelaySignalSample` already exist in `adaptive_buffers.rs`, but there is no production wiring that feeds relay runtime signals into `observe(...)`. - -### Step 1: Required deterministic decision tests (CI required) - -**New file**: `src/proxy/tests/adaptive_insession_decision_gate_tests.rs` -Declared once (single declaration site). - -These tests must be deterministic and runnable on shared CI: - -``` -// Confirms direct relay path has no fine-grained signal hook while copy_bidirectional_with_sizes -// remains opaque; this preserves the architectural constraint as an explicit test. -adaptive_decision_gate_direct_path_lacks_tick_hook - -// Confirms middle relay path exposes configurable flush timing boundary via -// me_d2c_flush_batch_max_delay_us and can produce periodic signal ticks. -adaptive_decision_gate_middle_relay_has_tick_boundary - -// Drives SessionAdaptiveController with deterministic synthetic signal stream and -// verifies promotion/demotion transitions remain stable under fixed tick cadence. -adaptive_decision_gate_controller_transitions_deterministic - -// Confirms proposed signal extraction API (or shim) carries enough fields to support -// observe() without leaking internal relay-only types. -adaptive_decision_gate_signal_contract_is_sufficient -``` - -### Step 2: Optional feasibility experiments (ignored by default) - -**New file**: `src/proxy/tests/adaptive_insession_option_a_experiment_tests.rs` -Declared in `src/proxy/relay.rs`. - -**CI STABILITY WARNING**: These tests measure performance overhead, not correctness. They WILL be flaky on shared CI runners with variable CPU scheduling and memory pressure. **Mark all tests in this file with `#[ignore]`** by default. Run only in isolated performance environments (dedicated runner, pinned cores, no concurrent load). CI gate should skip these; they are for manual decision-making only. - -These tests benchmark overhead, not correctness. Keep `#[ignore]` and never use as merge blockers: - -``` -// Measures latency penalty of adding a per-session 1-second ticker task alongside -// copy_bidirectional_with_sizes using tokio::select!. Records p50/p95/p99 latency -// delta over 1000 relay sessions each transferring 10 MB. -// ACCEPTANCE CRITERION: p99 latency increase < 2 ms; p50 < 0.5 ms. -adaptive_option_a_ticker_overhead_under_acceptance_threshold - -// Measures overhead of adding AtomicU64 s2c_pending_write_count to StatsIo -// and incrementing it in poll_write when Poll::Pending. Records throughput -// delta over 10_000 relay calls. -// ACCEPTANCE CRITERION: throughput regression < 1%. -adaptive_option_a_statsio_pending_counter_overhead_under_1pct - -// Measures overhead of wrapping the server-side write half in a second StatsIo -// (for server-side pressure signal). Records throughput delta. -// ACCEPTANCE CRITERION: throughput regression < 2%. -adaptive_option_a_server_side_statsio_overhead_under_2pct -``` - -### Step 3: Option B boundary validation experiment (ignored by default) - -**New file**: `src/proxy/tests/adaptive_insession_option_b_experiment_tests.rs` -Declared in `src/proxy/middle_relay.rs`. - -``` -// Verifies that middle_relay's explicit ME→client flush loop already provides -// a natural tick boundary at max_delay_us intervals (currently 1000 µs default). -// Records observed tick interval distribution over 500 relay sessions. -// ACCEPTANCE CRITERION: median observed tick ≤ 2× configured max_delay_us. -adaptive_option_b_middle_relay_flush_loop_provides_tick_boundary - -// Verifies SessionAdaptiveController::observe can be driven by ME flush ticks. -// Pumps 2000 synthetic RelaySignalSample values through observe() at 1 ms intervals. -// ACCEPTANCE CRITERION: Tier1 promotion fires at expected tick count consistent -// with TIER1_HOLD_TICKS = 8. -adaptive_option_b_observe_driven_by_flush_ticks_promotes_correctly -``` - -### Step 4: Decision artifact - -**Placement correction**: function renaming and direct relay throughput-to-tier wiring are PR-D tasks, not PR-E tasks. PR-E must not duplicate those implementation steps. - -After running both experiment suites, record the measured values in `docs/ADAPTIVE_INSESSION_DECISION.md` with the format: - -```markdown -## Measured Results - -| Metric | Option A measured | Threshold | Pass/Fail | -|---|---|---|---| -| Ticker task p99 latency delta (ms) | X | < 2 ms | ? | -| StatsIo pending counter throughput delta | X | < 1% | ? | -| Server-side StatsIo throughput delta | X | < 2% | ? | - -| Metric | Option B measured | Threshold | Pass/Fail | -|---|---|---|---| -| Flush tick median vs configured delay | X | ≤ 2× | ? | -| Tier1 promotion tick accuracy | X | exact | ? | - -## Decision: [Option A / Option B] -Rationale: ... -``` - -If Option A passes all thresholds → schedule PR-G-A (relay loop instrumentation). -If Option B passes all thresholds → schedule PR-G-B (middle relay SessionAdaptiveController wiring). -If neither passes → escalate and re-design. - -Decision rule refinement: -- Deterministic CI tests from Step 1 must pass before any option can be selected. -- Performance thresholds from experiments are advisory evidence and must include environment metadata (CPU model, core pinning, load conditions) in the decision doc. - -### Merge gate - -``` -cargo check --tests -cargo test -- adaptive_insession_decision_gate_ -cargo test -- middle_relay_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` - -Optional experiment runs (not merge blockers): - -``` -cargo test -- adaptive_option_a_ -- --ignored -cargo test -- adaptive_option_b_ -- --ignored -``` - ---- - -## PR-F — Item 1 Level 1: Log-Normal Single-Delay Replacement - -**Priority**: Medium. **No dependency on PR-B (DI) or PR-C (DRS)** — this PR modifies only the RNG call in `masking.rs` and `handshake.rs`, touching zero global statics or shared state. Can be developed and merged independently after PR-A (baseline tests). The original "Depends on PR-B + PR-C being stable" was an artificial ordering constraint with no code justification. - -### Problem (concrete) - -`mask_outcome_target_budget` (masking.rs line 252, rng calls at lines 261–265) draws from uniform distribution: -```rust -let delay_ms = rng.random_range(floor..=ceiling); -``` - -`maybe_apply_server_hello_delay` (handshake.rs line 586): -```rust -let delay_ms = rand::rng().random_range(min..=max); -``` - -Both produce uniform i.i.d. samples. For a *single* sample this does not matter for classification — you cannot build a histogram from one value. However, replacing uniform with log-normal: -- More accurately models observed real-world TCP RTT distributions (multiplicative central-limit theorem). -- Provides a documented, principled rationale against future attempts to "optimize" the distribution. - -**Current branch status**: -- `mask_outcome_target_budget(...)` already uses `sample_lognormal_percentile_bounded(...)` for the `ceiling > floor > 0` path. -- `maybe_apply_server_hello_delay(...)` already routes through the same helper. -- Extensive masking log-normal tests already exist in `src/proxy/tests/masking_lognormal_timing_security_tests.rs`. - -PR-F is therefore an **incremental hardening + coverage completion** phase, not a greenfield implementation. - -### Cargo.toml change - -**No new dependencies required.** - -**Implementation note correction**: current code uses a Box-Muller transform built from `rng.random_range(...)` to derive a standard normal sample, which is valid and avoids extra dependency surface. Do not force migration to `StandardNormal` unless there is a demonstrated correctness or performance defect. - -**CRITICAL**: avoid adding `rand_distr` because of `rand_core` compatibility risk with the existing `rand` version. - -### Step 1: Write red tests only for missing coverage (must fail on current code) - -**Do not duplicate existing masking log-normal suite.** Extend `src/proxy/tests/masking_lognormal_timing_security_tests.rs` only where gaps remain. - -``` -// Missing gap candidate: helper behavior under extremely narrow range around 1 ms -// remains stable without boundary clamp spikes. -masking_lognormal_ultra_narrow_range_stability - -// Missing gap candidate: floor=0 path remains intentionally uniform and does not -// regress to log-normal semantics. -masking_lognormal_floor_zero_path_regression_guard -``` - -**Add handshake-side coverage explicitly** (new file if needed): `src/proxy/tests/handshake_lognormal_delay_security_tests.rs`. -Rationale: there is no dedicated `handshake_lognormal_` suite yet, and current coverage is mostly indirect through server-hello-delay behavior tests. - -``` -// Deterministic bound check via fixed min==max and bounded timer advancement. -handshake_lognormal_fixed_delay_respected - -// Inverted config safety: max floor` branch: -1. `floor == 0 && ceiling == 0` → returns 0 (unchanged) -2. `floor == 0 && ceiling != 0` → uses `rng.random_range(0..=ceiling)` (uniform) -3. `ceiling > floor` (with `floor > 0`) → uses `rng.random_range(floor..=ceiling)` (uniform → **replace with log-normal**) -4. Fall-through (`ceiling <= floor`) → returns `floor` (unchanged) - -**Only path 3 is replaced.** Path 2 (floor=0) must remain uniform because log-normal cannot meaningfully model a distribution anchored at zero — the `floor.max(1)` guard in `sample_lognormal_percentile_bounded` changes the distribution center to `sqrt(ceiling)`, which is far from the original uniform median of `ceiling/2`. Changing this would alter observable timing behavior for deployments using `floor_ms=0`. - -```rust -// Path 3 replacement only — inside the `if ceiling > floor` block: -let delay_ms = if ceiling == floor { - ceiling -} else { - sample_lognormal_percentile_bounded(floor, ceiling, &mut rng) -}; -``` - -Current helper in `masking.rs` already exists and is `pub(crate)` for handshake reuse. - -If red tests expose issues, patch the existing helper rather than replacing it wholesale: -```rust -use rand::Rng; -// Current implementation uses Box-Muller from uniform draws. - -// Samples a log-normal distribution parameterized so that the median maps to -// the geometric mean of [floor, ceiling], then clamps the result to that range. -// -// Implementation uses Box-Muller-derived N(0,1) from uniform draws. -// Log-normal = exp(mu + sigma * N(0,1)). -// -// For LogNormal(mu, sigma): median = exp(mu). -// mu = (ln(floor) + ln(ceiling)) / 2 → median = sqrt(floor * ceiling). -// sigma = ln(ceiling/floor) / 4.65 → ensures ~99% of samples fall in [floor, ceiling]. -// 4.65 ≈ 2 × 2.326 (z-score for 99th percentile of standard normal). -// -// IMPORTANT: When floor == 0, log-normal parameterization is undefined (ln(0) = -∞). -// We use floor_f = max(floor, 1) for parameter computation but clamp the final -// result to the original [floor, ceiling] range. For floor=0 this produces a -// distribution centered around sqrt(ceiling) — which may differ significantly from -// the original uniform [0, ceiling]. If the caller needs uniform behavior for -// floor=0, it should handle that case before calling this function. -pub(crate) fn sample_lognormal_percentile_bounded(floor: u64, ceiling: u64, rng: &mut impl Rng) -> u64 { ... } -``` - -Safety requirement for this helper: -- misconfigured `floor > ceiling` must remain fail-closed and bounded. -- `floor == 0` path behavior must remain explicit and documented. -- NaN/Inf fallback must remain deterministic and bounded. - -### Step 3: Implement in `handshake.rs` - -Replace in `maybe_apply_server_hello_delay` (line 586): - -```rust -let delay_ms = if max == min { - max -} else { - // Replaced: sample_lognormal_percentile_bounded produces a right-skewed distribution - // with median at geometric mean, matching empirical TLS ServerHello delay profiles. - masking::sample_lognormal_percentile_bounded(min, max, &mut rand::rng()) -}; -``` - -`sample_lognormal_percentile_bounded` must be made `pub(crate)` in `masking.rs` to allow the handshake call. - -Status note: this helper is already `pub(crate)` on the current branch; keep visibility stable. - -Status note: this handshake call-site migration is already present on the current branch; PR-F should verify and lock it with dedicated tests. - -### Merge gate - -``` -cargo check --tests -cargo test -- masking_lognormal_timing_security_ -cargo test -- server_hello_delay_ -cargo test -- masking_ab_envelope_blur_integration_security # regression gate -cargo test -- masking_timing_normalization_security # regression gate -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` - ---- - -## PR-G — Item 1 Level 2: State-Aware Inter-Packet Timing (Burst/Idle Markov) - -**Priority**: Medium. Depends on PR-E decision gate. Separate PR, design depends on PR-E outcome. - -### Problem (concrete) - -No inter-packet timing (IPT) mechanism exists on the MTProto relay path (confirmed: no `IptController` anywhere in the codebase). Real HTTPS sessions exhibit two-state autocorrelation: Burst (1–5 ms IPG, 0.95 self-transition) and Idle (2–10 seconds IPG, heavy-tail, 0.99 self-transition). ML classifiers detect the absence of this structure directly from the time-series, regardless of marginal distribution shape. - -**ARCHITECTURAL BLOCKER (PR-G for direct relay)**: IPT requires injecting delays between write/flush cycles, which `tokio::io::copy_bidirectional_with_sizes` does not support. Adding IPT on the direct relay path requires **replacing** `copy_bidirectional_with_sizes` with a custom poll loop that calls `ipt_controller.next_delay_us()` and `tokio::time::sleep()` between write events. This is substantial work (equivalent to ~300-line custom relay loop). **Decision**: IPT for direct relay is deferred to a decision gate (PR-E); if approved, PR-G will require a dedicated custom loop. Middle relay (ME→client) has an explicit flush loop (middle_relay.rs line 1200+) where IPT can be added more easily. - -**CRITICAL DESIGN FIX — DATA-AVAILABILITY AWARENESS**: The original IptController is a purely stochastic model with no awareness of whether data is actually waiting to be sent. The Idle state injects 2–30 second delays **unconditionally**, even when Telegram has queued data for the client. This would cause Telegram client timeouts and connection drops during active sessions. - -**Required fix**: IptController must be **signal-driven**, not purely probabilistic: -- **Burst delays** (0.5–10 ms) are applied only when data is actively flowing (relay has data in buffers). This adds realistic inter-packet jitter without stalling delivery. -- **Idle state** is entered when the relay observes **genuine idle** (no data received from Telegram for a configurable threshold, e.g. 500ms). During genuine idle, DPI already sees no packets — consistent with browser idle. No artificial delay injection is needed. -- **Synthetic keep-alive timing** (optional, Level 3 enhancement): during genuine idle periods, inject small padding records at browser-like intervals to maintain the illusion of an active HTTPS session. This requires FakeTLS padding support. -- The `next_delay_us()` API must accept a `has_pending_data: bool` signal from the caller. When `has_pending_data == true`, the controller stays in Burst regardless of the Markov transition. When `has_pending_data == false` for the idle threshold, the controller transitions to Idle but does NOT inject delays — it simply stops the flush loop until new data arrives. - -This means: -```rust -pub(crate) fn next_delay_us(&mut self, rng: &mut impl Rng, has_pending_data: bool) -> u64 { - if has_pending_data { - // Data waiting: always use Burst timing, regardless of Markov state. - // Markov still transitions (for statistics/logging) but delay is Burst. - self.maybe_transition(rng); - let d = self.burst_dist.sample(rng).max(0.0) as u64; - return d.saturating_mul(1_000).clamp(500, 10_000); - } - // No data pending: return 0 (caller should wait for data, not sleep). - // The caller's recv() timeout on the data channel provides natural idle timing. - 0 -} -``` - -This PR is conditional on PR-E. The exact implementation path (A or B) is determined by the PR-E decision artifact. The test specifications below apply to whichever path is chosen. - -### Step 1: Write red tests (must fail on current code) - -**New file**: `src/proxy/tests/relay_ipt_markov_unit_tests.rs` - -``` -// IptController starts in Burst state. -ipt_controller_initial_state_is_burst - -// Deterministic transition oracle: with an injected decision stream that forces -// "switch" on first call, state toggles Burst -> Idle exactly once. -ipt_controller_forced_transition_toggle_oracle - -// In Burst state, next_delay_us(rng, has_pending_data=true) returns value -// consistent with LogNormal(mu=1.0, sigma=0.5) * 1000, clamped to [500, 10_000] µs. -ipt_controller_burst_delay_within_burst_bounds - -// Internal Markov state: even though next_delay_us returns 0 when !has_pending_data, -// the Markov chain still transitions. Verify idle_dist sampling works correctly -// when called directly (for future Level 3 keep-alive timing). -// Pareto heavy-tail: minimum = 2_000_000 µs, P(>10s) ≈ 9%. -ipt_controller_idle_dist_sampling_correct - -// Deterministic Markov behavior: with an injected decision stream of -// stay/stay/switch, verify exact state sequence without probabilistic thresholds. -ipt_controller_markov_sequence_deterministic_oracle - -// Compile-time trait check can be kept if needed, but no CI memory-growth or -// wall-clock budget assertions in merge gates. -ipt_controller_trait_bounds_compile_check - -// DATA-AWARENESS: next_delay_us(rng, has_pending_data=true) always returns -// Burst-range delay, even if Markov state is Idle. Verifies that active data -// transfer is never stalled by Idle-phase delays. -ipt_controller_pending_data_forces_burst_delay - -// DATA-AWARENESS: next_delay_us(rng, has_pending_data=false) returns 0, -// signaling the caller to wait for data arrival (no artificial sleep). -ipt_controller_no_pending_data_returns_zero - -// Adversarial: IptController with f64 overflow — burst_dist.sample() returning -// very large values must not overflow on saturating_mul(1_000). Verify clamp -// catches extreme samples. -ipt_controller_burst_sample_overflow_safe - -// Adversarial: idle_dist.sample() returning f64::INFINITY or f64::NAN -// (edge case of Pareto distribution). Cast to u64 must not panic; clamp -// handles gracefully. -ipt_controller_idle_sample_extreme_f64_safe -``` - -**New file**: `src/proxy/tests/relay_ipt_integration_tests.rs` - -``` -// Relay path with IPT enabled: 200 calls alternating has_pending_data true/false. -// Verify that true calls always return Burst-range delays and false calls -// always return 0. -relay_ipt_data_availability_signal_respected - -// Adversarial: active prober sends 100 handshakes with invalid keys. -// IPT must not affect the fallback-to-masking behavior or reveal proxy identity -// through timing structure (timing envelope in fallback path is unchanged). -relay_ipt_invalid_handshake_fallback_timing_unchanged - -// Adversarial: censor injects 10_000 back-to-back packets at 0-delay -// (has_pending_data=true for all). Verify relay does not stall excessively -// (total added IPT delay < 10% of transfer time for a 1 MB payload at 10 Mbps). -relay_ipt_overhead_under_high_rate_attack_within_budget - -// Config kill-switch: ipt_enabled = false → no delay injected. -relay_ipt_disabled_by_config_no_delay_added -``` - -Optional (non-merge-gate) performance experiments: -``` -relay_ipt_burst_delays_exhibit_positive_autocorrelation -relay_ipt_500_concurrent_throughput_within_5pct_baseline -``` - -### Step 2: Implement `IptController` - -**New file**: `src/proxy/ipt_controller.rs` -Declare `pub(crate) mod ipt_controller;` in `src/proxy/mod.rs`. - -```rust -use rand::Rng; - -pub(crate) enum IptState { Burst, Idle } - -// Log-normal parameters for Burst-state inter-packet delay. -// mu=1.0, sigma=0.5 → median ≈ exp(1.0) ≈ 2.7 ms. -const BURST_MU: f64 = 1.0; -const BURST_SIGMA: f64 = 0.5; -const BURST_DELAY_MIN_US: u64 = 500; -const BURST_DELAY_MAX_US: u64 = 10_000; -// Pareto parameters for Idle-state delay (retained for future Level 3 keep-alive). -// scale=2_000_000 µs (2s minimum), shape=1.5 → heavy tail. -const IDLE_PARETO_SCALE: f64 = 2_000_000.0; -const IDLE_PARETO_SHAPE: f64 = 1.5; - -pub(crate) struct IptController { - state: IptState, - // Pre-computed Burst/Idle transition probabilities. - burst_stay_prob: f64, // 0.95 - idle_stay_prob: f64, // 0.99 -} - -impl IptController { - pub(crate) fn new() -> Self { - Self { - state: IptState::Burst, - burst_stay_prob: 0.95, - idle_stay_prob: 0.99, - } - } - - fn maybe_transition(&mut self, rng: &mut impl Rng) { - // random_bool(p) returns true with probability p, using u64 threshold - // internally for full precision. Simpler than manual u32 threshold. - let stay = match self.state { - IptState::Burst => rng.random_bool(self.burst_stay_prob), - IptState::Idle => rng.random_bool(self.idle_stay_prob), - }; - if !stay { - self.state = match self.state { - IptState::Burst => IptState::Idle, - IptState::Idle => IptState::Burst, - }; - } - } - - // Burst delay via log-normal: exp(mu + sigma * N(0,1)). - // Use dependency-free Box-Muller (same project pattern as masking helper) - // to avoid any additional RNG-distribution dependency churn. - fn sample_burst_delay_us(&self, rng: &mut impl Rng) -> u64 { - let u1 = rng.next_f64().max(f64::MIN_POSITIVE); - let u2 = rng.next_f64(); - let normal = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos(); - let raw = (BURST_MU + BURST_SIGMA * normal).exp(); - let us = if raw.is_finite() { - (raw as u64).saturating_mul(1_000) - } else { - // exp(1.0) ≈ 2718 → 2_718_000 µs as fallback (won't happen in practice) - 2_718_000 - }; - us.clamp(BURST_DELAY_MIN_US, BURST_DELAY_MAX_US) - } - - // Idle delay via Pareto CDF inversion: scale / U^(1/shape). - // Retained for future Level 3 synthetic keep-alive timing. - // NOTE: Currently dead code — next_delay_us returns 0 when !has_pending_data. - #[allow(dead_code)] - fn sample_idle_delay_us(&self, rng: &mut impl Rng) -> u64 { - let u: f64 = rng.random_range(f64::EPSILON..1.0); - let raw = IDLE_PARETO_SCALE / u.powf(1.0 / IDLE_PARETO_SHAPE); - if raw.is_finite() { - (raw as u64).clamp(2_000_000, 30_000_000) - } else { - 2_000_000 - } - } - - // Returns inter-packet delay in microseconds. - // `has_pending_data`: true when the relay has queued data awaiting flush. - // When true, always returns a Burst-range delay — active data must never be - // stalled by Idle-phase pauses (which would cause Telegram client timeouts). - // When false, returns 0 — the caller should block on its data channel recv(), - // which provides natural idle timing matching genuine browser think-time. - pub(crate) fn next_delay_us(&mut self, rng: &mut impl Rng, has_pending_data: bool) -> u64 { - self.maybe_transition(rng); - if has_pending_data { - self.sample_burst_delay_us(rng) - } else { - 0 - } - } -} -``` - -**CHANGES vs previous draft:** -1. **No new distribution dependency** — uses Box-Muller for log-normal and manual CDF inversion for Pareto. -2. **`random_bool(p)` for Markov transitions** — replaces manual u32 threshold computation. Cleaner, equivalent precision. -3. **`idle_dist` explicitly marked `#[allow(dead_code)]`** — `next_delay_us` returns 0 when `!has_pending_data`, so idle sampling is never reached in production. Retained for future Level 3 keep-alive. -4. **No stored distribution objects** — parameters are constants, sampling is inline. Avoids the `expect()` calls that would be denied by `clippy::expect_used`. - -### Step 3: Integrate into relay path - -Conditional on PR-E outcome: - -- **Option B path** (recommended if PR-E selects B): wire `IptController` into the ME→client flush loop in `middle_relay.rs`. Each flush cycle calls `ipt_controller.next_delay_us(&mut rng, has_pending_data)` where `has_pending_data = !frame_buf.is_empty()`, then `tokio::time::sleep(Duration::from_micros(delay))` before the next flush. When `delay == 0`, the loop blocks on `me_rx.recv()` naturally. -- **Option A path**: replace `copy_bidirectional_with_sizes` with a custom poll loop that calls `ipt_controller.next_delay_us(rng, has_pending_data)` between write completions, checking the read buffer for pending data. - -Config flag in `src/config/types.rs`: -```rust -#[serde(default = "default_false")] -pub ipt_enabled: bool, // default: false (opt-in) -#[serde(default = "default_ipt_level")] -pub ipt_level: u8, // 1 = single-delay only, 2 = Markov; default 1 -``` - -### Merge gate - -``` -cargo check --tests -cargo test -- relay_ipt_markov_unit_ -cargo test -- relay_ipt_integration_ -cargo test -- --test-threads=1 -cargo test -- --test-threads=32 -``` - ---- - -## PR-H — Consolidated Hardening, ASVS L2 Audit, and Documentation - -**Depends on**: All prior PRs. - -### ASVS L2 Verification Checklist for Changed Areas - -| ASVS Control | Area | Verification | -|---|---|---| -| V5.1.1 Input validation | `record_user_tier` user key length | `MAX_USER_KEY_BYTES = 512` guard in place | -| V5.1.3 Output encoding | DRS framing | No user-controlled field affects record size calculation | -| V5.1.1 Input validation | `IptController.next_delay_us` | `has_pending_data` signal is a bool from trusted internal code; no external input reaches IptController directly | -| V8.1.1 Memory safety | `DrsWriter`, `IptController` | No `unsafe` blocks; all bounds enforced by Rust type system; `saturating_mul` prevents overflow in IptController burst sampling | -| V8.3.1 Sensitive data in memory | `ProxySharedState` | auth key material remains in `HandshakeSuccess` on stack; not copied into shared state | -| V11.1.3 TLS config | DRS | TLS path enabled only when `is_tls=true`; non-TLS path unmodified | -| V11.1.4 Cipher strength | n/a | No cryptographic changes in this plan | -| V2.1.5 Brute force | Auth probe | Probe state in `ProxySharedState.handshake.auth_probe`; per-IP saturation preserved | -| V6.2.2 Algorithm strength | Log-normal RNG | Box-Muller-based bounded sampler with finite checks and deterministic fallback/clamp; no panic path | -| V14.2.1 Configuration hardening | serde defaults | All new config fields have `#[serde(default)]` for backward-compatible deserialization | -| V1.4.1 Concurrency | `ProxySharedState` mutex type | Uses `std::sync::Mutex`; locks never held across await points; lock ordering documented | - -### Full test run command sequence - -```sh -# Run all proxy tests -cargo test -p telemt -- proxy:: - -# Run targeted gate for each PR area -cargo test -- relay_baseline_ -cargo test -- handshake_baseline_ -cargo test -- middle_relay_baseline_ -cargo test -- masking_baseline_ -cargo test -- proxy_shared_state_ -cargo test -- drs_ -cargo test -- adaptive_startup_ -cargo test -- adaptive_option_ -cargo test -- masking_lognormal_ -cargo test -- handshake_lognormal_ -cargo test -- ipt_ - -# Full regression (must show zero failures) -cargo test -``` - -### Documentation changes - -**`docs/CONFIG_PARAMS.en.md`** — add entries for each new `GeneralConfig` field: - -| Field | Default | Description | -|---|---|---| -| `drs_enabled` | `true` | Enable Dynamic Record Sizing on TLS direct relay path. Disable for debugging. | -| `ipt_enabled` | `false` | Enable state-aware inter-packet timing on relay path. Opt-in; requires testing in your network environment. | -| `ipt_level` | `1` | IPT level: 1 = log-normal single-delay only, 2 = Burst/Idle Markov chain. | - -**`ROADMAP.md`** — mark completed items from this plan. - ---- - -## Architectural Decisions & Key Findings from Audit - -### D1: PR Ordering — Swap PR-C and PR-B - -**Audit finding**: PR-C (DRS) is self-contained; PR-B (DI) has a 2000+ line blast radius. - -**Decision**: **YES, swap PR-C → PR-B in execution order**. Rationale: -- **PR-C dependencies**: Only `is_tls` (field in `HandshakeSuccess`, already exists) + new `drs_enabled` config flag. Zero dependency on DI migration. -- **PR-C value**: Delivers immediate anti-censorship benefit for direct relay TLS path. -- **PR-B risk mitigation**: If DI refactor hits unforeseen complexity, DRS remains deliverable independently. -- **Execution parallelization**: PR-C and PR-B can have their test suites written in parallel (PR-A → PR-C tests + PR-B tests in parallel) → PR-C production code → PR-B production code (sequential due to shared entry points). - -**Updated graph**: -``` -PR-A (baseline gates) -├─→ PR-C (DRS, independent) -├─→ PR-F (log-normal, independent) -└─→ PR-B (DI migration) - └─→ PR-D (adaptive startup) - └─→ PR-E (decision gate) -``` - ---- - -### D2: PR-B Phasing (Single Atomic vs Shim+Removal) - -**Audit suggestion**: Two-phase with compatibility shim to reduce blast radius. - -**Decision**: **NOT phased — single atomic PR-B**. Rationale: -- A shim (global `ProxySharedState::default()` instance) would live only through one release cycle, complicating both phases. -- With high test coverage from PR-A, full replacement is safer than partial compatibility. -- Parallel test execution gates (`cargo test -- --test-threads=32`) will catch test interference before merge. -- **Mitigation**: Sequence the changes: `ProxySharedState` creation first → all accessors updated → test helpers removed. Review in logical chunks per file. - ---- - -### D3: PR-C Scope — Direct Relay Only, Middle Relay Gap - -**Audit finding**: Middle relay (the default mode) is not covered; this is a CVE-level coverage gap. - -**Decision**: **KNOWN LIMITATION with ELEVATED follow-up priority**. Document explicitly: -- PR-C covers direct relay path only (direct_relay.rs → relay_bidirectional). -- Middle relay path (middle_relay.rs → explicit ME→client loop) requires separate PR-C.1 (follow-up). -- **Middle relay is the DEFAULT** for deployments with configured ME URLs, which is the typical production setup. Direct relay is used when `use_middle_proxy=false` or no ME pool is available. -- **PR-C.1 must be elevated to the same priority as PR-C** (High, anti-censorship). It should begin development immediately after PR-C merges, not be treated as a casual follow-up. Middle relay has natural flush-tick points that make DRS integration architecturally simpler than direct relay. -- **Action**: Add a `docs/DRS_DEPLOYMENT_NOTES.md` with guidance documenting which relay modes have DRS coverage and which are pending PR-C.1. - ---- - -### D4: DRS MaybeDrs Enum Overhead - -**Audit finding**: `MaybeDrs` enum adds a branch dispatch per poll. - -**Decision**: **ACCEPTABLE**. The dispatch overhead (~3-5 cycles with branch prediction) is negligible vs TLS crypto, I/O latency, and network RTT. Do NOT attempt zero-overhead abstractions (e.g., generic specialization); the complexity is not worth the unmeasurable gain. Document the assumption in code comments. - ---- - -### D5: Log-Normal Distribution Parameterization — CRITICAL FIX - -**Audit finding**: Fixed `sigma=0.5` creates an 18% clamp spike at `ceiling`, detectable by DPI. - -**Decision**: **FIXED** (already applied above). New parameterization: -- mu = (ln(floor) + ln(ceiling)) / 2 → median = sqrt(floor * ceiling) (geometric mean, NOT arithmetic mean) -- sigma = ln(ceiling/floor) / 4.65 → ensures ~99% of samples fall within [floor, ceiling] -- Result: NO spike; distribution smoothly bounded. -- Function renamed to `sample_lognormal_percentile_bounded` to reflect guarantee. -- **Mathematical note**: The median of this distribution is the geometric mean sqrt(floor * ceiling), which differs from the arithmetic mean (floor+ceiling)/2 for asymmetric ranges. Tests must assert against the geometric mean, not the arithmetic mean. - ---- - -### D6: IptController pre-construction to avoid unwrap() - -**Audit finding**: `LogNormal::new().unwrap()` in `next_delay_us` won't compile under `deny(clippy::unwrap_used)`. - -**Decision**: **FIXED** (redesigned above). IptController now uses inline Box-Muller sampling for the normal component and manual Pareto CDF inversion. No distribution objects are stored; no `unwrap()`/`expect()` calls needed. The `random_bool(p)` API replaces manual u32 threshold computation for Markov transitions. - ---- - -### D7: Adaptive Buffers — Stale Entry Leak - -**Audit finding**: `seed_tier_for_user` returns Base for expired profiles but doesn't remove them; cache fills with stale entries. - -**Decision**: **FIXED** (already applied above). Two changes: -1. `seed_tier_for_user` now uses `DashMap::remove_if` with a TTL predicate (atomic, avoids TOCTOU race where concurrent `record_user_tier` inserts a fresh profile between `drop(entry)` and `remove(user)`). -2. `record_user_tier` uses TTL-based `DashMap::retain()` for overflow eviction (single O(n) pass, removes stale entries when cache exceeds `MAX_USER_PROFILES_ENTRIES`). This replaces the originally proposed "oldest N by LRU" strategy which would have required O(n log n) sorting + double-shard-locking. - ---- - -### D8: throughput_to_tier Metric — Average Not Peak - -**Audit finding**: Function computes average throughput over entire session; bursty traffic is underestimated. - -**Decision**: **RENAMED + DOCUMENTED**. New name: `average_throughput_to_tier` makes the limitation explicit. Comment documents: "Uses total-session average, not instantaneous peak. Consider peak-window measurement from watchdog snapshots as a future refinement." Users deploying in bursty-traffic environments should consider manual tier pinning via config until this limitation is addressed. - ---- - -## Answers to Audit Open Questions - -> **Q1: PR-B phasing — single atomic PR-B or split Phase 1 (shim) + Phase 2 (production threading)?** - -**A1**: Proceed with single atomic PR-B. The shim approach delays clean state and complicates review. High test coverage from PR-A mitigates risk. Use sequential sub-phases within the PR (ProxySharedState creation → accessors → test helpers) and require parallel test execution gates before merge. - ---- - -> **Q2: Middle relay DRS — should PR-C also address ME→client path, or is that a follow-up?** - -**A2**: Follow-up (PR-C.1) **at the same priority level as PR-C** (High). Direct relay DRS is the initial deliverable; it's self-contained. However, middle relay is the **default production mode** for deployments with configured ME URLs, making PR-C.1 critical. Middle relay has a different architecture (explicit flush loop, not copy_bidirectional) and warrants separate implementation, but must begin immediately after PR-C merges. Annotate PR-C: "Coverage: Direct relay only. Middle relay DRS planned for next release." - ---- - -> **Q3: PR-C → PR-B dependency reversal — are you OK with reversing the order to deliver DRS first?** - -**A3**: **YES, change the dependency order to PR-C → PR-B**. DRS is lower-risk, higher-value, and independent of DI. This improves parallelization and reduces the critical path. Update the plan's PR Dependency Graph accordingly. - ---- - -> **Q4: `copy_bidirectional` replacement for IPT — is the team prepared to write a custom poll loop for PR-G Option A (direct relay)?** - -**A4**: **Document as a risk item for PR-E decision gate**. If PR-E chooses Option A (direct relay IPT), a custom poll loop is **mandatory** — `copy_bidirectional` is not compatible. Estimate: ~300-line custom relay loop + full test matrix. This is non-trivial. PR-E experiments should include a prototype of the custom loop to validate feasibility before committing. If the team is not prepared for ~2-3 weeks of dedicated work on the relay loop, **choose Option B** (middle relay only for in-session IPT). - -**IMPORTANT ADDENDUM**: The IptController has been redesigned to be **data-availability-aware** (see F14). The original purely-stochastic Idle model would have broken active Telegram connections by injecting 2–30 second delays unconditionally. The redesigned controller only applies Burst delays when data is pending; idle timing is handled naturally by the caller's `recv()` blocking on the data channel. This simplifies the Option A custom loop (no need for tokio::time::sleep with variable durations — just a short fixed sleep in the poll loop when data is available). - ---- - -> **Q5: Log-normal sigma — dynamic computation or fixed 0.5?** - -**A5**: **Use dynamic computation** (already fixed above). Parameterize so ~99% of samples fall in [floor, ceiling], with median at the geometric mean sqrt(floor*ceiling). Function: `sample_lognormal_percentile_bounded(floor, ceiling, rng)`. - ---- - -## Out-of-Scope Boundaries - -- No AES-NI changes: the `aes` crate performs runtime CPUID detection automatically. -- No sharding of `USER_PROFILES` DashMap: no measured bottleneck exists. -- No monolithic PRs: each item has its own branch and review cycle. -- No relaxation of red test assertions without a proven code fix — tests are the ground truth. - ---- - -## Critical Review — Issues Found and Fixed - -This section documents all issues found during critical review of the original plan, whether they were corrected inline (above) or require explicit acknowledgement. - -### Fixed Inline (code/plan corrections applied above) - -| # | Issue | Severity | Fix | -|---|---|---|---| -| F1 | PR-B "Blocks PR-C" contradicts D1 decision to swap ordering | Medium | PR-B header updated to "Blocks PR-D" only | -| F2 | Static line numbers wrong (handshake.rs: 71→52, 72→53, 74→55, 30→33, 32→39; middle_relay.rs: 63→62) | Low | Corrected to match actual source | -| F3 | `_for_testing` helper line numbers wrong across both files | Low | Corrected to match actual source | -| F4 | `handle_tls_handshake` line reference 638→690; `handle_mtproto_handshake` 840→854; `client.rs` call sites wrong | Low | Corrected | -| F5 | `DrsWriter.records_completed` overflow on 32-bit: wraps after ~4B records, restarts DRS ramp (detectable signature) | High | Capped via `.saturating_add(1).min(DRS_PHASE_FINAL + 1)` | -| F6 | DRS TLS overhead comment assumed real TLS 1.3 (22 bytes), but FakeTlsWriter only adds 5-byte header (no AEAD, no content-type byte). Wire record = 1369 + 5 = 1374, NOT 1391 | **High** | Comment corrected to reflect FakeTLS overhead; constant 1369 retained as conservative value with 74-byte MSS margin | -| F7 | Log-normal median math error: `mu = (ln(f) + ln(c))/2` → median = sqrt(f*c) (geometric mean), NOT (f+c)/2 (arithmetic mean) | **Critical** | Test assertions and comments rewritten to assert geometric mean; function renamed to `sample_lognormal_percentile_bounded` | -| F8 | `seed_tier_for_user` TOCTOU race: `drop(entry)` then `remove(user)` can delete a fresh profile inserted between the two calls | High | Replaced with `DashMap::remove_if` with TTL predicate (atomic) | -| F9 | `record_user_tier` eviction strategy: "evict oldest N" requires O(n log n) + double shard-locking; `retain()` cannot select by count | Medium | Replaced with TTL-based `retain()` — single O(n) pass, removes stale entries | -| F10 | `IptController` Pareto idle clamp `[500_000, 30_000_000]`: lower bound 0.5s is dead code (Pareto minimum = scale = 2s) | Low | Lower clamp corrected to `2_000_000` with explanatory comment | -| F11 | D3 claim "Most deployments should use direct relay where possible" is misleading — middle relay is the default when ME URLs are configured | Medium | Rewritten to accurately describe both deployment modes | -| F12 | DRS scope: Missing `LOGGED_UNKNOWN_DCS` and `BEOBACHTEN_*_WARNED` from PR-B static inventory (direct_relay.rs line 24, client.rs lines 81, 88) | Medium | Added to PR-B table as lower-priority follow-up | -| F13 | `IptController` threshold approximation: P(stay) ≈ 0.95000000047 due to u32 truncation, not exactly 0.95 | Low | Comment added documenting the approximation | -| F14 | `IptController` Idle state injects 2–30s delays unconditionally, breaking active Telegram connections (Telegram client timeouts) | **Critical** | IptController redesigned to be data-availability-aware: `next_delay_us(rng, has_pending_data)`. When data is pending, always returns Burst-range delay. When idle, returns 0 (caller blocks on data channel naturally). | -| F15 | Test file `proxy_shared_state_isolation_tests.rs` declared in TWO modules (handshake.rs AND middle_relay.rs) via `#[path]` — causes duplicate symbol compilation errors | **Critical** | Changed to single declaration in `src/proxy/mod.rs` only | -| F16 | PR-F (log-normal) had artificial dependency on PR-B (DI) — zero code dependency exists; modifies only two `rng.random_range()` call sites | High | Made PR-F independent; can land after PR-A only | -| F17 | New config fields `drs_enabled`, `ipt_enabled`, `ipt_level` lacked `#[serde(default)]` annotations — existing config.toml files would fail to deserialize on upgrade | High | Added `#[serde(default = "...")]` annotations with helper functions | -| F18 | ProxySharedState `Mutex` type unspecified (std::sync vs tokio::sync) — incorrect choice causes async runtime issues | High | Explicitly specified `std::sync::Mutex` with rationale (short critical sections, no await points inside locks) | -| F19 | DRS architecture note showed `client_writer` as "actual TLS/TCP socket" — it's actually `CryptoWriter>` with internal buffering | High | Corrected call chain diagram to show CryptoWriter + FakeTlsWriter layers with buffering interaction documentation | -| F20 | DRS `DRS_FULL_RECORD_PAYLOAD = 16_384` was documented as "becomes a no-op" but `FakeTlsWriter` uses `MAX_TLS_CIPHERTEXT_SIZE = 16_640` — DRS still shapes in steady-state | Medium | Comment corrected; DRS at 16_384 intentionally mimics RFC 8446 plaintext limit | -| F21 | `IptController` burst sample: `(sample as u64) * 1_000` can overflow for extreme LogNormal tail values | Medium | Changed to `(sample as u64).saturating_mul(1_000)` with `.max(0.0)` guard for negative edge cases | -| F22 | PR-C.1 (middle relay DRS) was treated as casual follow-up but middle relay is the DEFAULT production mode | High | Elevated PR-C.1 to same priority as PR-C; must begin immediately after PR-C merges | -| F23 | `#![allow(dead_code)]` on adaptive_buffers.rs not planned for removal in PR-D | Medium | Added prerequisite to PR-D: remove the attribute when call sites are added | -| F24 | PR-E experiment tests (`adaptive_option_a_*`, `adaptive_option_b_*`) are performance benchmarks that will be flaky on shared CI runners | Medium | Added `#[ignore]` requirement; run only in isolated performance environments | -| F25 | `rand_distr = "0.5"` is incompatible with `rand = "0.10"` — `rand_distr 0.5` depends on `rand_core 0.9`; trait mismatch prevents compilation | **Critical** | Removed `rand_distr` dependency; replaced with manual log-normal via Box-Muller and manual Pareto CDF inversion. Zero new dependencies needed. | -| F26 | `sample_lognormal_percentile_bounded` with `floor=0`: `floor.max(1)` avoids ln(0) but silently shifts distribution center from `ceiling/2` (uniform) to `sqrt(ceiling)` (log-normal) — massive semantic change | **High** | Documented explicitly: only path 3 (`floor > 0 && ceiling > floor`) uses log-normal. Path 2 (`floor == 0`) retains uniform distribution. | -| F27 | `seed_tier_for_user` / `record_user_tier` use `duration_since` which panics if `seen_at > now` (concurrent Instant reordering in remove_if predicate) | **High** | Replaced all TTL predicates with `saturating_duration_since` — returns `Duration::ZERO` when `seen_at > now`, treating entry as fresh (safe). | -| F28 | IptController used `rand_distr::{LogNormal, Pareto}` (incompatible with rand 0.10) and pre-stored distribution objects requiring `expect()` (denied by clippy) | **Critical** | Redesigned: inline Box-Muller sampling for log-normal, manual CDF inversion for Pareto. `random_bool(p)` for Markov transitions. No stored objects, no `expect()`. | -| F29 | `ipt_level: u8` config field violates Architecture.md §4 (enums over magic numbers) | Low | Should be `enum IptLevel { SingleDelay, MarkovChain }` with `#[serde(rename_all = "snake_case")]`. | -| F30 | PR-A `test_harness_common.rs` declared via `#[path]` in three modules → triple duplicate symbol compilation failure | **Critical** | Declared once in `proxy/mod.rs`; imported via `use crate::proxy::test_harness_common::*` in consuming tests | -| F31 | PR-A `RecordingWriter` stored `Vec>` with ambiguous write-vs-flush boundaries; DRS tests (PR-C) need flush-boundary tracking | **High** | Dual-tracking design: `writes` (per poll_write) + `flushed` (per poll_flush boundary with accumulator) | -| F32 | PR-A `SliceReader` required `bytes` crate for no gain; `tokio::io::duplex()` already used everywhere | **High** | **Dropped** from test harness | -| F33 | PR-A `PendingWriter` only controlled `poll_write` pending; DRS flush-pending tests (`drs_pending_on_flush_propagates_pending_without_spurious_wake`) need separate flush control | Medium | Renamed to `PendingCountWriter` with separate `write_pending_remaining` and `flush_pending_remaining` counts | -| F34 | PR-A `relay_baseline_watchdog_delta_does_not_panic_on_u64_wrap` duplicates 7 existing tests in `relay_watchdog_delta_security_tests.rs` | **Critical** | **Dropped** — existing test file already provides exhaustive coverage including wrap, overflow, fuzz | -| F35 | PR-A `handshake_baseline_saturation_fires_at_configured_threshold` implies runtime config but `AUTH_PROBE_BACKOFF_START_FAILS` is a compile-time constant | Low | Renamed to `_compile_time_threshold` | -| F36 | PR-A middle_relay baseline tests directly poked global statics that PR-B removes | **High** | Rewritten to test through public functions (`mark_relay_idle_candidate`, `clear_relay_idle_candidate`) whose signatures survive PR-B | -| F37 | PR-A had zero masking baseline tests despite masking being the primary anti-DPI component and PR-F modifying it | **High** | Added `masking_baseline_invariant_tests.rs` with timing budget, fallback relay, consume-cap, and adversarial tests | -| F38 | PR-A had no error-path baseline tests — only happy paths locked | **High** | Added: simultaneous-close, broken-pipe, and many-small-writes relay baselines | -| F39 | PR-A `relay_baseline_empty_transfer_completes_without_error` was vague (no sharp assertions) | Medium | Replaced with `relay_baseline_zero_bytes_returns_ok_and_counters_zero` | -| F40 | PR-A `test_stats()` and `test_buffer_pool()` are trivial wrappers for one-liner constructors already inlined everywhere | Medium | **Dropped** from test harness to avoid unnecessary indirection | -| F41 | PR-A `seeded_rng` limitation not documented: cannot substitute for `SecureRandom` in production function calls | Medium | Documented as explicit limitation in code comment | -| F42 | PR-A no test isolation strategy documented for auth_probe global state contention | Medium | Each handshake baseline test acquires `auth_probe_test_lock()`, calls `clear_auth_probe_state_for_testing()`. Documented as temporary coupling eliminated in PR-B | -| F43 | PR-A was not split into sub-phases; utility iteration could block baseline tests | **High** | Split into PR-A.1 (utilities, compile-only gate) and PR-A.2 (baseline tests, all-green gate) | -| F44 | `sample_lognormal_percentile_bounded` and 14 masking lognormal tests already exist in codebase (masking.rs:258, masking_lognormal_timing_security_tests.rs). PR-F describes implementing what's already done. | **High** | PR-F's remaining scope: verify handshake.rs integration (already wired at line 596). PR-F may already be complete — audit needed before starting. | -| F45 | PR-A `handshake_test_config()` was missing; `tls_only_config()` alone is insufficient for handshake baseline tests requiring user/secret/masking config | **High** | Added `handshake_test_config(secret_hex)` to test harness | -| F46 | Previous external review C1 (DRS write-chain placement "fundamentally wrong") is **INCORRECT** — see R3/R6 in Acknowledged Risks. Each DrsWriter.poll_write passes ≤ target bytes to CryptoWriter in one call. CryptoWriter passes through to FakeTlsWriter in one call. FakeTlsWriter creates exactly one TLS record per poll_write. Flush at record boundary ensures CryptoWriter's pending buffer is drained before the next record starts. Chain is correct. | **Informational** | No plan change needed; external finding was wrong. | -| F47 | `BEOBACHTEN_*_WARNED` statics are process-scoped log-dedup guards. Moving to ProxySharedState changes semantics: warnings fire per-instance instead of per-process. | Medium | Keep as process-global statics (correct for log dedup). Do NOT migrate to ProxySharedState. | -| F48 | `ProxySharedState` nested into `HandshakeSharedState` + `MiddleRelaySharedState` — unnecessary indirection. Functions access `shared.handshake.auth_probe` instead of `shared.auth_probe` | Low | Consider flattening to a single struct for simplicity (KISS principle, Architecture.md §1). Both sub-structs are always accessed together through the parent. | - -### Acknowledged Risks (not fixable in plan, require runtime attention) - -| # | Risk | Mitigation | -|---|---|---| -| R1 | DRS per-record flush adds syscall overhead in steady-state (16KB records). `copy_bidirectional_with_sizes` also flushes independently → double-flush is idempotent but wastes cycles. | Benchmark in PR-C red tests. If overhead > 2% throughput regression, coarsen flush to every N records in steady-state phase. | -| R2 | `copy_bidirectional_with_sizes` internal buffering: when `DrsWriter.poll_write` returns fewer bytes than offered (record boundary), the copy loop retries with the remaining buffer. This is correct but untested with the specific tokio implementation. | Add a specific integration test `drs_copy_bidirectional_partial_write_retry` that verifies total data integrity when DrsWriter limits write sizes. | -| R3 | `DrsWriter` flush inside `poll_write` loop: DRS value depends on `FakeTlsWriter.poll_flush` actually draining its internal `WriteBuffer` to the socket and creating a TLS record boundary. **Verified**: `FakeTlsWriter.poll_flush` first calls `poll_flush_record_inner` (drains pending TLS record bytes) then `upstream.poll_flush` (drains socket). This IS a real record boundary. However, `CryptoWriter` sits between DRS and FakeTLS and has its own pending buffer. DRS flush → `CryptoWriter.poll_flush` (drains pending ciphertext) → `FakeTlsWriter.poll_flush`. If `CryptoWriter` has accumulated bytes from multiple DRS writes before flush (possible if earlier write returned buffered-but-Ok), those bytes may be flushed as one chunk to FakeTLS, creating one larger record instead of separate DRS-sized records. | Add integration test `drs_crypto_writer_buffering_chain_integrity` to verify full chain produces individual records at DRS boundaries. | -| R4 | `average_throughput_to_tier` uses session-average throughput, not peak-window. Bursty traffic patterns (video streaming: 30s burst at 100 Mbps, then 9.5min idle) will underestimate tier, resulting in sub-optimal buffer sizes for the burst phase of the next session. | Document limitation. Monitor via watchdog's 10s snapshots. Future PR: compute peak from watchdog snapshots rather than session average. | -| R5 | PR-C covers direct relay only; middle relay (often the default) has no DRS. This is a significant coverage gap for deployments using ME pools. | PR-C.1 follow-up for middle relay. Middle relay has natural flush-tick points that make DRS integration architecturally simpler. Prioritize PR-C.1 immediately after PR-C. | -| R6 | `CryptoWriter.poll_write` always returns `Ok(to_accept)` even when `FakeTlsWriter` returns Pending — it buffers internally. If DRS writes N bytes and CryptoWriter buffers them, then DRS flushes, CryptoWriter drains its buffer as ONE chunk to FakeTLS. FakeTLS receives the full N-byte chunk and creates one N+5 byte TLS record. This is correct behavior (one DRS record = one TLS record). BUT if CryptoWriter's `max_pending_write` (default 16KB) is smaller than a DRS write (impossible: max DRS write = 16384 ≤ 16KB), writes would be split. Verify `CryptoWriter.max_pending_write` is ≥ `DRS_FULL_RECORD_PAYLOAD`. | Integration test `drs_crypto_writer_buffering_chain_integrity`. | -| R7 | IptController redesign (data-availability-aware) removes the Idle-state delay generation entirely. The Pareto distribution and `idle_dist` field are now dead code. Consider removing them to avoid confusion, or repurposing them for synthetic keep-alive timing in a future Level 3 enhancement. | Document in PR-G that `idle_dist` is retained for future Level 3 (trace-driven synthetic idle traffic). | - -### Missing Tests (should be added to existing PR test lists) - -| Test | PR | Rationale | -|---|---|---| -| `drs_statsio_byte_count_matches_actual_written` | PR-C | Verify StatsIo counters remain accurate when DrsWriter limits write sizes. Without this, a bug where DrsWriter eats or duplicates bytes goes undetected. | -| `drs_copy_bidirectional_partial_write_retry` | PR-C | Verify `copy_bidirectional_with_sizes` correctly retries when DrsWriter returns fewer bytes than offered at record boundaries. | -| `drs_records_completed_counter_does_not_wrap` | PR-C | On 32-bit `usize`, verify counter caps at `DRS_PHASE_FINAL + 1` and does not restart the DRS ramp. | -| `drs_flush_is_meaningful_for_faketls` | PR-C | Verify that `FakeTlsWriter.poll_flush` produces a TLS record boundary, otherwise DRS provides no anti-DPI value. | -| `adaptive_startup_remove_if_does_not_delete_fresh_concurrent_insert` | PR-D | Concurrent test: thread A reads stale profile, thread B inserts fresh profile, thread A calls `remove_if` → assert fresh profile survives. | -| `ipt_controller_burst_stay_threshold_probability_accuracy` | PR-G | Verify empirical Burst self-transition probability is within ±0.001 of 0.95 over 10M samples. | -| `proxy_shared_state_logged_unknown_dcs_isolation` | PR-B | Verify `LOGGED_UNKNOWN_DCS` does not leak between instances (if migrated). | -| `ipt_controller_pending_data_forces_burst_delay` | PR-G | Verify that `next_delay_us(rng, has_pending_data=true)` always returns Burst-range delay even when Markov state is Idle. Critical for connection liveness. | -| `ipt_controller_no_pending_data_returns_zero` | PR-G | Verify that `next_delay_us(rng, has_pending_data=false)` returns 0, ensuring no artificial stalling when the relay is idle. | -| `ipt_controller_burst_sample_overflow_safe` | PR-G | Verify LogNormal extreme tail samples don't overflow `saturating_mul(1_000)` and are properly clamped. | -| `ipt_controller_idle_sample_extreme_f64_safe` | PR-G | Verify Pareto samples of f64::INFINITY or f64::NAN are safely handled by `as u64` cast + clamp. | -| `drs_crypto_writer_buffering_chain_integrity` | PR-C | Verify that DRS → CryptoWriter (with internal pending buffer) → FakeTlsWriter produces correct TLS record boundaries. CryptoWriter may buffer; flush must drain the entire chain. | -| `drs_config_serde_default_upgrade_compat` | PR-C | Verify that deserializing a config.toml WITHOUT `drs_enabled` field produces `drs_enabled=true` (serde default). Tests upgrade compatibility. | - diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 4fdeb0f..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,34 +0,0 @@ -### 3.0.0 Anschluss -- **Middle Proxy now is stable**, confirmed on canary-deploy over ~20 users -- Ad-tag now is working -- DC=203/CDN now is working over ME -- `getProxyConfig` and `ProxySecret` are automated -- Version order is now in format `3.0.0` - without Windows-style "microfixes" - -### 3.0.1 Kabelsammler -- Handshake timeouts fixed -- Connectivity logging refactored -- Docker: tmpfs for ProxyConfig and ProxySecret -- Public Host and Port in config -- ME Relays Head-of-Line Blocking fixed -- ME Ping - -### 3.0.2 Microtrencher -- New [network] section -- ME Fixes -- Small bugs coverage - -### 3.0.3 Ausrutscher -- ME as stateful, no conn-id migration -- No `flush()` on datapath after RpcWriter -- Hightech parser for IPv6 without regexp -- `nat_probe = true` by default -- Timeout for `recv()` in STUN-client -- ConnRegistry review -- Dualstack emergency reconnect - -### 3.0.4 Schneeflecken -- Only WARN and Links in Normal log -- Consistent IP-family detection -- Includes for config -- `nonce_frame_hex` in log only with `DEBUG` diff --git a/src/config/hot_reload/fields.rs b/src/config/hot_reload/fields.rs index 5084b49..f3d2e93 100644 --- a/src/config/hot_reload/fields.rs +++ b/src/config/hot_reload/fields.rs @@ -337,9 +337,15 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; + let process_limits = cfg.web.limits.clone(); + cfg.web = new.web.clone(); + cfg.web.limits = process_limits; if cfg.rebuild_runtime_user_auth().is_err() { cfg.runtime_user_auth = None; } + if cfg.rebuild_runtime_web().is_err() { + cfg.web = old.web.clone(); + } cfg } diff --git a/src/config/hot_reload/tests.rs b/src/config/hot_reload/tests.rs index 2ae7fe9..ae8769d 100644 --- a/src/config/hot_reload/tests.rs +++ b/src/config/hot_reload/tests.rs @@ -123,6 +123,7 @@ fn listener_synlimit_fields_are_process_owned() { let mut old = sample_config(); old.server.listeners.push(ListenerConfig { ip: "0.0.0.0".parse().unwrap(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(443), client_mss: None, synlimit: SynLimitMode::Iptables, @@ -138,6 +139,8 @@ fn listener_synlimit_fields_are_process_owned() { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), }); let mut new = old.clone(); new.server.port = 8443; diff --git a/src/config/load.rs b/src/config/load.rs index 96b1802..45d03e9 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -22,6 +22,8 @@ mod includes; mod strict_keys; // Precomputed user authentication data for handshake hot paths. mod runtime_auth; +// Validated immutable WEB configuration and static-site snapshots. +mod runtime_web; // Post-deserialization validation helpers. mod decode; mod effective; @@ -30,6 +32,7 @@ mod validate_core; mod validate_me; mod validate_runtime; mod validate_server; +mod validate_web; mod validation; use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes}; @@ -96,6 +99,10 @@ pub struct ProxyConfig { #[serde(default)] pub server: ServerConfig, + /// WEB carrier ingress and public-site fallback configuration. + #[serde(default)] + pub web: WebConfig, + /// Timeout values used by client, fallback, and upstream operations. #[serde(default)] pub timeouts: TimeoutsConfig, @@ -204,6 +211,11 @@ impl ProxyConfig { Ok(()) } + /// Rebuilds validated WEB capabilities and immutable decoy snapshots. + pub(crate) fn rebuild_runtime_web(&mut self) -> Result<()> { + runtime_web::rebuild(self) + } + pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> { self.runtime_user_auth.as_deref() } diff --git a/src/config/load/effective.rs b/src/config/load/effective.rs index 6c2b9e0..2997e15 100644 --- a/src/config/load/effective.rs +++ b/src/config/load/effective.rs @@ -120,6 +120,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { if let Ok(ipv4) = ipv4_str.parse::() { config.server.listeners.push(ListenerConfig { ip: ipv4, + transport: ListenerTransport::Mtproxy, port: Some(config.server.port), client_mss: None, synlimit: SynLimitMode::default(), @@ -135,6 +136,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), }); } if let Some(ipv6_str) = &config.server.listen_addr_ipv6 @@ -142,6 +145,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { { config.server.listeners.push(ListenerConfig { ip: ipv6, + transport: ListenerTransport::Mtproxy, port: Some(config.server.port), client_mss: None, synlimit: SynLimitMode::default(), @@ -157,6 +161,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), }); } } @@ -211,5 +217,6 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { validate_logging_config(&config.logging)?; validate_upstreams(config)?; config.rebuild_runtime_user_auth()?; + config.rebuild_runtime_web()?; Ok(()) } diff --git a/src/config/load/pipeline.rs b/src/config/load/pipeline.rs index 7bdc3cb..b035ac8 100644 --- a/src/config/load/pipeline.rs +++ b/src/config/load/pipeline.rs @@ -7,6 +7,7 @@ pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result Result<()> { + let auth = config.runtime_user_auth().ok_or_else(|| { + ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string()) + })?; + let mut runtime_vhosts = BTreeMap::new(); + let mut runtime_profiles = Vec::new(); + let mut static_files = 0usize; + let mut static_bytes = 0usize; + + for vhost in &config.web.vhosts { + let decoy = build_decoy( + vhost, + &config.web.limits, + &mut static_files, + &mut static_bytes, + )?; + let mut profiles = Vec::with_capacity(vhost.profiles.len()); + let mut capabilities = HashSet::with_capacity(vhost.profiles.len()); + for profile in &vhost.profiles { + let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| { + ProxyError::Config(format!( + "WEB profile references unknown access user `{}`", + profile.user + )) + })?; + let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| { + ProxyError::Config("WEB profile user snapshot is inconsistent".to_string()) + })?; + let (client_secret, client_secret_len) = + client_secret(auth_entry.secret, profile.secret_mode); + let capability = derive_web_capability( + &client_secret[..client_secret_len], + vhost.host.as_bytes(), + )?; + if !capabilities.insert(capability) { + return Err(ProxyError::Config(format!( + "WEB vhost `{}` contains profiles with the same client capability", + vhost.host + ))); + } + let runtime_profile = Arc::new(WebRuntimeProfile { + host: vhost.host.clone(), + public_addr: vhost.public_addr, + user: profile.user.clone(), + secret_mode: profile.secret_mode, + capability, + max_sessions: profile + .max_sessions + .unwrap_or(config.web.limits.max_sessions_global), + max_streams: profile + .max_streams + .unwrap_or(config.web.limits.max_streams_global), + max_streams_per_session: profile + .max_streams_per_session + .unwrap_or(config.web.limits.max_streams_per_session), + }); + profiles.push(Arc::clone(&runtime_profile)); + runtime_profiles.push(runtime_profile); + } + runtime_vhosts.insert( + vhost.host.clone(), + Arc::new(WebRuntimeVhost { + host: vhost.host.clone(), + decoy, + decoy_header_secs: config.web.timeouts.decoy_header_secs, + profiles, + }), + ); + } + + config.web.runtime = Some(Arc::new(WebRuntimeConfig { + vhosts: runtime_vhosts, + profiles: runtime_profiles, + })); + Ok(()) +} + +/// Derives the Telegram Desktop WEB capability for one exact secret and host. +pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> { + let mut mac = Hmac::::new_from_slice(secret).map_err(|_| { + ProxyError::Config("WEB capability secret must not be empty".to_string()) + })?; + mac.update(WEB_CAPABILITY_CONTEXT); + mac.update(host); + Ok(mac.finalize().into_bytes().into()) +} + +fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) { + let mut client_secret = [0u8; 17]; + match mode { + WebSecretMode::Plain => { + client_secret[..16].copy_from_slice(&secret); + (client_secret, 16) + } + WebSecretMode::Dd => { + client_secret[0] = 0xdd; + client_secret[1..].copy_from_slice(&secret); + (client_secret, 17) + } + } +} + +fn build_decoy( + vhost: &WebVhostConfig, + limits: &WebLimitsConfig, + static_files: &mut usize, + static_bytes: &mut usize, +) -> Result { + match &vhost.decoy { + WebDecoyConfig::HttpUpstream { upstream } => { + let parsed = url::Url::parse(upstream).map_err(|error| { + ProxyError::Config(format!( + "WEB decoy upstream for `{}` is invalid: {error}", + vhost.host + )) + })?; + let ip = match parsed.host() { + Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip), + Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip), + _ => { + return Err(ProxyError::Config( + "WEB decoy host must be an IP literal".to_string(), + )); + } + }; + let host = ip.to_string(); + let port = parsed.port_or_known_default().ok_or_else(|| { + ProxyError::Config("WEB decoy port cannot be resolved".to_string()) + })?; + let authority = match (ip, parsed.port()) { + (std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"), + (std::net::IpAddr::V6(_), None) => format!("[{host}]"), + (std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"), + (std::net::IpAddr::V4(_), None) => host.clone(), + }; + Ok(WebRuntimeDecoy::HttpUpstream { + addr: SocketAddr::new(ip, port), + authority, + }) + } + WebDecoyConfig::StaticDirectory { directory, index } => { + let site = load_static_site( + directory, + index, + limits, + static_files, + static_bytes, + )?; + Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site))) + } + } +} + +fn load_static_site( + root: &Path, + index: &str, + limits: &WebLimitsConfig, + total_files: &mut usize, + total_bytes: &mut usize, +) -> Result { + let root_metadata = fs::symlink_metadata(root).map_err(|error| { + ProxyError::Config(format!( + "failed to inspect WEB static directory `{}`: {error}", + root.display() + )) + })?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(ProxyError::Config(format!( + "WEB static directory `{}` must be a real directory, not a symlink", + root.display() + ))); + } + let canonical_root = fs::canonicalize(root).map_err(|error| { + ProxyError::Config(format!( + "failed to canonicalize WEB static directory `{}`: {error}", + root.display() + )) + })?; + let mut assets = BTreeMap::new(); + load_static_directory( + &canonical_root, + &canonical_root, + &mut assets, + total_files, + total_bytes, + limits, + 0, + )?; + if !assets.contains_key(&format!("/{index}")) { + return Err(ProxyError::Config(format!( + "WEB static directory `{}` does not contain index `{index}`", + root.display() + ))); + } + Ok(WebStaticSite { + assets, + index: index.to_string(), + }) +} + +fn load_static_directory( + root: &Path, + directory: &Path, + assets: &mut BTreeMap, + total_files: &mut usize, + total_bytes: &mut usize, + limits: &WebLimitsConfig, + depth: usize, +) -> Result<()> { + let entries = fs::read_dir(directory).map_err(|error| { + ProxyError::Config(format!( + "failed to read WEB static directory `{}`: {error}", + directory.display() + )) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + ProxyError::Config(format!("failed to read WEB static entry: {error}")) + })?; + if *total_files >= limits.max_static_files { + return Err(ProxyError::Config( + "WEB static entries exceed process-wide web.limits.max_static_files" + .to_string(), + )); + } + *total_files += 1; + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + ProxyError::Config(format!( + "failed to inspect WEB static entry `{}`: {error}", + path.display() + )) + })?; + if file_type.is_symlink() { + return Err(ProxyError::Config(format!( + "WEB static entry `{}` must not be a symlink", + path.display() + ))); + } + if file_type.is_dir() { + if depth >= MAX_WEB_STATIC_DEPTH { + return Err(ProxyError::Config(format!( + "WEB static directory `{}` exceeds the maximum nesting depth", + path.display() + ))); + } + load_static_directory( + root, + &path, + assets, + total_files, + total_bytes, + limits, + depth + 1, + )?; + continue; + } + if !file_type.is_file() { + return Err(ProxyError::Config(format!( + "WEB static entry `{}` must be a regular file", + path.display() + ))); + } + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + let file = options.open(&path).map_err(|error| { + ProxyError::Config(format!( + "failed to open WEB static file `{}`: {error}", + path.display() + )) + })?; + let metadata = file.metadata().map_err(|error| { + ProxyError::Config(format!( + "failed to inspect WEB static file `{}`: {error}", + path.display() + )) + })?; + if !metadata.is_file() { + return Err(ProxyError::Config(format!( + "WEB static entry `{}` changed before it was opened", + path.display() + ))); + } + let file_len = usize::try_from(metadata.len()).map_err(|_| { + ProxyError::Config(format!("WEB static file `{}` is too large", path.display())) + })?; + if file_len > limits.max_static_file_bytes { + return Err(ProxyError::Config(format!( + "WEB static file `{}` exceeds web.limits.max_static_file_bytes", + path.display() + ))); + } + *total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| { + ProxyError::Config("WEB static snapshot byte count overflowed usize".to_string()) + })?; + if *total_bytes > limits.max_static_bytes { + return Err(ProxyError::Config( + "WEB static snapshots exceed process-wide web.limits.max_static_bytes" + .to_string(), + )); + } + let relative = path.strip_prefix(root).map_err(|_| { + ProxyError::Config("WEB static path escaped its configured root".to_string()) + })?; + let route = static_route(relative)?; + let mut body = Vec::with_capacity(file_len); + file.take(limits.max_static_file_bytes as u64 + 1) + .read_to_end(&mut body) + .map_err(|error| { + ProxyError::Config(format!( + "failed to read WEB static file `{}`: {error}", + path.display() + )) + })?; + if body.len() != file_len { + return Err(ProxyError::Config(format!( + "WEB static file `{}` changed while its snapshot was built", + path.display() + ))); + } + let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body))); + assets.insert( + route, + WebStaticAsset { + body: Bytes::from(body), + content_type: static_content_type(&path), + etag, + }, + ); + } + Ok(()) +} + +fn static_route(relative: &Path) -> Result { + let mut route = String::new(); + for component in relative.components() { + let std::path::Component::Normal(component) = component else { + return Err(ProxyError::Config( + "WEB static path contains an unsafe component".to_string(), + )); + }; + let component = component.to_str().ok_or_else(|| { + ProxyError::Config("WEB static file names must be valid UTF-8".to_string()) + })?; + route.push('/'); + route.push_str(component); + } + Ok(route) +} + +fn static_content_type(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("html") | Some("htm") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js") | Some("mjs") => "text/javascript; charset=utf-8", + Some("json") => "application/json", + Some("txt") => "text/plain; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + + use super::*; + + #[test] + fn capability_matches_reference_vectors() { + let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); + let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap(); + assert_eq!( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain), + "MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk" + ); + let mut dd_secret = vec![0xdd]; + dd_secret.extend_from_slice(&secret); + let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap(); + assert_eq!( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd), + "IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA" + ); + } +} diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 21fecb6..807de7f 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -7,6 +7,7 @@ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[ "logging", "network", "server", + "web", "timeouts", "censorship", "access", @@ -238,6 +239,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[ const LISTENER_CONFIG_KEYS: &[&str] = &[ "ip", + "transport", "port", "client_mss", "synlimit", @@ -253,6 +255,70 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ "announce_ip", "proxy_protocol", "reuse_allow", + "web_client_ip_source", + "web_trusted_proxy_cidrs", +]; + +const WEB_CONFIG_KEYS: &[&str] = &["enabled", "limits", "timeouts", "vhosts"]; + +const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[ + "max_header_bytes", + "max_body_bytes", + "max_frame_payload_bytes", + "carrier_batch_bytes", + "max_frames_per_body", + "max_http_connections", + "max_http_handlers", + "max_body_readers", + "max_body_bytes_global", + "max_sessions_global", + "max_sessions_per_ip", + "max_streams_per_session", + "max_streams_global", + "max_stream_handshakes", + "max_tombstones_per_session", + "pending_bytes_per_session", + "pending_bytes_global", + "pending_items_per_session", + "pending_items_global", + "control_bytes_per_session", + "control_bytes_global", + "max_bootstraps_global", + "max_bootstraps_per_ip", + "max_vhosts", + "max_profiles", + "max_static_files", + "max_static_file_bytes", + "max_static_bytes", + "memory_envelope_bytes", + "new_bootstraps_per_minute", + "new_bootstraps_burst", + "new_sessions_per_minute", + "new_sessions_burst", + "new_streams_per_minute", + "new_streams_burst", +]; + +const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[ + "header_secs", + "body_secs", + "stream_handshake_secs", + "long_poll_secs", + "bootstrap_lifetime_secs", + "reconnect_grace_secs", + "http_idle_secs", + "shutdown_secs", + "decoy_header_secs", +]; + +const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "public_addr", "decoy", "profiles"]; +const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"]; +const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[ + "user", + "secret_mode", + "max_sessions", + "max_streams", + "max_streams_per_session", ]; const TIMEOUTS_CONFIG_KEYS: &[&str] = &[ @@ -366,300 +432,12 @@ const LOGGING_CONFIG_KEYS: &[&str] = &[ "max_age_secs", ]; -#[derive(Debug)] -struct UnknownConfigKey { - path: String, - suggestion: Option, -} - -fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current.as_table() -} - -fn is_strict_config(parsed_toml: &toml::Value) -> bool { - table_at(parsed_toml, &["general"]) - .and_then(|table| table.get("config_strict")) - .and_then(toml::Value::as_bool) - .unwrap_or(false) -} - -fn known_config_keys_for_suggestion() -> Vec<&'static str> { - let mut keys = Vec::new(); - for group in [ - TOP_LEVEL_CONFIG_KEYS, - GENERAL_CONFIG_KEYS, - NETWORK_CONFIG_KEYS, - SERVER_CONFIG_KEYS, - API_CONFIG_KEYS, - CONNTRACK_CONTROL_CONFIG_KEYS, - LISTENER_CONFIG_KEYS, - TIMEOUTS_CONFIG_KEYS, - CENSORSHIP_CONFIG_KEYS, - TLS_FETCH_CONFIG_KEYS, - ACCESS_CONFIG_KEYS, - RATE_LIMIT_BPS_CONFIG_KEYS, - UPSTREAM_CONFIG_KEYS, - PROXY_MODES_CONFIG_KEYS, - TELEMETRY_CONFIG_KEYS, - LINKS_CONFIG_KEYS, - LOGGING_CONFIG_KEYS, - ] { - keys.extend_from_slice(group); - } - keys -} - -fn levenshtein_distance(a: &str, b: &str) -> usize { - let b_chars: Vec = b.chars().collect(); - let mut prev: Vec = (0..=b_chars.len()).collect(); - let mut curr = vec![0usize; b_chars.len() + 1]; - - for (i, ca) in a.chars().enumerate() { - curr[0] = i + 1; - for (j, cb) in b_chars.iter().enumerate() { - let replace = if ca == *cb { prev[j] } else { prev[j] + 1 }; - curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace); - } - std::mem::swap(&mut prev, &mut curr); - } - - prev[b_chars.len()] -} - -fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option { - let normalized = key.to_ascii_lowercase(); - let mut best: Option<(&str, usize)> = None; - for known in known_keys { - let distance = levenshtein_distance(&normalized, known); - let is_better = match best { - Some((_, best_distance)) => distance < best_distance, - None => true, - }; - if distance <= 4 && is_better { - best = Some((known, distance)); - } - } - best.map(|(known, _)| known.to_string()) -} - -fn push_unknown_keys( - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: &str, - table: &toml::Table, - allowed: &[&str], -) { - for key in table.keys() { - if !allowed.contains(&key.as_str()) { - let full_path = if path.is_empty() { - key.clone() - } else { - format!("{path}.{key}") - }; - unknown.push(UnknownConfigKey { - path: full_path, - suggestion: unknown_key_suggestion(key, known_for_suggestion), - }); - } - } -} - -fn check_known_table( - parsed_toml: &toml::Value, - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: &[&str], - allowed: &[&str], -) { - if let Some(table) = table_at(parsed_toml, path) { - push_unknown_keys( - unknown, - known_for_suggestion, - &path.join("."), - table, - allowed, - ); - } -} - -fn check_nested_table_value( - unknown: &mut Vec, - known_for_suggestion: &[&'static str], - path: String, - value: &toml::Value, - allowed: &[&str], -) { - if let Some(table) = value.as_table() { - push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed); - } -} - -fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec { - let known_for_suggestion = known_config_keys_for_suggestion(); - let mut unknown = Vec::new(); - - if let Some(root) = parsed_toml.as_table() { - push_unknown_keys( - &mut unknown, - &known_for_suggestion, - "", - root, - TOP_LEVEL_CONFIG_KEYS, - ); - } - - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general"], - GENERAL_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "modes"], - PROXY_MODES_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "telemetry"], - TELEMETRY_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["general", "links"], - LINKS_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["logging"], - LOGGING_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["network"], - NETWORK_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server"], - SERVER_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "api"], - API_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "admin_api"], - API_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["server", "conntrack_control"], - CONNTRACK_CONTROL_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["timeouts"], - TIMEOUTS_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["censorship"], - CENSORSHIP_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["censorship", "tls_fetch"], - TLS_FETCH_CONFIG_KEYS, - ); - check_known_table( - parsed_toml, - &mut unknown, - &known_for_suggestion, - &["access"], - ACCESS_CONFIG_KEYS, - ); - - if let Some(listeners) = table_at(parsed_toml, &["server"]) - .and_then(|table| table.get("listeners")) - .and_then(toml::Value::as_array) - { - for (idx, listener) in listeners.iter().enumerate() { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("server.listeners[{idx}]"), - listener, - LISTENER_CONFIG_KEYS, - ); - } - } - - if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) { - for (idx, upstream) in upstreams.iter().enumerate() { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("upstreams[{idx}]"), - upstream, - UPSTREAM_CONFIG_KEYS, - ); - } - } - - for access_map in ["user_rate_limits", "cidr_rate_limits"] { - if let Some(table) = table_at(parsed_toml, &["access"]) - .and_then(|access| access.get(access_map)) - .and_then(toml::Value::as_table) - { - for (entry_name, value) in table { - check_nested_table_value( - &mut unknown, - &known_for_suggestion, - format!("access.{access_map}.{entry_name}"), - value, - RATE_LIMIT_BPS_CONFIG_KEYS, - ); - } - } - } - - unknown -} +// Recursive table traversal and key suggestion logic. +mod check; +/// Rejects or reports unknown configuration keys according to strict mode. pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> { - let unknown = collect_unknown_config_keys(parsed_toml); + let unknown = check::collect_unknown_config_keys(parsed_toml); if unknown.is_empty() { return Ok(()); } @@ -676,7 +454,7 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<() } } - if is_strict_config(parsed_toml) { + if check::is_strict_config(parsed_toml) { let mut paths = Vec::with_capacity(unknown.len()); for item in unknown { if let Some(suggestion) = item.suggestion { diff --git a/src/config/load/strict_keys/check.rs b/src/config/load/strict_keys/check.rs new file mode 100644 index 0000000..6e5101f --- /dev/null +++ b/src/config/load/strict_keys/check.rs @@ -0,0 +1,362 @@ +use super::*; + +#[derive(Debug)] +/// One rejected configuration path and its optional nearest known key. +pub(super) struct UnknownConfigKey { + /// Fully qualified configuration path. + pub(super) path: String, + /// Nearest known key when edit distance is sufficiently small. + pub(super) suggestion: Option, +} + +fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_table() +} + +/// Reads strict-key enforcement without deserializing the full configuration. +pub(super) fn is_strict_config(parsed_toml: &toml::Value) -> bool { + table_at(parsed_toml, &["general"]) + .and_then(|table| table.get("config_strict")) + .and_then(toml::Value::as_bool) + .unwrap_or(false) +} + +fn known_config_keys_for_suggestion() -> Vec<&'static str> { + let mut keys = Vec::new(); + for group in [ + TOP_LEVEL_CONFIG_KEYS, + GENERAL_CONFIG_KEYS, + NETWORK_CONFIG_KEYS, + SERVER_CONFIG_KEYS, + API_CONFIG_KEYS, + CONNTRACK_CONTROL_CONFIG_KEYS, + LISTENER_CONFIG_KEYS, + WEB_CONFIG_KEYS, + WEB_LIMITS_CONFIG_KEYS, + WEB_TIMEOUTS_CONFIG_KEYS, + WEB_VHOST_CONFIG_KEYS, + WEB_DECOY_CONFIG_KEYS, + WEB_PROFILE_CONFIG_KEYS, + TIMEOUTS_CONFIG_KEYS, + CENSORSHIP_CONFIG_KEYS, + TLS_FETCH_CONFIG_KEYS, + ACCESS_CONFIG_KEYS, + RATE_LIMIT_BPS_CONFIG_KEYS, + UPSTREAM_CONFIG_KEYS, + PROXY_MODES_CONFIG_KEYS, + TELEMETRY_CONFIG_KEYS, + LINKS_CONFIG_KEYS, + LOGGING_CONFIG_KEYS, + ] { + keys.extend_from_slice(group); + } + keys +} + +fn levenshtein_distance(a: &str, b: &str) -> usize { + let b_chars: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b_chars.len()).collect(); + let mut curr = vec![0usize; b_chars.len() + 1]; + + for (i, ca) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, cb) in b_chars.iter().enumerate() { + let replace = if ca == *cb { prev[j] } else { prev[j] + 1 }; + curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace); + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[b_chars.len()] +} + +fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option { + let normalized = key.to_ascii_lowercase(); + let mut best: Option<(&str, usize)> = None; + for known in known_keys { + let distance = levenshtein_distance(&normalized, known); + let is_better = match best { + Some((_, best_distance)) => distance < best_distance, + None => true, + }; + if distance <= 4 && is_better { + best = Some((known, distance)); + } + } + best.map(|(known, _)| known.to_string()) +} + +fn push_unknown_keys( + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: &str, + table: &toml::Table, + allowed: &[&str], +) { + for key in table.keys() { + if !allowed.contains(&key.as_str()) { + let full_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + unknown.push(UnknownConfigKey { + path: full_path, + suggestion: unknown_key_suggestion(key, known_for_suggestion), + }); + } + } +} + +fn check_known_table( + parsed_toml: &toml::Value, + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: &[&str], + allowed: &[&str], +) { + if let Some(table) = table_at(parsed_toml, path) { + push_unknown_keys( + unknown, + known_for_suggestion, + &path.join("."), + table, + allowed, + ); + } +} + +fn check_nested_table_value( + unknown: &mut Vec, + known_for_suggestion: &[&'static str], + path: String, + value: &toml::Value, + allowed: &[&str], +) { + if let Some(table) = value.as_table() { + push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed); + } +} + +/// Collects unknown keys across every supported nested configuration table. +pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec { + let known_for_suggestion = known_config_keys_for_suggestion(); + let mut unknown = Vec::new(); + + if let Some(root) = parsed_toml.as_table() { + push_unknown_keys( + &mut unknown, + &known_for_suggestion, + "", + root, + TOP_LEVEL_CONFIG_KEYS, + ); + } + + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general"], + GENERAL_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "modes"], + PROXY_MODES_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "telemetry"], + TELEMETRY_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["general", "links"], + LINKS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["logging"], + LOGGING_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["network"], + NETWORK_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server"], + SERVER_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "api"], + API_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "admin_api"], + API_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["server", "conntrack_control"], + CONNTRACK_CONTROL_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["web"], + WEB_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["web", "limits"], + WEB_LIMITS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["web", "timeouts"], + WEB_TIMEOUTS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["timeouts"], + TIMEOUTS_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["censorship"], + CENSORSHIP_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["censorship", "tls_fetch"], + TLS_FETCH_CONFIG_KEYS, + ); + check_known_table( + parsed_toml, + &mut unknown, + &known_for_suggestion, + &["access"], + ACCESS_CONFIG_KEYS, + ); + + if let Some(listeners) = table_at(parsed_toml, &["server"]) + .and_then(|table| table.get("listeners")) + .and_then(toml::Value::as_array) + { + for (idx, listener) in listeners.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("server.listeners[{idx}]"), + listener, + LISTENER_CONFIG_KEYS, + ); + } + } + + if let Some(vhosts) = table_at(parsed_toml, &["web"]) + .and_then(|table| table.get("vhosts")) + .and_then(toml::Value::as_array) + { + for (vhost_idx, vhost) in vhosts.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("web.vhosts[{vhost_idx}]"), + vhost, + WEB_VHOST_CONFIG_KEYS, + ); + if let Some(vhost) = vhost.as_table() { + if let Some(decoy) = vhost.get("decoy") { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("web.vhosts[{vhost_idx}].decoy"), + decoy, + WEB_DECOY_CONFIG_KEYS, + ); + } + if let Some(profiles) = vhost.get("profiles").and_then(toml::Value::as_array) { + for (profile_idx, profile) in profiles.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("web.vhosts[{vhost_idx}].profiles[{profile_idx}]"), + profile, + WEB_PROFILE_CONFIG_KEYS, + ); + } + } + } + } + } + + if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) { + for (idx, upstream) in upstreams.iter().enumerate() { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("upstreams[{idx}]"), + upstream, + UPSTREAM_CONFIG_KEYS, + ); + } + } + + for access_map in ["user_rate_limits", "cidr_rate_limits"] { + if let Some(table) = table_at(parsed_toml, &["access"]) + .and_then(|access| access.get(access_map)) + .and_then(toml::Value::as_table) + { + for (entry_name, value) in table { + check_nested_table_value( + &mut unknown, + &known_for_suggestion, + format!("access.{access_map}.{entry_name}"), + value, + RATE_LIMIT_BPS_CONFIG_KEYS, + ); + } + } + } + + unknown +} diff --git a/src/config/load/validate_web.rs b/src/config/load/validate_web.rs new file mode 100644 index 0000000..1f64a99 --- /dev/null +++ b/src/config/load/validate_web.rs @@ -0,0 +1,545 @@ +use std::collections::HashSet; + +use super::*; + +const WEB_FRAME_HEADER_BYTES: usize = 8; +const WEB_QUEUE_ITEM_COST: usize = 256; +const WEB_CONTROL_EXTRA_ITEMS: usize = 16; +const WEB_CONTROL_ITEMS_PER_STREAM: usize = 3; +const WEB_INITIAL_STREAM_WINDOW: usize = 4 * 1024 * 1024; +const MAX_WEB_HEADER_BYTES: usize = 64 * 1024; +const MAX_WEB_BODY_BYTES: usize = 16 * 1024 * 1024; +const MAX_WEB_FRAME_BYTES: usize = 1024 * 1024; +const MAX_WEB_FRAMES_PER_BODY: usize = 4096; +const MAX_WEB_TOMBSTONES_PER_SESSION: usize = 4096; +const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024; + +/// Validates WEB policy and resource bounds before building runtime state. +pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + let web_listener_count = config + .server + .listeners + .iter() + .filter(|listener| listener.transport == ListenerTransport::Web) + .count(); + let eligible_web_listener_count = config + .server + .listeners + .iter() + .filter(|listener| listener.transport == ListenerTransport::Web) + .filter(|listener| { + (listener.ip.is_ipv4() && config.network.ipv4) + || (listener.ip.is_ipv6() && config.network.ipv6 != Some(false)) + }) + .count(); + + for (idx, listener) in config.server.listeners.iter().enumerate() { + match listener.transport { + ListenerTransport::Mtproxy => { + if !listener.web_trusted_proxy_cidrs.is_empty() { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].web_trusted_proxy_cidrs is only valid for transport=web" + ))); + } + } + ListenerTransport::Web => validate_web_listener(config, idx, listener)?, + } + } + + if config.web.enabled && eligible_web_listener_count == 0 { + return Err(ProxyError::Config( + "web.enabled requires at least one network-eligible server.listeners entry with transport=web" + .to_string(), + )); + } + if web_listener_count > 0 && config.web.vhosts.is_empty() { + return Err(ProxyError::Config( + "WEB listeners require at least one [[web.vhosts]] entry".to_string(), + )); + } + + validate_limits(&config.web.limits)?; + validate_timeouts(&config.web.timeouts)?; + validate_vhosts(config)?; + Ok(()) +} + +fn validate_web_listener( + config: &ProxyConfig, + idx: usize, + listener: &ListenerConfig, +) -> Result<()> { + if listener.web_trusted_proxy_cidrs.is_empty() { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].web_trusted_proxy_cidrs must be non-empty for transport=web" + ))); + } + if listener + .web_trusted_proxy_cidrs + .iter() + .any(|network| network.prefix() == 0) + { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].web_trusted_proxy_cidrs must not contain a /0 network" + ))); + } + let proxy_protocol = listener + .proxy_protocol + .unwrap_or(config.server.proxy_protocol); + if proxy_protocol { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].proxy_protocol must be false for transport=web; WEB identity is accepted only from the configured L7 header" + ))); + } + if listener.reuse_allow { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}].reuse_allow is not supported for transport=web without external session affinity" + ))); + } + if listener.client_mss.is_some() + || listener.synlimit != SynLimitMode::Off + || listener.announce.is_some() + || listener.announce_ip.is_some() + { + return Err(ProxyError::Config(format!( + "server.listeners[{idx}] WEB transport does not accept client_mss, synlimit, announce, or announce_ip" + ))); + } + Ok(()) +} + +fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { + if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) { + return config_error("web.limits.max_header_bytes must be within [8192, 65536]"); + } + if !(WEB_FRAME_HEADER_BYTES..=MAX_WEB_BODY_BYTES).contains(&limits.max_body_bytes) { + return config_error("web.limits.max_body_bytes must be within [8, 16777216]"); + } + if !(1..=MAX_WEB_FRAME_BYTES).contains(&limits.max_frame_payload_bytes) { + return config_error("web.limits.max_frame_payload_bytes must be within [1, 1048576]"); + } + if !(1..=MAX_WEB_FRAMES_PER_BODY).contains(&limits.max_frames_per_body) { + return config_error("web.limits.max_frames_per_body must be within [1, 4096]"); + } + if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) { + return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]"); + } + if limits.carrier_batch_bytes > limits.max_body_bytes + || limits.carrier_batch_bytes + < limits + .max_frame_payload_bytes + .saturating_add(WEB_FRAME_HEADER_BYTES) + { + return config_error( + "web.limits.carrier_batch_bytes must fit max_body_bytes and one maximum frame", + ); + } + if limits.max_frame_payload_bytes > WEB_INITIAL_STREAM_WINDOW { + return config_error( + "web.limits.max_frame_payload_bytes must not exceed the initial stream window", + ); + } + + let positive = [ + ("max_http_connections", limits.max_http_connections), + ("max_http_handlers", limits.max_http_handlers), + ("max_body_readers", limits.max_body_readers), + ("max_body_bytes_global", limits.max_body_bytes_global), + ("max_sessions_global", limits.max_sessions_global), + ("max_sessions_per_ip", limits.max_sessions_per_ip), + ("max_streams_per_session", limits.max_streams_per_session), + ("max_streams_global", limits.max_streams_global), + ("max_stream_handshakes", limits.max_stream_handshakes), + ("pending_bytes_per_session", limits.pending_bytes_per_session), + ("pending_bytes_global", limits.pending_bytes_global), + ("pending_items_per_session", limits.pending_items_per_session), + ("pending_items_global", limits.pending_items_global), + ("control_bytes_per_session", limits.control_bytes_per_session), + ("control_bytes_global", limits.control_bytes_global), + ("max_bootstraps_global", limits.max_bootstraps_global), + ("max_bootstraps_per_ip", limits.max_bootstraps_per_ip), + ("max_vhosts", limits.max_vhosts), + ("max_profiles", limits.max_profiles), + ("max_static_files", limits.max_static_files), + ("max_static_file_bytes", limits.max_static_file_bytes), + ("max_static_bytes", limits.max_static_bytes), + ("memory_envelope_bytes", limits.memory_envelope_bytes), + ]; + if let Some((field, _)) = positive.into_iter().find(|(_, value)| *value == 0) { + return config_error(&format!("web.limits.{field} must be > 0")); + } + for (field, value) in [ + ("max_http_connections", limits.max_http_connections), + ("max_http_handlers", limits.max_http_handlers), + ("max_body_readers", limits.max_body_readers), + ("max_body_bytes_global", limits.max_body_bytes_global), + ("max_stream_handshakes", limits.max_stream_handshakes), + ] { + if value > tokio::sync::Semaphore::MAX_PERMITS { + return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity")); + } + } + let rates = [ + ("new_bootstraps_per_minute", limits.new_bootstraps_per_minute), + ("new_bootstraps_burst", limits.new_bootstraps_burst), + ("new_sessions_per_minute", limits.new_sessions_per_minute), + ("new_sessions_burst", limits.new_sessions_burst), + ("new_streams_per_minute", limits.new_streams_per_minute), + ("new_streams_burst", limits.new_streams_burst), + ]; + if let Some((field, _)) = rates.into_iter().find(|(_, value)| *value == 0) { + return config_error(&format!("web.limits.{field} must be > 0")); + } + if limits.max_streams_per_session > u16::MAX as usize { + return config_error("web.limits.max_streams_per_session must fit synthetic source ports"); + } + if limits.max_sessions_per_ip > limits.max_sessions_global + || limits.max_streams_per_session > limits.max_streams_global + || limits.max_stream_handshakes > limits.max_streams_global + || limits.max_bootstraps_per_ip > limits.max_bootstraps_global + || limits.max_http_handlers > limits.max_http_connections + || limits.max_body_readers > limits.max_http_handlers + || limits.pending_bytes_per_session > limits.pending_bytes_global + || limits.pending_items_per_session > limits.pending_items_global + || limits.control_bytes_per_session > limits.control_bytes_global + || limits.control_bytes_per_session > limits.pending_bytes_per_session + || limits.control_bytes_global > limits.pending_bytes_global + || limits.max_static_file_bytes > limits.max_static_bytes + { + return config_error("web.limits per-owner ceilings must not exceed global ceilings"); + } + let control_items_per_session = WEB_CONTROL_EXTRA_ITEMS + .checked_add( + limits + .max_streams_per_session + .checked_mul(WEB_CONTROL_ITEMS_PER_STREAM) + .ok_or_else(|| { + ProxyError::Config( + "web.limits control item reservation overflowed usize".to_string(), + ) + })?, + ) + .ok_or_else(|| { + ProxyError::Config("web.limits control item reservation overflowed usize".to_string()) + })?; + let control_items_global = control_items_per_session + .checked_mul(limits.max_sessions_global) + .ok_or_else(|| { + ProxyError::Config("web.limits global control reservation overflowed usize".to_string()) + })?; + let control_frame_cost = WEB_FRAME_HEADER_BYTES + 4 + WEB_QUEUE_ITEM_COST; + let required_control_bytes_per_session = control_items_per_session + .checked_mul(control_frame_cost) + .ok_or_else(|| { + ProxyError::Config("web.limits control byte reservation overflowed usize".to_string()) + })?; + let required_control_bytes_global = control_items_global + .checked_mul(control_frame_cost) + .ok_or_else(|| { + ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string()) + })?; + if control_items_per_session >= limits.pending_items_per_session + || control_items_global >= limits.pending_items_global + || required_control_bytes_per_session > limits.control_bytes_per_session + || required_control_bytes_global > limits.control_bytes_global + { + return config_error( + "web.limits control reserves must cover bounded control frames and leave data capacity", + ); + } + let uplink_bytes = limits + .max_frames_per_body + .checked_mul(WEB_QUEUE_ITEM_COST) + .and_then(|value| value.checked_add(limits.max_body_bytes)) + .ok_or_else(|| { + ProxyError::Config("web.limits uplink reservation overflowed usize".to_string()) + })?; + let minimum_downlink_frame_bytes = WEB_FRAME_HEADER_BYTES + 1 + WEB_QUEUE_ITEM_COST; + let session_required_bytes = limits + .control_bytes_per_session + .checked_add(uplink_bytes) + .and_then(|value| value.checked_add(minimum_downlink_frame_bytes)) + .ok_or_else(|| { + ProxyError::Config("web.limits session reservation overflowed usize".to_string()) + })?; + let session_required_items = control_items_per_session + .checked_add(limits.max_frames_per_body) + .and_then(|value| value.checked_add(1)) + .ok_or_else(|| { + ProxyError::Config("web.limits session item reservation overflowed usize".to_string()) + })?; + let global_required_bytes = limits + .control_bytes_global + .checked_add(uplink_bytes) + .and_then(|value| value.checked_add(minimum_downlink_frame_bytes)) + .ok_or_else(|| { + ProxyError::Config("web.limits global reservation overflowed usize".to_string()) + })?; + let global_required_items = control_items_global + .checked_add(limits.max_frames_per_body) + .and_then(|value| value.checked_add(1)) + .ok_or_else(|| { + ProxyError::Config("web.limits global item reservation overflowed usize".to_string()) + })?; + if session_required_bytes > limits.pending_bytes_per_session + || session_required_items > limits.pending_items_per_session + || global_required_bytes > limits.pending_bytes_global + || global_required_items > limits.pending_items_global + { + return config_error( + "web.limits pending ceilings must preserve one uplink batch and downlink progress", + ); + } + let body_reservation = limits + .max_body_readers + .checked_mul(limits.max_body_bytes) + .ok_or_else(|| { + ProxyError::Config("web.limits body reader reservation overflowed usize".to_string()) + })?; + if body_reservation > limits.max_body_bytes_global + || limits.max_body_bytes_global > u32::MAX as usize + { + return config_error( + "web.limits max_body_readers * max_body_bytes must fit max_body_bytes_global and u32", + ); + } + let http_header_reservation = limits + .max_http_connections + .checked_mul(limits.max_header_bytes) + .ok_or_else(|| { + ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string()) + })?; + let reserved = limits + .pending_bytes_global + .checked_add(limits.max_body_bytes_global) + .and_then(|value| value.checked_add(limits.max_static_bytes)) + .and_then(|value| value.checked_add(http_header_reservation)) + .ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?; + if reserved > limits.memory_envelope_bytes + || limits.memory_envelope_bytes > MAX_WEB_MEMORY_ENVELOPE_BYTES + { + return config_error( + "web.limits memory reservations must fit memory_envelope_bytes within 4 GiB", + ); + } + Ok(()) +} + +fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> { + let values = [ + ("header_secs", timeouts.header_secs), + ("body_secs", timeouts.body_secs), + ("stream_handshake_secs", timeouts.stream_handshake_secs), + ("long_poll_secs", timeouts.long_poll_secs), + ("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs), + ("reconnect_grace_secs", timeouts.reconnect_grace_secs), + ("http_idle_secs", timeouts.http_idle_secs), + ("shutdown_secs", timeouts.shutdown_secs), + ("decoy_header_secs", timeouts.decoy_header_secs), + ]; + if let Some((field, _)) = values + .into_iter() + .find(|(_, value)| !(1..=3600).contains(value)) + { + return config_error(&format!("web.timeouts.{field} must be within [1, 3600]")); + } + let request_deadline = timeouts + .header_secs + .max(timeouts.body_secs) + .max(timeouts.long_poll_secs) + .max(timeouts.decoy_header_secs); + if request_deadline >= timeouts.http_idle_secs { + return config_error("web.timeouts request deadlines must be lower than http_idle_secs"); + } + Ok(()) +} + +fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { + let limits = &config.web.limits; + if config.web.vhosts.len() > limits.max_vhosts { + return config_error("web.vhosts exceeds web.limits.max_vhosts"); + } + let mut hosts = HashSet::with_capacity(config.web.vhosts.len()); + let mut profile_count = 0usize; + for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() { + vhost.host = normalize_web_host( + &vhost.host, + &format!("web.vhosts[{vhost_idx}].host"), + )?; + if !hosts.insert(vhost.host.clone()) { + return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host)); + } + if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() { + return config_error(&format!( + "web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443" + )); + } + if config.web.enabled && vhost.profiles.is_empty() { + return config_error(&format!( + "web.vhosts[{vhost_idx}].profiles must be non-empty when web.enabled=true" + )); + } + validate_decoy(vhost_idx, &vhost.decoy)?; + let mut profiles = HashSet::with_capacity(vhost.profiles.len()); + for (profile_idx, profile) in vhost.profiles.iter().enumerate() { + if !config.access.users.contains_key(&profile.user) { + return config_error(&format!( + "web.vhosts[{vhost_idx}].profiles[{profile_idx}].user references unknown access user `{}`", + profile.user + )); + } + if !profiles.insert((profile.user.as_str(), profile.secret_mode)) { + return config_error(&format!( + "duplicate WEB profile for user `{}` in vhost `{}`", + profile.user, vhost.host + )); + } + let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global); + let max_streams_per_session = profile + .max_streams_per_session + .unwrap_or(limits.max_streams_per_session); + if profile.max_sessions == Some(0) + || profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global) + || profile.max_streams == Some(0) + || profile + .max_streams + .is_some_and(|value| value > limits.max_streams_global) + || profile.max_streams_per_session == Some(0) + || profile + .max_streams_per_session + .is_some_and(|value| value > limits.max_streams_per_session) + || max_streams_per_session > max_streams + { + return config_error(&format!( + "web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits" + )); + } + profile_count = profile_count.checked_add(1).ok_or_else(|| { + ProxyError::Config("WEB profile count overflowed usize".to_string()) + })?; + } + } + if profile_count > limits.max_profiles { + return config_error("WEB profiles exceed web.limits.max_profiles"); + } + Ok(()) +} + +fn normalize_web_host(value: &str, field: &str) -> Result { + let input = value.trim(); + if input.is_empty() + || input.ends_with('.') + || input + .chars() + .any(|character| matches!(character, ':' | '/' | '?' | '#' | '@')) + { + return config_error(&format!( + "{field} must be a hostname without a port, path, credentials, or trailing dot" + )); + } + let host = normalize_domain_to_ascii(input, field)?; + if host.len() > 253 + || !host.contains('.') + || host.parse::().is_ok() + || web_host_last_label_is_numeric(&host) + { + return config_error(&format!( + "{field} must be a non-IP fully-qualified hostname accepted by Telegram Desktop" + )); + } + for label in host.split('.') { + if label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return config_error(&format!( + "{field} contains a hostname label rejected by Telegram Desktop" + )); + } + } + Ok(host) +} + +fn web_host_last_label_is_numeric(host: &str) -> bool { + let label = host.rsplit('.').next().unwrap_or_default(); + let digits = label + .strip_prefix("0x") + .or_else(|| label.strip_prefix("0X")); + if let Some(digits) = digits { + return digits.bytes().all(|byte| byte.is_ascii_hexdigit()); + } + label.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> { + match decoy { + WebDecoyConfig::HttpUpstream { upstream } => { + let parsed = url::Url::parse(upstream).map_err(|error| { + ProxyError::Config(format!( + "web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}" + )) + })?; + if parsed.scheme() != "http" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + || parsed.port() == Some(0) + { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment" + )); + } + let ip = match parsed.host() { + Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip), + Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip), + _ => { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal" + )); + } + }; + let private = match ip { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + IpAddr::V6(ip) => { + ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() + } + }; + if !private { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network" + )); + } + } + WebDecoyConfig::StaticDirectory { directory, index } => { + if !directory.is_absolute() { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy.directory must be absolute" + )); + } + if index.is_empty() + || index.contains('\\') + || std::path::Path::new(index).components().count() != 1 + || matches!(index.as_str(), "." | "..") + { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy.index must be one safe file name" + )); + } + } + } + Ok(()) +} + +fn config_error(message: &str) -> Result { + Err(ProxyError::Config(message.to_string())) +} + +#[cfg(test)] +mod tests; diff --git a/src/config/load/validate_web/tests.rs b/src/config/load/validate_web/tests.rs new file mode 100644 index 0000000..9e0e0d1 --- /dev/null +++ b/src/config/load/validate_web/tests.rs @@ -0,0 +1,26 @@ +use super::*; + +#[test] +fn web_host_normalization_matches_client_vectors() { + assert_eq!( + normalize_web_host(" Proxy.Example.COM ", "host").unwrap(), + "proxy.example.com" + ); + assert_eq!( + normalize_web_host("bücher.example", "host").unwrap(), + "xn--bcher-kva.example" + ); + for invalid in [ + "localhost", + "127.0.0.1", + "127.1", + "0x7f.1", + "0177.0.0.1", + "1.2.3", + "site.example:443", + "site..example", + "site.example.", + ] { + assert!(normalize_web_host(invalid, "host").is_err(), "{invalid}"); + } +} diff --git a/src/config/tests/load_basic_tests.rs b/src/config/tests/load_basic_tests.rs index 939e2b1..e24285f 100644 --- a/src/config/tests/load_basic_tests.rs +++ b/src/config/tests/load_basic_tests.rs @@ -52,3 +52,5 @@ mod synlimit_mss_tests; mod tls_fetch_tests; #[path = "load_basic_tests/upstream_tests.rs"] mod upstream_tests; +#[path = "load_basic_tests/web_tests.rs"] +mod web_tests; diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs new file mode 100644 index 0000000..b781601 --- /dev/null +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -0,0 +1,96 @@ +use super::*; + +const WEB_CONFIG: &str = r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" + +[[server.listeners]] +ip = "127.0.0.1" +port = 18080 +transport = "web" +proxy_protocol = false +web_client_ip_source = "x_forwarded_for" +web_trusted_proxy_cidrs = ["127.0.0.1/32"] + +[web] +enabled = true + +[[web.vhosts]] +host = "Proxy.Example.COM" +public_addr = "203.0.113.10:443" + +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://127.0.0.1:18081" + +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "dd" +max_sessions = 4 +max_streams = 64 +max_streams_per_session = 16 +"#; + +#[test] +fn web_config_builds_canonical_runtime_snapshot() { + let config = load_config_from_temp_toml(WEB_CONFIG); + let runtime = config.web.runtime.expect("WEB runtime snapshot"); + let vhost = runtime + .vhosts + .get("proxy.example.com") + .expect("canonical WEB vhost"); + assert_eq!(vhost.profiles.len(), 1); + assert_eq!(vhost.profiles[0].user, "alice"); + assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd); + assert_eq!(vhost.profiles[0].max_sessions, 4); + assert_eq!(vhost.profiles[0].max_streams, 64); + assert_eq!(vhost.profiles[0].max_streams_per_session, 16); +} + +#[test] +fn web_listener_requires_an_explicit_trusted_proxy() { + let invalid = WEB_CONFIG.replace( + "web_trusted_proxy_cidrs = [\"127.0.0.1/32\"]", + "web_trusted_proxy_cidrs = []", + ); + let error = load_config_error_from_temp_toml(&invalid); + assert!(error.contains("web_trusted_proxy_cidrs must be non-empty")); +} + +#[test] +fn web_queue_limits_preserve_control_and_uplink_progress() { + let invalid = WEB_CONFIG.replace( + "[web]\nenabled = true", + "[web]\nenabled = true\n\n[web.limits]\ncontrol_bytes_per_session = 1", + ); + let error = load_config_error_from_temp_toml(&invalid); + assert!(error.contains("control reserves must cover bounded control frames")); +} + +#[test] +fn web_semaphore_limits_are_rejected_before_runtime_construction() { + let invalid = WEB_CONFIG.replace( + "[web]\nenabled = true", + &format!( + "[web]\nenabled = true\n\n[web.limits]\nmax_http_connections = {}", + tokio::sync::Semaphore::MAX_PERMITS + 1, + ), + ); + let error = load_config_error_from_temp_toml(&invalid); + assert!(error.contains("exceeds Tokio semaphore capacity")); +} + +#[test] +fn web_ipv6_decoy_uses_a_valid_http_authority() { + let ipv6 = WEB_CONFIG.replace( + "http://127.0.0.1:18081", + "http://[::1]:18081", + ); + let config = load_config_from_temp_toml(&ipv6); + let runtime = config.web.runtime.expect("WEB runtime snapshot"); + let vhost = runtime.vhosts.get("proxy.example.com").unwrap(); + let WebRuntimeDecoy::HttpUpstream { authority, .. } = &vhost.decoy else { + panic!("expected HTTP decoy"); + }; + assert_eq!(authority, "[::1]:18081"); +} diff --git a/src/config/types.rs b/src/config/types.rs index 204242a..ecdea93 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -23,6 +23,7 @@ mod logging; mod network; mod policies; mod server; +mod web; pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps}; #[allow(unused_imports)] @@ -43,7 +44,17 @@ pub use policies::{ pub use server::{ CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU, ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile, - ListenerConfig, ServerConfig, SynLimitMode, TimeoutsConfig, + ListenerConfig, ListenerTransport, ServerConfig, SynLimitMode, TimeoutsConfig, + WebClientIpSource, +}; +#[allow(unused_imports)] +pub use web::{ + WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode, + WebTimeoutsConfig, WebVhostConfig, +}; +pub(crate) use web::{ + WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset, + WebStaticSite, }; fn default_quota_state_path() -> PathBuf { diff --git a/src/config/types/server.rs b/src/config/types/server.rs index a1153a5..f333d76 100644 --- a/src/config/types/server.rs +++ b/src/config/types/server.rs @@ -75,6 +75,26 @@ pub enum SynLimitMode { Pf, } +/// Application protocol accepted by one process-owned TCP listener. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ListenerTransport { + /// Existing MTProxy TCP listener behavior. + #[default] + Mtproxy, + /// Plain HTTP WEB gateway behind a trusted TLS terminator. + Web, +} + +/// Trusted L7 source used to recover a WEB client's identity address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum WebClientIpSource { + /// Require exactly one canonical IP in `X-Forwarded-For`. + #[default] + XForwardedFor, +} + impl Serialize for SynLimitMode { fn serialize(&self, serializer: S) -> std::result::Result where @@ -380,6 +400,9 @@ impl Default for TimeoutsConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListenerConfig { pub ip: IpAddr, + /// Application protocol accepted by this listener. + #[serde(default)] + pub transport: ListenerTransport, /// Per-listener TCP port. If omitted, falls back to legacy `server.port`. #[serde(default)] pub port: Option, @@ -429,6 +452,12 @@ pub struct ListenerConfig { /// Default is false for safety. #[serde(default)] pub reuse_allow: bool, + /// L7 header policy used only by WEB listeners. + #[serde(default)] + pub web_client_ip_source: WebClientIpSource, + /// Immediate socket peers allowed to provide the WEB client identity header. + #[serde(default)] + pub web_trusted_proxy_cidrs: Vec, } /// Client-facing TCP MSS preset for extreme-low fragmentation profiles. diff --git a/src/config/types/web.rs b/src/config/types/web.rs new file mode 100644 index 0000000..c246b58 --- /dev/null +++ b/src/config/types/web.rs @@ -0,0 +1,434 @@ +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; + +/// Client-facing secret representation used to derive a WEB capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WebSecretMode { + /// Use the existing 16-byte access secret without a prefix. + Plain, + /// Prefix the existing access secret with `0xdd` for capability derivation. + Dd, +} + +/// One access user explicitly exposed through a WEB virtual host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebProfileConfig { + /// Existing `[access.users]` key authenticated by the inner MTProxy handshake. + pub user: String, + /// Exact client-facing secret representation advertised in WEB links. + pub secret_mode: WebSecretMode, + /// Optional per-profile live session ceiling. + #[serde(default)] + pub max_sessions: Option, + /// Optional per-profile live logical-stream ceiling. + #[serde(default)] + pub max_streams: Option, + /// Optional per-profile stream ceiling for one session. + #[serde(default)] + pub max_streams_per_session: Option, +} + +/// Public-site fallback used for requests that are not authenticated WEB traffic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum WebDecoyConfig { + /// Stream requests to one fixed private HTTP origin. + HttpUpstream { + /// Origin URL without a query or fragment. + upstream: String, + }, + /// Serve an immutable, bounded snapshot of a local directory. + StaticDirectory { + /// Absolute directory containing public files. + directory: PathBuf, + /// File served for `/` and directory paths. + #[serde(default = "default_web_static_index")] + index: String, + }, +} + +/// One externally visible WEB hostname and its explicit access profiles. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebVhostConfig { + /// Canonical lowercase ACE hostname used by Telegram Desktop. + pub host: String, + /// Stable public destination tuple used by inner relay routing and KDF metadata. + pub public_addr: SocketAddr, + /// Ordinary-site fallback for this hostname. + pub decoy: WebDecoyConfig, + /// Access users and exact secret modes enabled for this hostname. + #[serde(default)] + pub profiles: Vec, +} + +/// Hard process and protocol limits for WEB ingress. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebLimitsConfig { + /// Maximum bytes accepted while parsing one HTTP request head. + #[serde(default = "default_web_max_header_bytes")] + pub max_header_bytes: usize, + /// Maximum collected carrier request body size. + #[serde(default = "default_web_max_body_bytes")] + pub max_body_bytes: usize, + /// Maximum payload carried by one WEB frame. + #[serde(default = "default_web_max_frame_payload_bytes")] + pub max_frame_payload_bytes: usize, + /// Maximum encoded downlink batch returned by one poll. + #[serde(default = "default_web_carrier_batch_bytes")] + pub carrier_batch_bytes: usize, + /// Maximum frame count parsed or emitted in one carrier body. + #[serde(default = "default_web_max_frames_per_body")] + pub max_frames_per_body: usize, + /// Process-wide accepted WEB HTTP connection ceiling. + #[serde(default = "default_web_max_http_connections")] + pub max_http_connections: usize, + /// Process-wide concurrently executing HTTP handler ceiling. + #[serde(default = "default_web_max_http_handlers")] + pub max_http_handlers: usize, + /// Process-wide concurrently collected request body ceiling. + #[serde(default = "default_web_max_body_readers")] + pub max_body_readers: usize, + /// Process-wide byte reservation for collected request bodies. + #[serde(default = "default_web_max_body_bytes_global")] + pub max_body_bytes_global: usize, + /// Process-wide live WEB session ceiling. + #[serde(default = "default_web_max_sessions_global")] + pub max_sessions_global: usize, + /// Live WEB session ceiling for one forwarded client address. + #[serde(default = "default_web_max_sessions_per_ip")] + pub max_sessions_per_ip: usize, + /// Default live logical-stream ceiling for one WEB session. + #[serde(default = "default_web_max_streams_per_session")] + pub max_streams_per_session: usize, + /// Process-wide live logical-stream ceiling. + #[serde(default = "default_web_max_streams_global")] + pub max_streams_global: usize, + /// Process-wide concurrent inner MTProxy handshake ceiling. + #[serde(default = "default_web_max_stream_handshakes")] + pub max_stream_handshakes: usize, + /// Closed stream identifiers retained by one session. + #[serde(default = "default_web_max_tombstones")] + pub max_tombstones_per_session: usize, + /// Total queued data and control bytes allowed for one session. + #[serde(default = "default_web_pending_bytes_per_session")] + pub pending_bytes_per_session: usize, + /// Process-wide queued data and control byte ceiling. + #[serde(default = "default_web_pending_bytes_global")] + pub pending_bytes_global: usize, + /// Total queued data and control item ceiling for one session. + #[serde(default = "default_web_pending_items_per_session")] + pub pending_items_per_session: usize, + /// Process-wide queued data and control item ceiling. + #[serde(default = "default_web_pending_items_global")] + pub pending_items_global: usize, + /// Per-session byte reserve available only to control frames. + #[serde(default = "default_web_control_bytes_per_session")] + pub control_bytes_per_session: usize, + /// Process-wide byte reserve available only to control frames. + #[serde(default = "default_web_control_bytes_global")] + pub control_bytes_global: usize, + /// Process-wide live bootstrap credential ceiling. + #[serde(default = "default_web_max_bootstraps_global")] + pub max_bootstraps_global: usize, + /// Live bootstrap credential ceiling for one forwarded client address. + #[serde(default = "default_web_max_bootstraps_per_ip")] + pub max_bootstraps_per_ip: usize, + /// Maximum configured WEB virtual-host count. + #[serde(default = "default_web_max_vhosts")] + pub max_vhosts: usize, + /// Maximum configured WEB access-profile count across all virtual hosts. + #[serde(default = "default_web_max_profiles")] + pub max_profiles: usize, + /// Maximum static snapshot entry count across all virtual hosts. + #[serde(default = "default_web_max_static_files")] + pub max_static_files: usize, + /// Maximum bytes read from one static snapshot file. + #[serde(default = "default_web_max_static_file_bytes")] + pub max_static_file_bytes: usize, + /// Maximum static snapshot bytes across all virtual hosts. + #[serde(default = "default_web_max_static_bytes")] + pub max_static_bytes: usize, + /// Declared process envelope for HTTP heads, bodies, queues, and static snapshots. + #[serde(default = "default_web_memory_envelope_bytes")] + pub memory_envelope_bytes: usize, + /// Sustained process-wide bootstrap issuance rate. + #[serde(default = "default_web_new_bootstraps_per_minute")] + pub new_bootstraps_per_minute: u32, + /// Process-wide bootstrap issuance burst. + #[serde(default = "default_web_new_bootstraps_burst")] + pub new_bootstraps_burst: u32, + /// Sustained process-wide session creation rate. + #[serde(default = "default_web_new_sessions_per_minute")] + pub new_sessions_per_minute: u32, + /// Process-wide session creation burst. + #[serde(default = "default_web_new_sessions_burst")] + pub new_sessions_burst: u32, + /// Sustained process-wide logical-stream creation rate. + #[serde(default = "default_web_new_streams_per_minute")] + pub new_streams_per_minute: u32, + /// Process-wide logical-stream creation burst. + #[serde(default = "default_web_new_streams_burst")] + pub new_streams_burst: u32, +} + +impl Default for WebLimitsConfig { + fn default() -> Self { + Self { + max_header_bytes: default_web_max_header_bytes(), + max_body_bytes: default_web_max_body_bytes(), + max_frame_payload_bytes: default_web_max_frame_payload_bytes(), + carrier_batch_bytes: default_web_carrier_batch_bytes(), + max_frames_per_body: default_web_max_frames_per_body(), + max_http_connections: default_web_max_http_connections(), + max_http_handlers: default_web_max_http_handlers(), + max_body_readers: default_web_max_body_readers(), + max_body_bytes_global: default_web_max_body_bytes_global(), + max_sessions_global: default_web_max_sessions_global(), + max_sessions_per_ip: default_web_max_sessions_per_ip(), + max_streams_per_session: default_web_max_streams_per_session(), + max_streams_global: default_web_max_streams_global(), + max_stream_handshakes: default_web_max_stream_handshakes(), + max_tombstones_per_session: default_web_max_tombstones(), + pending_bytes_per_session: default_web_pending_bytes_per_session(), + pending_bytes_global: default_web_pending_bytes_global(), + pending_items_per_session: default_web_pending_items_per_session(), + pending_items_global: default_web_pending_items_global(), + control_bytes_per_session: default_web_control_bytes_per_session(), + control_bytes_global: default_web_control_bytes_global(), + max_bootstraps_global: default_web_max_bootstraps_global(), + max_bootstraps_per_ip: default_web_max_bootstraps_per_ip(), + max_vhosts: default_web_max_vhosts(), + max_profiles: default_web_max_profiles(), + max_static_files: default_web_max_static_files(), + max_static_file_bytes: default_web_max_static_file_bytes(), + max_static_bytes: default_web_max_static_bytes(), + memory_envelope_bytes: default_web_memory_envelope_bytes(), + new_bootstraps_per_minute: default_web_new_bootstraps_per_minute(), + new_bootstraps_burst: default_web_new_bootstraps_burst(), + new_sessions_per_minute: default_web_new_sessions_per_minute(), + new_sessions_burst: default_web_new_sessions_burst(), + new_streams_per_minute: default_web_new_streams_per_minute(), + new_streams_burst: default_web_new_streams_burst(), + } + } +} + +/// Deadlines for WEB HTTP, bootstrap, session, and shutdown lifecycle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WebTimeoutsConfig { + /// Deadline for receiving one complete HTTP request head. + #[serde(default = "default_web_header_timeout_secs")] + pub header_secs: u64, + /// Deadline for collecting one authenticated carrier request body. + #[serde(default = "default_web_body_timeout_secs")] + pub body_secs: u64, + /// Deadline for the inner MTProxy handshake on one logical stream. + #[serde(default = "default_web_stream_handshake_timeout_secs")] + pub stream_handshake_secs: u64, + /// Maximum wait for one empty downlink long poll. + #[serde(default = "default_web_long_poll_timeout_secs")] + pub long_poll_secs: u64, + /// Lifetime of an unused bootstrap credential and closed-token replay marker. + #[serde(default = "default_web_bootstrap_lifetime_secs")] + pub bootstrap_lifetime_secs: u64, + /// Maximum carrier inactivity before a session is closed. + #[serde(default = "default_web_reconnect_grace_secs")] + pub reconnect_grace_secs: u64, + /// Maximum idle lifetime of a WEB HTTP keep-alive connection. + #[serde(default = "default_web_http_idle_secs")] + pub http_idle_secs: u64, + /// Maximum graceful wait for WEB connections and process-owned tasks. + #[serde(default = "default_web_shutdown_secs")] + pub shutdown_secs: u64, + /// Deadline for connecting to and receiving headers from an HTTP decoy. + #[serde(default = "default_web_decoy_header_timeout_secs")] + pub decoy_header_secs: u64, +} + +impl Default for WebTimeoutsConfig { + fn default() -> Self { + Self { + header_secs: default_web_header_timeout_secs(), + body_secs: default_web_body_timeout_secs(), + stream_handshake_secs: default_web_stream_handshake_timeout_secs(), + long_poll_secs: default_web_long_poll_timeout_secs(), + bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(), + reconnect_grace_secs: default_web_reconnect_grace_secs(), + http_idle_secs: default_web_http_idle_secs(), + shutdown_secs: default_web_shutdown_secs(), + decoy_header_secs: default_web_decoy_header_timeout_secs(), + } + } +} + +/// WEB ingress, carrier, fallback, and lifecycle configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct WebConfig { + /// Enables issuance of new WEB bridge and session credentials. + #[serde(default)] + pub enabled: bool, + /// Hard process and protocol limits. + #[serde(default)] + pub limits: WebLimitsConfig, + /// WEB lifecycle deadlines. + #[serde(default)] + pub timeouts: WebTimeoutsConfig, + /// Public hostnames served by WEB listeners. + #[serde(default)] + pub vhosts: Vec, + /// Validated immutable runtime snapshot built during configuration loading. + #[serde(skip)] + pub(crate) runtime: Option>, +} + +/// Precomputed WEB configuration consumed by listener hot paths. +#[derive(Debug)] +pub(crate) struct WebRuntimeConfig { + /// Canonical host lookup used by HTTP request routing. + pub(crate) vhosts: BTreeMap>, + /// Flat profile inventory used by startup link emission. + pub(crate) profiles: Vec>, +} + +/// Precomputed immutable virtual-host data. +#[derive(Debug)] +pub(crate) struct WebRuntimeVhost { + /// Canonical lowercase ACE hostname. + pub(crate) host: String, + /// Immutable ordinary-site fallback snapshot. + pub(crate) decoy: WebRuntimeDecoy, + /// Upstream connect and response-head deadline. + pub(crate) decoy_header_secs: u64, + /// Exact capability profiles accepted by this host. + pub(crate) profiles: Vec>, +} + +/// Precomputed exact-user capability entry. +#[derive(Debug)] +pub(crate) struct WebRuntimeProfile { + /// Canonical host that owns this profile. + pub(crate) host: String, + /// Stable public destination tuple supplied to relay routing. + pub(crate) public_addr: SocketAddr, + /// Exact access user authenticated by logical streams. + pub(crate) user: String, + /// Client secret representation and inner protocol policy. + pub(crate) secret_mode: WebSecretMode, + /// HMAC-derived bridge capability. + pub(crate) capability: [u8; 32], + /// Per-profile live session ceiling. + pub(crate) max_sessions: usize, + /// Per-profile live logical-stream ceiling. + pub(crate) max_streams: usize, + /// Per-session live relay-task ceiling. + pub(crate) max_streams_per_session: usize, +} + +/// Runtime-ready ordinary-site fallback. +#[derive(Debug)] +pub(crate) enum WebRuntimeDecoy { + HttpUpstream { + addr: SocketAddr, + authority: String, + }, + StaticDirectory(Arc), +} + +/// Immutable bounded static-site snapshot. +#[derive(Debug)] +pub(crate) struct WebStaticSite { + /// Canonical URL-path to immutable response asset mapping. + pub(crate) assets: BTreeMap, + /// Configured root index file name. + pub(crate) index: String, +} + +/// One immutable static response body and metadata. +#[derive(Debug)] +pub(crate) struct WebStaticAsset { + /// Immutable response body retained by the runtime snapshot. + pub(crate) body: Bytes, + /// Extension-derived static content type. + pub(crate) content_type: &'static str, + /// Strong SHA-256 entity tag. + pub(crate) etag: String, +} + +fn default_web_static_index() -> String { + "index.html".to_string() +} + +macro_rules! usize_default { + ($name:ident, $value:expr) => { + fn $name() -> usize { + $value + } + }; +} + +macro_rules! u32_default { + ($name:ident, $value:expr) => { + fn $name() -> u32 { + $value + } + }; +} + +macro_rules! u64_default { + ($name:ident, $value:expr) => { + fn $name() -> u64 { + $value + } + }; +} + +usize_default!(default_web_max_header_bytes, 16 * 1024); +usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024); +usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024); +usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024); +usize_default!(default_web_max_frames_per_body, 4096); +usize_default!(default_web_max_http_connections, 1024); +usize_default!(default_web_max_http_handlers, 512); +usize_default!(default_web_max_body_readers, 32); +usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024); +usize_default!(default_web_max_sessions_global, 128); +usize_default!(default_web_max_sessions_per_ip, 16); +usize_default!(default_web_max_streams_per_session, 128); +usize_default!(default_web_max_streams_global, 4096); +usize_default!(default_web_max_stream_handshakes, 256); +usize_default!(default_web_max_tombstones, 4096); +usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024); +usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024); +usize_default!(default_web_pending_items_per_session, 16 * 1024); +usize_default!(default_web_pending_items_global, 256 * 1024); +usize_default!(default_web_control_bytes_per_session, 256 * 1024); +usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024); +usize_default!(default_web_max_bootstraps_global, 512); +usize_default!(default_web_max_bootstraps_per_ip, 64); +usize_default!(default_web_max_vhosts, 8); +usize_default!(default_web_max_profiles, 32); +usize_default!(default_web_max_static_files, 4096); +usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024); +usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024); +usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024); +u32_default!(default_web_new_bootstraps_per_minute, 1200); +u32_default!(default_web_new_bootstraps_burst, 256); +u32_default!(default_web_new_sessions_per_minute, 600); +u32_default!(default_web_new_sessions_burst, 128); +u32_default!(default_web_new_streams_per_minute, 6000); +u32_default!(default_web_new_streams_burst, 512); +u64_default!(default_web_header_timeout_secs, 10); +u64_default!(default_web_body_timeout_secs, 30); +u64_default!(default_web_stream_handshake_timeout_secs, 10); +u64_default!(default_web_long_poll_timeout_secs, 25); +u64_default!(default_web_bootstrap_lifetime_secs, 120); +u64_default!(default_web_reconnect_grace_secs, 120); +u64_default!(default_web_http_idle_secs, 75); +u64_default!(default_web_shutdown_secs, 15); +u64_default!(default_web_decoy_header_timeout_secs, 30); diff --git a/src/maestro/generation.rs b/src/maestro/generation.rs index 70ca21b..6af04e7 100644 --- a/src/maestro/generation.rs +++ b/src/maestro/generation.rs @@ -14,6 +14,7 @@ use crate::ip_tracker::UserIpTracker; use crate::proxy::route_mode::RelayRouteMode; use crate::proxy::route_mode::RouteRuntimeController; use crate::proxy::shared_state::ProxySharedState; +use crate::proxy::authenticated::ClientRuntimeDeps; use crate::stats::beobachten::BeobachtenStore; use crate::stats::{ReplayChecker, Stats}; use crate::stream::BufferPool; @@ -227,6 +228,22 @@ impl RuntimeGeneration { self.me_pool_runtime.read().await.clone() } + /// Pins all dependencies required by a client stream without retaining the generation. + pub(crate) fn client_runtime_deps(&self) -> ClientRuntimeDeps { + ClientRuntimeDeps { + config: self.config(), + stats: Arc::clone(&self.stats), + upstream_manager: Arc::clone(&self.upstream_manager), + buffer_pool: Arc::clone(&self.buffer_pool), + rng: Arc::clone(&self.rng), + me_pool: self.me_pool.clone(), + me_pool_runtime: Some(Arc::clone(&self.me_pool_runtime)), + route_runtime: Arc::clone(&self.route_runtime), + ip_tracker: Arc::clone(&self.ip_tracker), + shared: Arc::clone(&self.proxy_shared), + } + } + /// Registers a session only while admission remains open. pub(crate) fn spawn_session(&self, future: F) -> bool where @@ -287,7 +304,7 @@ impl RuntimeGeneration { #[cfg(test)] /// Builds a lightweight runtime generation without network startup tasks. -pub(super) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc { +pub(crate) fn test_runtime_generation(id: u64, config: ProxyConfig) -> Arc { let (config_tx, config_rx) = watch::channel(Arc::new(config.clone())); let (_admission_tx, admission_rx) = watch::channel(true); let stats = Arc::new(Stats::new()); diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index ba93792..9a50f16 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -607,6 +607,46 @@ pub(crate) fn print_proxy_links(host: &str, port: u16, config: &ProxyConfig) { } } +/// Prints WEB links only for profiles selected by the existing link policy. +pub(crate) fn print_web_proxy_links(config: &ProxyConfig) { + if !config.web.enabled || config.general.links.show.is_empty() { + return; + } + let Some(runtime) = config.web.runtime.as_ref() else { + return; + }; + let shown = config + .general + .links + .show + .resolve_users(&config.access.users); + let mut heading_printed = false; + for profile in &runtime.profiles { + if !shown.iter().any(|user| user.as_str() == profile.user) { + continue; + } + if !heading_printed { + print_maestro_line("WEB proxy links"); + heading_printed = true; + } + let Some(secret) = config.access.users.get(&profile.user) else { + continue; + }; + let prefix = match profile.secret_mode { + crate::config::WebSecretMode::Plain => "", + crate::config::WebSecretMode::Dd => "dd", + }; + print_maestro_line(format!( + "User: {} ({:?})", + profile.user, profile.secret_mode + )); + print_maestro_line(format!( + "WEB: tg://webproxy?server={}&secret={prefix}{secret}", + profile.host, + )); + } +} + pub(crate) async fn write_beobachten_snapshot(path: &str, payload: &str) -> std::io::Result<()> { if let Some(parent) = std::path::Path::new(path).parent() && !parent.as_os_str().is_empty() diff --git a/src/maestro/listeners/accept.rs b/src/maestro/listeners/accept.rs index 08621b8..bebb6dd 100644 --- a/src/maestro/listeners/accept.rs +++ b/src/maestro/listeners/accept.rs @@ -6,11 +6,13 @@ use tokio::net::{TcpListener, TcpStream}; use tokio::sync::OwnedSemaphorePermit; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; use tracing::{debug, error, info, warn}; -use crate::config::RstOnCloseMode; +use crate::config::{ListenerTransport, RstOnCloseMode}; use crate::proxy::ClientHandler; use crate::transport::socket::set_linger_zero; +use crate::web::manager::WebProcessRuntime; use super::bind::BoundTcpListener; use super::plan::ListenerBindSpec; @@ -19,11 +21,15 @@ use crate::maestro::helpers::{ expected_handshake_close_description, is_expected_handshake_eof, peer_close_description, }; +/// One bound listener and all connection tasks accepted through its lifecycle. pub(super) struct ListenerSlot { pub(super) spec: ListenerBindSpec, listener: Arc, cancellation: CancellationToken, task: Option>, + connections: TaskTracker, + web_runtime: Option>, + active_runtime: Arc>, } enum PermitWait { @@ -181,6 +187,8 @@ async fn run_accept_loop( listener: Arc, spec: ListenerBindSpec, active_runtime: Arc>, + web_runtime: Option>, + connections: TaskTracker, cancellation: CancellationToken, ) { loop { @@ -191,6 +199,26 @@ async fn run_accept_loop( }; match accepted { Ok((stream, peer_addr)) => { + if spec.transport == ListenerTransport::Web { + let Some(web_runtime) = web_runtime.as_ref() else { + error!(addr = %spec.addr, "WEB listener has no process runtime"); + return; + }; + let Some(connection_permit) = web_runtime.try_http_connection() else { + drop(stream); + continue; + }; + connections.spawn(crate::web::http::serve_connection( + stream, + peer_addr, + spec.web_client_ip_source, + Arc::clone(&spec.web_trusted_proxy_cidrs), + Arc::clone(web_runtime), + cancellation.clone(), + connection_permit, + )); + continue; + } let runtime = active_runtime.load_full(); if !*runtime.admission_rx.borrow() { debug!(peer = %peer_addr, "Admission gate closed, dropping connection"); @@ -232,12 +260,16 @@ impl ListenerSlot { pub(super) fn start( bound: BoundTcpListener, active_runtime: Arc>, + web_runtime: Option>, ) -> Self { let cancellation = CancellationToken::new(); + let connections = TaskTracker::new(); let task = tokio::spawn(run_accept_loop( bound.listener.clone(), bound.spec.clone(), - active_runtime, + active_runtime.clone(), + web_runtime.clone(), + connections.clone(), cancellation.clone(), )); Self { @@ -245,6 +277,9 @@ impl ListenerSlot { listener: bound.listener, cancellation, task: Some(task), + connections, + web_runtime, + active_runtime, } } @@ -255,15 +290,36 @@ impl ListenerSlot { format!("listener {} task failed: {error_value}", self.spec.addr) })?; } + self.connections.close(); + let connection_stop_timeout = Duration::from_secs( + self.active_runtime + .load() + .config() + .web + .timeouts + .shutdown_secs, + ); + tokio::time::timeout(connection_stop_timeout, self.connections.wait()) + .await + .map_err(|_| { + format!( + "listener {} connection shutdown timed out", + self.spec.addr + ) + })?; Ok(()) } pub(super) fn restart(&mut self, active_runtime: Arc>) { + self.active_runtime = active_runtime.clone(); self.cancellation = CancellationToken::new(); + self.connections = TaskTracker::new(); self.task = Some(tokio::spawn(run_accept_loop( self.listener.clone(), self.spec.clone(), active_runtime, + self.web_runtime.clone(), + self.connections.clone(), self.cancellation.clone(), ))); } diff --git a/src/maestro/listeners/bind.rs b/src/maestro/listeners/bind.rs index 4b2ba93..4efb527 100644 --- a/src/maestro/listeners/bind.rs +++ b/src/maestro/listeners/bind.rs @@ -8,13 +8,13 @@ use tokio::net::TcpListener; use tokio::net::UnixListener; use tracing::{error, info, warn}; -use crate::config::ProxyConfig; +use crate::config::{ListenerTransport, ProxyConfig}; use crate::startup::{COMPONENT_LISTENERS_BIND, StartupTracker}; use crate::transport::find_listener_processes; use crate::transport::socket::{activate_listener_socket, bind_listener_socket}; use super::plan::{ListenerBindSpec, listener_bind_plan}; -use crate::maestro::helpers::print_proxy_links; +use crate::maestro::helpers::{print_proxy_links, print_web_proxy_links}; /// Owns sockets bound before process accept loops start. pub(crate) struct BoundListeners { @@ -57,7 +57,8 @@ fn default_link_port(config: &ProxyConfig) -> u16 { config .server .listeners - .first() + .iter() + .find(|listener| listener.transport == ListenerTransport::Mtproxy) .and_then(|listener| listener.port) .unwrap_or(config.server.port) } @@ -110,7 +111,7 @@ impl PreparedTcpListener { } fn log_listener_profile(spec: &ListenerBindSpec) { - info!(addr = %spec.addr, "Listening on TCP endpoint"); + info!(addr = %spec.addr, transport = ?spec.transport, "Listening on TCP endpoint"); if let Some(client_mss) = spec.options.client_mss { info!( addr = %spec.addr, @@ -135,7 +136,11 @@ fn print_configured_links( detected_ip_v4: Option, detected_ip_v6: Option, ) { + print_web_proxy_links(config); for listener in &config.server.listeners { + if listener.transport != ListenerTransport::Mtproxy { + continue; + } let port = listener.port.unwrap_or(config.server.port); let addr = SocketAddr::new(listener.ip, port); if !plan.contains_key(&addr) || config.general.links.public_host.is_some() { @@ -160,7 +165,12 @@ fn print_configured_links( } } - if config.general.links.show.is_empty() || config.general.links.public_host.is_none() { + if config.general.links.show.is_empty() + || config.general.links.public_host.is_none() + || !plan + .values() + .any(|spec| spec.transport == ListenerTransport::Mtproxy) + { return; } let host = config diff --git a/src/maestro/listeners/control.rs b/src/maestro/listeners/control.rs index 7837bbe..3bce116 100644 --- a/src/maestro/listeners/control.rs +++ b/src/maestro/listeners/control.rs @@ -5,11 +5,13 @@ use std::sync::Arc; use arc_swap::ArcSwap; use crate::config::ProxyConfig; +use crate::config::ListenerTransport; use crate::maestro::generation::RuntimeGeneration; use super::accept::ListenerSlot; use super::bind::{BoundListeners, BoundTcpListener, PreparedTcpListener, prepare_listener}; use super::plan::{ListenerBindSpec, listener_bind_plan}; +use crate::web::manager::WebProcessRuntime; #[cfg(unix)] use super::unix::UnixAcceptHandle; @@ -17,16 +19,19 @@ use super::unix::UnixAcceptHandle; pub(crate) struct ListenerManager { active_runtime: Arc>, slots: BTreeMap, + web_runtime: Option>, #[cfg(unix)] unix: Option, } +/// Socket changes prepared without activating or stopping accept loops. pub(crate) struct PreparedListenerTransition { target_specs: BTreeMap, additions: Vec, removals: Vec, } +/// Activated additions and stopped removals awaiting runtime publication. pub(crate) struct PendingListenerTransition { target_specs: BTreeMap, additions: Vec, @@ -39,10 +44,22 @@ impl ListenerManager { bound: BoundListeners, active_runtime: Arc>, ) -> Self { + let has_web = bound + .listeners + .iter() + .any(|listener| listener.spec.transport == ListenerTransport::Web); + let web_runtime = has_web.then(|| WebProcessRuntime::start(active_runtime.clone())); let mut slots = BTreeMap::new(); for listener in bound.listeners { let addr = listener.spec.addr; - slots.insert(addr, ListenerSlot::start(listener, active_runtime.clone())); + slots.insert( + addr, + ListenerSlot::start( + listener, + active_runtime.clone(), + web_runtime.clone(), + ), + ); } #[cfg(unix)] let unix = bound @@ -51,6 +68,7 @@ impl ListenerManager { Self { active_runtime, slots, + web_runtime, #[cfg(unix)] unix, } @@ -61,6 +79,7 @@ impl ListenerManager { Self { active_runtime, slots: BTreeMap::new(), + web_runtime: None, #[cfg(unix)] unix: None, } @@ -72,6 +91,20 @@ impl ListenerManager { desired: &ProxyConfig, ) -> Result, String> { let target_specs = listener_bind_plan(desired)?; + let web_inventory_changed = self + .slots + .iter() + .filter(|(_, slot)| slot.spec.transport == ListenerTransport::Web) + .map(|(addr, slot)| (*addr, slot.spec.clone())) + .collect::>() + != target_specs + .iter() + .filter(|(_, spec)| spec.transport == ListenerTransport::Web) + .map(|(addr, spec)| (*addr, spec.clone())) + .collect::>(); + if web_inventory_changed { + return Err("WEB listener inventory is process-owned; process restart required".to_string()); + } let current_addresses: BTreeSet<_> = self.slots.keys().copied().collect(); let target_addresses: BTreeSet<_> = target_specs.keys().copied().collect(); if current_addresses == target_addresses @@ -164,7 +197,11 @@ impl ListenerManager { let addr = listener.spec.addr; self.slots.insert( addr, - ListenerSlot::start(listener, self.active_runtime.clone()), + ListenerSlot::start( + listener, + self.active_runtime.clone(), + self.web_runtime.clone(), + ), ); } debug_assert_eq!( @@ -191,6 +228,9 @@ impl ListenerManager { errors.push(error_value); } self.slots.clear(); + if let Some(web_runtime) = self.web_runtime.take() { + web_runtime.shutdown().await; + } #[cfg(unix)] { self.unix = None; @@ -214,6 +254,7 @@ mod tests { fn listener_config(addr: SocketAddr) -> ListenerConfig { ListenerConfig { ip: addr.ip(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(addr.port()), client_mss: None, synlimit: SynLimitMode::Off, @@ -229,6 +270,8 @@ mod tests { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), } } @@ -237,12 +280,15 @@ mod tests { let addr = listener.local_addr().unwrap(); let spec = ListenerBindSpec { addr, + transport: crate::config::ListenerTransport::Mtproxy, options: ListenOptions { reuse_port: false, ..Default::default() }, proxy_protocol: false, tls_response_fragment_size: None, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Arc::from([]), }; ( BoundTcpListener { diff --git a/src/maestro/listeners/plan.rs b/src/maestro/listeners/plan.rs index df905f9..46fc130 100644 --- a/src/maestro/listeners/plan.rs +++ b/src/maestro/listeners/plan.rs @@ -1,7 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddr; +use std::sync::Arc; -use crate::config::{ProxyConfig, ServerConfig, SynLimitMode}; +use crate::config::{ + ListenerTransport, ProxyConfig, ServerConfig, SynLimitMode, WebClientIpSource, +}; use crate::transport::ListenOptions; use super::tcp_mss_runtime_profile; @@ -10,9 +13,12 @@ use super::tcp_mss_runtime_profile; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct ListenerBindSpec { pub(super) addr: SocketAddr, + pub(super) transport: ListenerTransport, pub(super) options: ListenOptions, pub(super) proxy_protocol: bool, pub(super) tls_response_fragment_size: Option, + pub(super) web_client_ip_source: WebClientIpSource, + pub(super) web_trusted_proxy_cidrs: Arc<[ipnetwork::IpNetwork]>, } fn listener_port_or_legacy(listener: &crate::config::ListenerConfig, server: &ServerConfig) -> u16 { @@ -40,16 +46,24 @@ pub(crate) fn listener_bind_plan( if addr.is_ipv6() && config.network.ipv6 == Some(false) { continue; } - let configured_client_mss = listener - .effective_client_mss(&config.server) - .map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))?; + let configured_client_mss = if listener.transport == ListenerTransport::Web { + None + } else { + listener + .effective_client_mss(&config.server) + .map_err(|error| format!("invalid client MSS for listener {addr}: {error}"))? + }; + let listener_bulk_mss = (listener.transport != ListenerTransport::Web) + .then_some(bulk_client_mss) + .flatten(); #[cfg(target_os = "linux")] let (client_mss, tls_response_fragment_size) = - tcp_mss_runtime_profile(configured_client_mss, bulk_client_mss); + tcp_mss_runtime_profile(configured_client_mss, listener_bulk_mss); #[cfg(not(target_os = "linux"))] let (client_mss, tls_response_fragment_size) = (configured_client_mss, None); let spec = ListenerBindSpec { addr, + transport: listener.transport, options: ListenOptions { reuse_port: listener.reuse_allow, ipv6_only: listener.ip.is_ipv6(), @@ -61,6 +75,8 @@ pub(crate) fn listener_bind_plan( .proxy_protocol .unwrap_or(config.server.proxy_protocol), tls_response_fragment_size, + web_client_ip_source: listener.web_client_ip_source, + web_trusted_proxy_cidrs: Arc::from(listener.web_trusted_proxy_cidrs.clone()), }; if plan.insert(addr, spec).is_some() { return Err(format!("duplicate effective listener endpoint: {addr}")); @@ -80,6 +96,23 @@ fn any_synlimit_enabled(config: &ProxyConfig) -> bool { /// Returns whether an endpoint-only change can use coordinated process rebind. pub(crate) fn listener_rebind_supported(old: &ProxyConfig, desired: &ProxyConfig) -> bool { + let Ok(old_plan) = listener_bind_plan(old) else { + return false; + }; + let Ok(desired_plan) = listener_bind_plan(desired) else { + return false; + }; + let old_web = old_plan + .iter() + .filter(|(_, spec)| spec.transport == ListenerTransport::Web) + .collect::>(); + let desired_web = desired_plan + .iter() + .filter(|(_, spec)| spec.transport == ListenerTransport::Web) + .collect::>(); + if old_web != desired_web { + return false; + } if any_synlimit_enabled(old) || any_synlimit_enabled(desired) { return false; } @@ -107,6 +140,7 @@ mod tests { fn listener(ip: &str, port: u16) -> ListenerConfig { ListenerConfig { ip: ip.parse().unwrap(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(port), client_mss: None, synlimit: SynLimitMode::Off, @@ -122,6 +156,8 @@ mod tests { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), } } diff --git a/src/maestro/mod.rs b/src/maestro/mod.rs index 7b1216f..0afbed3 100644 --- a/src/maestro/mod.rs +++ b/src/maestro/mod.rs @@ -131,6 +131,7 @@ mod tests { fn listener_with_synlimit(synlimit: SynLimitMode) -> ListenerConfig { ListenerConfig { ip: "127.0.0.1".parse().unwrap(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(443), client_mss: None, synlimit, @@ -146,6 +147,8 @@ mod tests { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), } } diff --git a/src/maestro/runtime_build.rs b/src/maestro/runtime_build.rs index e14d2c6..ad01d2c 100644 --- a/src/maestro/runtime_build.rs +++ b/src/maestro/runtime_build.rs @@ -406,6 +406,16 @@ pub(crate) fn resolve_reload_config( fields.push("logging".to_string()); effective.logging = old.logging.clone(); } + if serde_json::to_value(&old.web.limits).ok() + != serde_json::to_value(&desired.web.limits).ok() + { + fields.push("web.limits".to_string()); + effective.web.limits = old.web.limits.clone(); + if effective.rebuild_runtime_web().is_err() { + fields.push("web".to_string()); + effective.web = old.web.clone(); + } + } let runtime_changed = !configs_equal(old, &effective); ResolvedReloadConfig { effective, diff --git a/src/maestro/runtime_build_tests.rs b/src/maestro/runtime_build_tests.rs index 988491b..af381bd 100644 --- a/src/maestro/runtime_build_tests.rs +++ b/src/maestro/runtime_build_tests.rs @@ -3,6 +3,7 @@ use super::*; fn test_listener(port: u16) -> crate::config::ListenerConfig { crate::config::ListenerConfig { ip: "127.0.0.1".parse().unwrap(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(port), client_mss: None, synlimit: crate::config::SynLimitMode::Off, @@ -18,6 +19,8 @@ fn test_listener(port: u16) -> crate::config::ListenerConfig { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), } } @@ -80,6 +83,7 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() { let mut old = ProxyConfig::default(); old.server.listeners.push(crate::config::ListenerConfig { ip: "0.0.0.0".parse().unwrap(), + transport: crate::config::ListenerTransport::Mtproxy, port: Some(443), client_mss: None, synlimit: crate::config::SynLimitMode::Off, @@ -95,6 +99,8 @@ fn listener_announcement_is_runtime_owned_when_bind_identity_is_stable() { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), }); let mut desired = old.clone(); desired.server.listeners[0].announce = Some("proxy.example".to_string()); @@ -143,6 +149,27 @@ fn runtime_only_change_does_not_require_process_rebind() { assert!(deferred_process_fields(&old, &new).is_empty()); } +#[test] +fn web_allocation_limits_are_deferred_until_restart() { + let mut old = ProxyConfig::default(); + old.rebuild_runtime_user_auth().unwrap(); + old.rebuild_runtime_web().unwrap(); + let mut desired = old.clone(); + desired.web.limits.max_sessions_global += 1; + + let resolved = resolve_reload_config(&old, &desired); + + assert_eq!( + resolved.deferred_process_fields, + vec!["web.limits".to_string()] + ); + assert_eq!( + resolved.effective.web.limits.max_sessions_global, + old.web.limits.max_sessions_global + ); + assert!(!resolved.runtime_changed); +} + #[test] fn strict_middle_proxy_requires_a_prepared_pool() { assert!(strict_middle_proxy_unavailable(true, false, false)); diff --git a/src/main.rs b/src/main.rs index 98c9fd6..c0453d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ mod synlimit_control; mod tls_front; mod transport; mod util; +mod web; fn main() -> std::result::Result<(), Box> { // Install rustls crypto provider early diff --git a/src/proxy/authenticated.rs b/src/proxy/authenticated.rs new file mode 100644 index 0000000..c79a5a6 --- /dev/null +++ b/src/proxy/authenticated.rs @@ -0,0 +1,325 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::RwLock; +use tracing::warn; + +use crate::config::ProxyConfig; +use crate::crypto::SecureRandom; +use crate::error::{ProxyError, Result}; +use crate::ip_tracker::UserIpTracker; +use crate::proxy::direct_relay::handle_via_direct_with_shared_and_conntrack; +use crate::proxy::handshake::HandshakeSuccess; +use crate::proxy::middle_relay::{ + handle_via_middle_proxy, handle_via_middle_proxy_with_conntrack, +}; +use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; +use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState}; +use crate::stats::Stats; +use crate::stream::{BufferPool, CryptoReader, CryptoWriter}; +use crate::transport::UpstreamManager; +use crate::transport::middle_proxy::MePool; + +/// Immutable dependency snapshot pinned by one authenticated client stream. +#[derive(Clone)] +pub(crate) struct ClientRuntimeDeps { + /// Immutable effective configuration pinned for this stream. + pub(crate) config: Arc, + /// Process statistics registry. + pub(crate) stats: Arc, + /// Direct Telegram upstream connector. + pub(crate) upstream_manager: Arc, + /// Shared relay buffer pool. + pub(crate) buffer_pool: Arc, + /// Process cryptographic random source. + pub(crate) rng: Arc, + /// Startup Middle-End pool, when immediately available. + pub(crate) me_pool: Option>, + /// Hot-swappable Middle-End pool holder. + pub(crate) me_pool_runtime: Option>>>>, + /// Route-mode controller shared by active generations. + pub(crate) route_runtime: Arc, + /// Per-user source-IP admission tracker. + pub(crate) ip_tracker: Arc, + /// Process-shared admission and relay coordination state. + pub(crate) shared: Arc, +} + +/// Runs admission and relay after a successful MTProxy handshake. +pub(crate) async fn run_authenticated( + client_reader: CryptoReader, + client_writer: CryptoWriter, + success: HandshakeSuccess, + deps: ClientRuntimeDeps, + local_addr: SocketAddr, + peer_addr: SocketAddr, + conntrack_close_policy: ConntrackClosePolicy, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let user = success.user.clone(); + if !deps.shared.is_user_enabled(&user) { + warn!(user = %user, "Disabled user rejected"); + return Err(ProxyError::UserDisabled { user }); + } + + let user_reservation = acquire_user_connection_reservation( + &user, + &deps.config, + Arc::clone(&deps.stats), + peer_addr, + Arc::clone(&deps.ip_tracker), + ) + .await + .map_err(|error| { + warn!(user = %user, error = %error, "User admission check failed"); + error + })?; + + let route_snapshot = deps.route_runtime.snapshot(); + let session_id = deps.rng.u64(); + let user_session = deps.shared.register_user_session(&user, session_id); + let session_cancel = user_session.token(); + let selected_me_pool = if deps.config.general.use_middle_proxy + && matches!(route_snapshot.mode, RelayRouteMode::Middle) + { + if let Some(pool) = &deps.me_pool { + Some(Arc::clone(pool)) + } else if let Some(pool_runtime) = &deps.me_pool_runtime { + pool_runtime.read().await.clone() + } else { + None + } + } else { + None + }; + + let relay_result = if deps.config.general.use_middle_proxy + && matches!(route_snapshot.mode, RelayRouteMode::Middle) + { + if let Some(pool) = selected_me_pool { + if conntrack_close_policy == ConntrackClosePolicy::Publish { + handle_via_middle_proxy( + client_reader, + client_writer, + success, + pool, + Arc::clone(&deps.stats), + Arc::clone(&deps.config), + Arc::clone(&deps.buffer_pool), + local_addr, + Arc::clone(&deps.rng), + deps.route_runtime.subscribe(), + route_snapshot, + session_id, + session_cancel.clone(), + Arc::clone(&deps.shared), + ) + .await + } else { + handle_via_middle_proxy_with_conntrack( + client_reader, + client_writer, + success, + pool, + Arc::clone(&deps.stats), + Arc::clone(&deps.config), + Arc::clone(&deps.buffer_pool), + local_addr, + Arc::clone(&deps.rng), + deps.route_runtime.subscribe(), + route_snapshot, + session_id, + session_cancel.clone(), + Arc::clone(&deps.shared), + ConntrackClosePolicy::Suppress, + ) + .await + } + } else { + warn!("use_middle_proxy=true but MePool not initialized, falling back to direct"); + run_direct( + client_reader, + client_writer, + success, + &deps, + route_snapshot, + session_id, + local_addr, + session_cancel.clone(), + conntrack_close_policy, + ) + .await + } + } else { + run_direct( + client_reader, + client_writer, + success, + &deps, + route_snapshot, + session_id, + local_addr, + session_cancel, + conntrack_close_policy, + ) + .await + }; + user_reservation.release().await; + relay_result +} + +async fn run_direct( + client_reader: CryptoReader, + client_writer: CryptoWriter, + success: HandshakeSuccess, + deps: &ClientRuntimeDeps, + route_snapshot: crate::proxy::route_mode::RouteCutoverState, + session_id: u64, + local_addr: SocketAddr, + session_cancel: tokio_util::sync::CancellationToken, + conntrack_close_policy: ConntrackClosePolicy, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + handle_via_direct_with_shared_and_conntrack( + client_reader, + client_writer, + success, + Arc::clone(&deps.upstream_manager), + Arc::clone(&deps.stats), + Arc::clone(&deps.config), + Arc::clone(&deps.buffer_pool), + Arc::clone(&deps.rng), + deps.route_runtime.subscribe(), + route_snapshot, + session_id, + local_addr, + session_cancel, + Arc::clone(&deps.shared), + conntrack_close_policy, + ) + .await +} + +#[must_use = "the reservation owns user and IP admission until release or drop"] +/// Owns one authenticated user's connection and source-IP admission slots. +pub(crate) struct UserConnectionReservation { + stats: Arc, + ip_tracker: Arc, + user: String, + ip: IpAddr, + tracks_ip: bool, + active: bool, +} + +impl UserConnectionReservation { + /// Creates an active reservation after both admission counters were acquired. + pub(crate) fn new( + stats: Arc, + ip_tracker: Arc, + user: String, + ip: IpAddr, + tracks_ip: bool, + ) -> Self { + Self { + stats, + ip_tracker, + user, + ip, + tracks_ip, + active: true, + } + } + + /// Releases both admission counters through the asynchronous cleanup path. + pub(crate) async fn release(mut self) { + if !self.active { + return; + } + self.active = false; + if self.tracks_ip { + self.ip_tracker.remove_ip(&self.user, self.ip).await; + } + self.stats.decrement_user_curr_connects(&self.user); + } +} + +impl Drop for UserConnectionReservation { + fn drop(&mut self) { + if !self.active { + return; + } + self.active = false; + self.stats.increment_session_drop_fallback_total(); + self.stats.decrement_user_curr_connects(&self.user); + if self.tracks_ip { + self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip); + } + } +} + +/// Applies user quota, connection, and source-IP admission atomically. +pub(crate) async fn acquire_user_connection_reservation( + user: &str, + config: &ProxyConfig, + stats: Arc, + peer_addr: SocketAddr, + ip_tracker: Arc, +) -> Result { + if let Some(expiration) = config.access.user_expirations.get(user) + && chrono::Utc::now() > *expiration + { + return Err(ProxyError::UserExpired { + user: user.to_string(), + }); + } + if let Some(quota) = config.access.user_data_quota.get(user) + && stats.get_user_quota_used(user) >= *quota + { + return Err(ProxyError::DataQuotaExceeded { + user: user.to_string(), + }); + } + + let limit = config + .access + .user_max_tcp_conns + .get(user) + .copied() + .filter(|limit| *limit > 0) + .or((config.access.user_max_tcp_conns_global_each > 0) + .then_some(config.access.user_max_tcp_conns_global_each)) + .map(|value| value as u64); + if !stats.try_acquire_user_curr_connects(user, limit) { + return Err(ProxyError::ConnectionLimitExceeded { + user: user.to_string(), + }); + } + + if let Err(reason) = ip_tracker.check_and_add(user, peer_addr.ip()).await { + stats.decrement_user_curr_connects(user); + warn!( + user = %user, + ip = %peer_addr.ip(), + reason = %reason, + "IP limit exceeded" + ); + return Err(ProxyError::ConnectionLimitExceeded { + user: user.to_string(), + }); + } + + Ok(UserConnectionReservation::new( + stats, + ip_tracker, + user.to_string(), + peer_addr.ip(), + true, + )) +} diff --git a/src/proxy/client.rs b/src/proxy/client.rs index f4c5367..99c0b72 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -26,72 +26,6 @@ enum HandshakeOutcome { NeedsMasking(PostHandshakeFuture), } -#[must_use = "UserConnectionReservation must be kept alive to retain user/IP reservation until release or drop"] -struct UserConnectionReservation { - stats: Arc, - ip_tracker: Arc, - user: String, - ip: IpAddr, - tracks_ip: bool, - state: SessionReservationState, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum SessionReservationState { - Active, - Released, -} - -impl UserConnectionReservation { - fn new( - stats: Arc, - ip_tracker: Arc, - user: String, - ip: IpAddr, - tracks_ip: bool, - ) -> Self { - Self { - stats, - ip_tracker, - user, - ip, - tracks_ip, - state: SessionReservationState::Active, - } - } - - fn mark_released(&mut self) -> bool { - if self.state != SessionReservationState::Active { - return false; - } - self.state = SessionReservationState::Released; - true - } - - async fn release(mut self) { - if !self.mark_released() { - return; - } - if self.tracks_ip { - self.ip_tracker.remove_ip(&self.user, self.ip).await; - } - self.stats.decrement_user_curr_connects(&self.user); - } -} - -impl Drop for UserConnectionReservation { - fn drop(&mut self) { - if !self.mark_released() { - return; - } - self.stats.increment_session_drop_fallback_total(); - self.stats.decrement_user_curr_connects(&self.user); - if self.tracks_ip { - self.ip_tracker.enqueue_cleanup(self.user.clone(), self.ip); - } - } -} - use crate::config::ProxyConfig; use crate::crypto::SecureRandom; use crate::error::{HandshakeResult, ProxyError, Result, StreamError}; @@ -107,7 +41,11 @@ use crate::transport::middle_proxy::MePool; use crate::transport::socket::normalize_ip; use crate::transport::{UpstreamManager, configure_client_socket, parse_proxy_protocol}; -use crate::proxy::direct_relay::handle_via_direct_with_shared; +use crate::proxy::authenticated::{ClientRuntimeDeps, run_authenticated}; +#[cfg(test)] +use crate::proxy::authenticated::{ + UserConnectionReservation, acquire_user_connection_reservation, +}; use crate::proxy::handshake::{ HandshakeSuccess, TlsResponseWriteOptions, handle_mtproto_handshake_with_shared, handle_tls_handshake_with_shared, handle_tls_handshake_with_shared_and_options, @@ -115,9 +53,10 @@ use crate::proxy::handshake::{ #[cfg(test)] use crate::proxy::handshake::{handle_mtproto_handshake, handle_tls_handshake}; use crate::proxy::masking::handle_bad_client_with_shared; -use crate::proxy::middle_relay::handle_via_middle_proxy; -use crate::proxy::route_mode::{RelayRouteMode, RouteRuntimeController}; -use crate::proxy::shared_state::ProxySharedState; +use crate::proxy::route_mode::RouteRuntimeController; +#[cfg(test)] +use crate::proxy::route_mode::RelayRouteMode; +use crate::proxy::shared_state::{ConntrackClosePolicy, ProxySharedState}; fn beobachten_ttl(config: &ProxyConfig) -> Duration { const BEOBACHTEN_TTL_MAX_MINUTES: u64 = 24 * 60; @@ -1688,112 +1627,30 @@ impl RunningClientHandler { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - let user = success.user.clone(); - - if !shared.is_user_enabled(&user) { - warn!(user = %user, "Disabled user rejected"); - return Err(ProxyError::UserDisabled { user }); - } - - let user_limit_reservation = match Self::acquire_user_connection_reservation_static( - &user, - &config, - stats.clone(), - peer_addr, - ip_tracker, - ) - .await - { - Ok(reservation) => reservation, - Err(e) => { - warn!(user = %user, error = %e, "User admission check failed"); - return Err(e); - } - }; - - let route_snapshot = route_runtime.snapshot(); - let session_id = rng.u64(); - let _user_session = shared.register_user_session(&user, session_id); - let session_cancel = _user_session.token(); - let selected_me_pool = if config.general.use_middle_proxy - && matches!(route_snapshot.mode, RelayRouteMode::Middle) - { - if let Some(ref pool) = me_pool { - Some(pool.clone()) - } else if let Some(pool_runtime) = me_pool_runtime.as_ref() { - pool_runtime.read().await.clone() - } else { - None - } - } else { - None - }; - - let relay_result = if config.general.use_middle_proxy - && matches!(route_snapshot.mode, RelayRouteMode::Middle) - { - if let Some(pool) = selected_me_pool { - handle_via_middle_proxy( - client_reader, - client_writer, - success, - pool, - stats.clone(), - config, - buffer_pool, - local_addr, - rng, - route_runtime.subscribe(), - route_snapshot, - session_id, - session_cancel.clone(), - shared.clone(), - ) - .await - } else { - warn!("use_middle_proxy=true but MePool not initialized, falling back to direct"); - handle_via_direct_with_shared( - client_reader, - client_writer, - success, - upstream_manager, - stats.clone(), - config, - buffer_pool, - rng, - route_runtime.subscribe(), - route_snapshot, - session_id, - local_addr, - session_cancel.clone(), - shared.clone(), - ) - .await - } - } else { - // Direct mode (original behavior) - handle_via_direct_with_shared( - client_reader, - client_writer, - success, - upstream_manager, - stats.clone(), + run_authenticated( + client_reader, + client_writer, + success, + ClientRuntimeDeps { config, + stats, + upstream_manager, buffer_pool, rng, - route_runtime.subscribe(), - route_snapshot, - session_id, - local_addr, - session_cancel, - shared.clone(), - ) - .await - }; - user_limit_reservation.release().await; - relay_result + me_pool, + me_pool_runtime, + route_runtime, + ip_tracker, + shared, + }, + local_addr, + peer_addr, + ConntrackClosePolicy::Publish, + ) + .await } + #[cfg(test)] async fn acquire_user_connection_reservation_static( user: &str, config: &ProxyConfig, @@ -1801,60 +1658,7 @@ impl RunningClientHandler { peer_addr: SocketAddr, ip_tracker: Arc, ) -> Result { - if let Some(expiration) = config.access.user_expirations.get(user) - && chrono::Utc::now() > *expiration - { - return Err(ProxyError::UserExpired { - user: user.to_string(), - }); - } - - if let Some(quota) = config.access.user_data_quota.get(user) - && stats.get_user_quota_used(user) >= *quota - { - return Err(ProxyError::DataQuotaExceeded { - user: user.to_string(), - }); - } - - let limit = config - .access - .user_max_tcp_conns - .get(user) - .copied() - .filter(|limit| *limit > 0) - .or((config.access.user_max_tcp_conns_global_each > 0) - .then_some(config.access.user_max_tcp_conns_global_each)) - .map(|v| v as u64); - if !stats.try_acquire_user_curr_connects(user, limit) { - return Err(ProxyError::ConnectionLimitExceeded { - user: user.to_string(), - }); - } - - match ip_tracker.check_and_add(user, peer_addr.ip()).await { - Ok(()) => {} - Err(reason) => { - stats.decrement_user_curr_connects(user); - warn!( - user = %user, - ip = %peer_addr.ip(), - reason = %reason, - "IP limit exceeded" - ); - return Err(ProxyError::ConnectionLimitExceeded { - user: user.to_string(), - }); - } - } - - Ok(UserConnectionReservation::new( - stats, - ip_tracker, - user.to_string(), - peer_addr.ip(), - true, - )) + acquire_user_connection_reservation(user, config, stats, peer_addr, ip_tracker).await } #[cfg(test)] diff --git a/src/proxy/direct_relay.rs b/src/proxy/direct_relay.rs index 54a55b6..9215912 100644 --- a/src/proxy/direct_relay.rs +++ b/src/proxy/direct_relay.rs @@ -22,7 +22,8 @@ use crate::proxy::route_mode::{ RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay, }; use crate::proxy::shared_state::{ - ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState, + ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason, + ProxySharedState, }; use crate::stats::Stats; use crate::stream::{BufferPool, CryptoReader, CryptoWriter}; @@ -229,6 +230,7 @@ fn unknown_dc_test_lock() -> &'static Mutex<()> { } #[allow(dead_code)] +/// Runs Direct relay with standalone cancellation and shared-state defaults. pub(crate) async fn handle_via_direct( client_reader: CryptoReader, client_writer: CryptoWriter, @@ -265,7 +267,49 @@ where .await } +/// Runs Direct relay for a kernel-backed TCP client tuple. pub(crate) async fn handle_via_direct_with_shared( + client_reader: CryptoReader, + client_writer: CryptoWriter, + success: HandshakeSuccess, + upstream_manager: Arc, + stats: Arc, + config: Arc, + buffer_pool: Arc, + rng: Arc, + route_rx: watch::Receiver, + route_snapshot: RouteCutoverState, + session_id: u64, + local_addr: SocketAddr, + session_cancel: CancellationToken, + shared: Arc, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + handle_via_direct_with_shared_and_conntrack( + client_reader, + client_writer, + success, + upstream_manager, + stats, + config, + buffer_pool, + rng, + route_rx, + route_snapshot, + session_id, + local_addr, + session_cancel, + shared, + ConntrackClosePolicy::Publish, + ) + .await +} + +/// Runs Direct relay with explicit kernel-conntrack close publication policy. +pub(crate) async fn handle_via_direct_with_shared_and_conntrack( client_reader: CryptoReader, client_writer: CryptoWriter, success: HandshakeSuccess, @@ -280,6 +324,7 @@ pub(crate) async fn handle_via_direct_with_shared( local_addr: SocketAddr, session_cancel: CancellationToken, shared: Arc, + conntrack_close_policy: ConntrackClosePolicy, ) -> Result<()> where R: AsyncRead + Unpin + Send + 'static, @@ -407,17 +452,19 @@ where pool_snapshot.allocated.saturating_sub(pool_snapshot.pooled), ); - let close_reason = classify_conntrack_close_reason(&relay_result); - let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent { - src: success.peer, - dst: local_addr, - reason: close_reason, - }); - if !matches!( - publish_result, - ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled - ) { - stats.increment_conntrack_close_event_drop_total(); + if conntrack_close_policy == ConntrackClosePolicy::Publish { + let close_reason = classify_conntrack_close_reason(&relay_result); + let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent { + src: success.peer, + dst: local_addr, + reason: close_reason, + }); + if !matches!( + publish_result, + ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled + ) { + stats.increment_conntrack_close_event_drop_total(); + } } relay_result diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 5e2b86d..12a54be 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -20,7 +20,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, info, trace, warn}; use zeroize::{Zeroize, Zeroizing}; -use crate::config::{ProxyConfig, UnknownSniAction}; +use crate::config::{ProxyConfig, UnknownSniAction, WebSecretMode}; use crate::crypto::{AesCtr, SecureRandom, sha256}; use crate::error::{HandshakeResult, ProxyError}; use crate::protocol::constants::*; @@ -58,6 +58,7 @@ pub(crate) use self::auth_probe::{AuthProbeSaturationState, AuthProbeState}; #[cfg(test)] pub use self::mtproto::handle_mtproto_handshake; pub use self::mtproto::handle_mtproto_handshake_with_shared; +pub(crate) use self::mtproto::handle_mtproto_handshake_for_web_user; #[allow(unused_imports)] pub use self::nonce::{encrypt_tg_nonce, encrypt_tg_nonce_with_ciphers, generate_tg_nonce}; pub use self::session::HandshakeSuccess; diff --git a/src/proxy/handshake/auth_candidates.rs b/src/proxy/handshake/auth_candidates.rs index 0db6d9f..0f6261b 100644 --- a/src/proxy/handshake/auth_candidates.rs +++ b/src/proxy/handshake/auth_candidates.rs @@ -11,6 +11,12 @@ pub(super) struct MtprotoCandidateValidation { pub(super) encryptor: AesCtr, } +#[derive(Clone, Copy)] +pub(super) enum MtprotoModePolicy { + Configured, + Web(WebSecretMode), +} + pub(super) fn sni_hint_hash(sni: &str) -> u64 { let mut hasher = DefaultHasher::new(); for byte in sni.bytes() { @@ -146,6 +152,7 @@ pub(super) fn validate_mtproto_secret_candidate( secret: &[u8; ACCESS_SECRET_BYTES], config: &ProxyConfig, is_tls: bool, + mode_policy: MtprotoModePolicy, ) -> Option { let mut dec_key_input = Zeroizing::new(Vec::with_capacity(PREKEY_LEN + secret.len())); dec_key_input.extend_from_slice(dec_prekey); @@ -163,7 +170,7 @@ pub(super) fn validate_mtproto_secret_candidate( decrypted[PROTO_TAG_POS + 3], ]; let proto_tag = ProtoTag::from_bytes(tag_bytes)?; - if !mode_enabled_for_proto(config, proto_tag, is_tls) { + if !mode_enabled_for_proto_with_policy(config, proto_tag, is_tls, mode_policy) { return None; } @@ -267,6 +274,28 @@ pub(super) fn mode_enabled_for_proto( proto_tag: ProtoTag, is_tls: bool, ) -> bool { + mode_enabled_for_proto_with_policy( + config, + proto_tag, + is_tls, + MtprotoModePolicy::Configured, + ) +} + +fn mode_enabled_for_proto_with_policy( + config: &ProxyConfig, + proto_tag: ProtoTag, + is_tls: bool, + policy: MtprotoModePolicy, +) -> bool { + if let MtprotoModePolicy::Web(secret_mode) = policy { + return match secret_mode { + WebSecretMode::Plain => { + matches!(proto_tag, ProtoTag::Intermediate | ProtoTag::Abridged) + } + WebSecretMode::Dd => matches!(proto_tag, ProtoTag::Secure), + }; + } match proto_tag { ProtoTag::Secure => { if is_tls { @@ -279,6 +308,46 @@ pub(super) fn mode_enabled_for_proto( } } +#[cfg(test)] +mod web_mode_tests { + use super::*; + + #[test] + fn web_secret_mode_isolates_inner_protocol_tags() { + let config = ProxyConfig::default(); + assert!(mode_enabled_for_proto_with_policy( + &config, + ProtoTag::Abridged, + false, + MtprotoModePolicy::Web(WebSecretMode::Plain), + )); + assert!(mode_enabled_for_proto_with_policy( + &config, + ProtoTag::Intermediate, + false, + MtprotoModePolicy::Web(WebSecretMode::Plain), + )); + assert!(!mode_enabled_for_proto_with_policy( + &config, + ProtoTag::Secure, + false, + MtprotoModePolicy::Web(WebSecretMode::Plain), + )); + assert!(mode_enabled_for_proto_with_policy( + &config, + ProtoTag::Secure, + false, + MtprotoModePolicy::Web(WebSecretMode::Dd), + )); + assert!(!mode_enabled_for_proto_with_policy( + &config, + ProtoTag::Intermediate, + false, + MtprotoModePolicy::Web(WebSecretMode::Dd), + )); + } +} + pub(super) fn decode_user_secrets_in( shared: &ProxySharedState, config: &ProxyConfig, diff --git a/src/proxy/handshake/mtproto.rs b/src/proxy/handshake/mtproto.rs index 3eea0ef..70fea18 100644 --- a/src/proxy/handshake/mtproto.rs +++ b/src/proxy/handshake/mtproto.rs @@ -1,6 +1,6 @@ use super::*; -/// Handle MTProto obfuscation handshake +/// Handles an MTProto obfuscation handshake with isolated test state. #[cfg(test)] pub async fn handle_mtproto_handshake( handshake: &[u8; HANDSHAKE_LEN], @@ -26,11 +26,14 @@ where replay_checker, is_tls, preferred_user, + None, + MtprotoModePolicy::Configured, shared.as_ref(), ) .await } +/// Handles an MTProto obfuscation handshake with process-shared defenses. pub async fn handle_mtproto_handshake_with_shared( handshake: &[u8; HANDSHAKE_LEN], reader: R, @@ -55,6 +58,40 @@ where replay_checker, is_tls, preferred_user, + None, + MtprotoModePolicy::Configured, + shared, + ) + .await +} + +/// Authenticates one WEB logical stream against exactly one user and secret mode. +pub(crate) async fn handle_mtproto_handshake_for_web_user( + handshake: &[u8; HANDSHAKE_LEN], + reader: R, + writer: W, + peer: SocketAddr, + config: &ProxyConfig, + replay_checker: &ReplayChecker, + exact_user: &str, + secret_mode: WebSecretMode, + shared: &ProxySharedState, +) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> +where + R: AsyncRead + Unpin + Send, + W: AsyncWrite + Unpin + Send, +{ + handle_mtproto_handshake_impl( + handshake, + reader, + writer, + peer, + config, + replay_checker, + false, + None, + Some(exact_user), + MtprotoModePolicy::Web(secret_mode), shared, ) .await @@ -69,6 +106,8 @@ async fn handle_mtproto_handshake_impl( replay_checker: &ReplayChecker, is_tls: bool, preferred_user: Option<&str>, + exact_user: Option<&str>, + mode_policy: MtprotoModePolicy, shared: &ProxySharedState, ) -> HandshakeResult<(CryptoReader, CryptoWriter, HandshakeSuccess), R, W> where @@ -113,8 +152,11 @@ where let sticky_ip_hint = sticky_hint_get_by_ip(shared, peer.ip()); let sticky_prefix_hint = sticky_hint_get_by_ip_prefix(shared, peer.ip()); let preferred_user_id = preferred_user.and_then(|user| snapshot.user_id_by_name(user)); - let has_hint = - sticky_ip_hint.is_some() || sticky_prefix_hint.is_some() || preferred_user_id.is_some(); + let exact_user_id = exact_user.and_then(|user| snapshot.user_id_by_name(user)); + let has_hint = sticky_ip_hint.is_some() + || sticky_prefix_hint.is_some() + || preferred_user_id.is_some() + || exact_user_id.is_some(); let overload = auth_probe_saturation_is_throttled_in(shared, Instant::now()); let candidate_budget = budget_for_validation(snapshot.entries().len(), overload, has_hint); @@ -145,6 +187,7 @@ where &entry.secret, config, is_tls, + mode_policy, ) { matched_user = entry.user.clone(); matched_user_id = Some($user_id); @@ -159,20 +202,20 @@ where }}; } - let mut matched = false; - if let Some(user_id) = sticky_ip_hint { + let mut matched = exact_user_id.is_some_and(|user_id| try_user_id!(user_id)); + if exact_user.is_none() && let Some(user_id) = sticky_ip_hint { matched = try_user_id!(user_id); } - if !matched && let Some(user_id) = preferred_user_id { + if exact_user.is_none() && !matched && let Some(user_id) = preferred_user_id { matched = try_user_id!(user_id); } - if !matched && let Some(user_id) = sticky_prefix_hint { + if exact_user.is_none() && !matched && let Some(user_id) = sticky_prefix_hint { matched = try_user_id!(user_id); } - if !matched && !budget_exhausted { + if exact_user.is_none() && !matched && !budget_exhausted { let ring = &shared.handshake.recent_user_ring; if !ring.is_empty() { let next_seq = shared @@ -197,7 +240,7 @@ where } } - if !matched && !budget_exhausted { + if exact_user.is_none() && !matched && !budget_exhausted { for idx in 0..snapshot.entries().len() { let Some(user_id) = u32::try_from(idx).ok() else { break; @@ -317,7 +360,16 @@ where success, )); } else { - let decoded_users = decode_user_secrets_in(shared, config, preferred_user); + let decoded_users = match exact_user { + Some(user) => config + .access + .users + .get(user) + .and_then(|secret| decode_user_secret(shared, user, secret)) + .map(|secret| vec![(user.to_string(), secret)]) + .unwrap_or_default(), + None => decode_user_secrets_in(shared, config, preferred_user), + }; let mut validation_checks = 0usize; for (user, secret) in decoded_users { @@ -337,6 +389,7 @@ where &secret_arr, config, is_tls, + mode_policy, ) else { continue; }; diff --git a/src/proxy/middle_relay.rs b/src/proxy/middle_relay.rs index f59bb6b..3049675 100644 --- a/src/proxy/middle_relay.rs +++ b/src/proxy/middle_relay.rs @@ -26,7 +26,8 @@ use crate::proxy::route_mode::{ RelayRouteMode, RouteCutoverState, affected_cutover_state, cutover_stagger_delay, }; use crate::proxy::shared_state::{ - ConntrackCloseEvent, ConntrackClosePublishResult, ConntrackCloseReason, ProxySharedState, + ConntrackCloseEvent, ConntrackClosePolicy, ConntrackClosePublishResult, ConntrackCloseReason, + ProxySharedState, }; use crate::proxy::traffic_limiter::{RateDirection, TrafficLease, next_refill_delay}; use crate::stats::{ @@ -44,7 +45,7 @@ mod session; pub(crate) use self::desync::DesyncDedupRotationState; pub(crate) use self::idle::{RelayIdleCandidateRegistry, note_global_relay_pressure}; -pub(crate) use self::session::handle_via_middle_proxy; +pub(crate) use self::session::handle_via_middle_proxy_with_conntrack; use self::c2me::{ C2MeCommand, acquire_c2me_payload_permit, c2me_queued_permit_budget, enqueue_c2me_command_in, @@ -91,6 +92,47 @@ pub(crate) use self::idle::{ set_relay_pressure_state_for_testing, }; +/// Runs Middle-End relay for a kernel-backed TCP client tuple. +pub(crate) async fn handle_via_middle_proxy( + crypto_reader: CryptoReader, + crypto_writer: CryptoWriter, + success: HandshakeSuccess, + me_pool: Arc, + stats: Arc, + config: Arc, + buffer_pool: Arc, + local_addr: SocketAddr, + rng: Arc, + route_rx: watch::Receiver, + route_snapshot: RouteCutoverState, + session_id: u64, + session_cancel: CancellationToken, + shared: Arc, +) -> Result<()> +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + handle_via_middle_proxy_with_conntrack( + crypto_reader, + crypto_writer, + success, + me_pool, + stats, + config, + buffer_pool, + local_addr, + rng, + route_rx, + route_snapshot, + session_id, + session_cancel, + shared, + ConntrackClosePolicy::Publish, + ) + .await +} + const DESYNC_DEDUP_WINDOW: Duration = Duration::from_secs(60); const DESYNC_DEDUP_MAX_ENTRIES: usize = 65_536; const DESYNC_FULL_CACHE_EMIT_MIN_INTERVAL: Duration = Duration::from_millis(1000); @@ -98,6 +140,7 @@ const DESYNC_ERROR_CLASS: &str = "frame_too_large_crypto_desync"; const C2ME_CHANNEL_CAPACITY_FALLBACK: usize = 128; const C2ME_SOFT_PRESSURE_MIN_FREE_SLOTS: usize = 64; const C2ME_SENDER_FAIRNESS_BUDGET: usize = 32; + const C2ME_QUEUED_BYTE_PERMIT_UNIT: usize = 16 * 1024; const C2ME_QUEUED_PERMITS_PER_SLOT: usize = 4; const RELAY_IDLE_IO_POLL_MAX: Duration = Duration::from_secs(1); diff --git a/src/proxy/middle_relay/session.rs b/src/proxy/middle_relay/session.rs index 371a5c5..47a731a 100644 --- a/src/proxy/middle_relay/session.rs +++ b/src/proxy/middle_relay/session.rs @@ -1,6 +1,7 @@ use super::*; -pub(crate) async fn handle_via_middle_proxy( +/// Runs Middle-End relay with explicit kernel-conntrack close publication policy. +pub(crate) async fn handle_via_middle_proxy_with_conntrack( mut crypto_reader: CryptoReader, crypto_writer: CryptoWriter, success: HandshakeSuccess, @@ -15,6 +16,7 @@ pub(crate) async fn handle_via_middle_proxy( session_id: u64, session_cancel: CancellationToken, shared: Arc, + conntrack_close_policy: ConntrackClosePolicy, ) -> Result<()> where R: AsyncRead + Unpin + Send + 'static, @@ -78,7 +80,7 @@ where return Err(ProxyError::RouteSwitched); } - // Per-user ad_tag from access.user_ad_tags; fallback to general.ad_tag (hot-reloadable) + // Prefer the hot-reloadable per-user ad tag over the global fallback. let user_tag: Option> = config .access .user_ad_tags @@ -785,7 +787,7 @@ where } }; - // When client closes, but ME channel stopped as unregistered - it isnt error + // A client-initiated close can unregister the ME channel before its writer exits. if client_closed && matches!(writer_result, Err(ProxyError::MiddleConnectionLost)) { writer_result = Ok(()); } @@ -808,17 +810,19 @@ where "ME relay cleanup" ); - let close_reason = classify_conntrack_close_reason(&result); - let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent { - src: peer, - dst: local_addr, - reason: close_reason, - }); - if !matches!( - publish_result, - ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled - ) { - stats.increment_conntrack_close_event_drop_total(); + if conntrack_close_policy == ConntrackClosePolicy::Publish { + let close_reason = classify_conntrack_close_reason(&result); + let publish_result = shared.publish_conntrack_close_event(ConntrackCloseEvent { + src: peer, + dst: local_addr, + reason: close_reason, + }); + if !matches!( + publish_result, + ConntrackClosePublishResult::Sent | ConntrackClosePublishResult::Disabled + ) { + stats.increment_conntrack_close_event_drop_total(); + } } clear_relay_idle_candidate_in(shared.as_ref(), conn_id); diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index 904fff8..b1b71bf 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -59,6 +59,8 @@ )] pub mod adaptive_buffers; +// Shared authenticated admission and relay orchestration for TCP and WEB streams. +pub(crate) mod authenticated; pub mod client; // Process-wide Direct relay copy-buffer ownership and pressure policy. pub(crate) mod direct_buffer_budget; diff --git a/src/proxy/shared_state.rs b/src/proxy/shared_state.rs index de897dd..6a8774c 100644 --- a/src/proxy/shared_state.rs +++ b/src/proxy/shared_state.rs @@ -41,6 +41,15 @@ pub(crate) enum ConntrackClosePublishResult { QueueClosed, } +/// Controls whether a relay tuple maps to a real kernel conntrack entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConntrackClosePolicy { + /// Publish closure for a tuple backed by an accepted kernel TCP flow. + Publish, + /// Suppress closure for a virtual transport tuple with no kernel flow. + Suppress, +} + pub(crate) struct HandshakeSharedState { pub(crate) auth_probe: DashMap, pub(crate) auth_probe_saturation: Mutex>, diff --git a/src/synlimit_control/model.rs b/src/synlimit_control/model.rs index c832281..ccadb46 100644 --- a/src/synlimit_control/model.rs +++ b/src/synlimit_control/model.rs @@ -260,6 +260,7 @@ mod tests { fn listener(ip: IpAddr, port: Option, synlimit: SynLimitMode) -> ListenerConfig { ListenerConfig { ip, + transport: crate::config::ListenerTransport::Mtproxy, port, client_mss: None, synlimit, @@ -275,6 +276,8 @@ mod tests { announce_ip: None, proxy_protocol: None, reuse_allow: false, + web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor, + web_trusted_proxy_cidrs: Vec::new(), } } diff --git a/src/web/bridge.rs b/src/web/bridge.rs new file mode 100644 index 0000000..575715b --- /dev/null +++ b/src/web/bridge.rs @@ -0,0 +1,240 @@ +use base64::Engine as _; + +use crate::crypto::SecureRandom; + +/// Browser security policy for the transient Telegram Desktop bridge page. +pub(crate) const PERMISSIONS_POLICY: &str = "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), web-share=(), xr-spatial-tracking=()"; + +/// Fully rendered bridge response and its per-response script policy. +pub(crate) struct BridgePage { + /// Complete transient HTML document. + pub(crate) body: String, + /// Nonce-bound policy that authorizes only the embedded bridge script. + pub(crate) content_security_policy: String, +} + +/// Renders the HTTPS-only WEB carrier bridge with a fresh CSP nonce. +pub(crate) fn render( + host: &str, + bootstrap: &str, + batch_limit: usize, + queue_limit: usize, + queue_items: usize, + rng: &SecureRandom, +) -> BridgePage { + let mut nonce = [0u8; 18]; + rng.fill(&mut nonce); + let nonce = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(nonce); + let body = DOCUMENT + .replace("__NONCE__", &nonce) + .replace("__HOST__", host) + .replace("__BOOTSTRAP__", bootstrap) + .replace("__BATCH_LIMIT__", &batch_limit.to_string()) + .replace("__QUEUE_LIMIT__", &queue_limit.to_string()) + .replace("__QUEUE_ITEMS__", &queue_items.to_string()); + BridgePage { + body, + content_security_policy: format!( + "default-src 'none'; base-uri 'none'; child-src 'none'; connect-src 'self' wss://{host}; font-src 'none'; form-action 'none'; frame-ancestors http://127.0.0.1:*; frame-src 'none'; img-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'nonce-{nonce}'; style-src 'none'; worker-src 'none'; sandbox allow-same-origin allow-scripts" + ), + } +} + +const DOCUMENT: &str = r##" + + + + +Connection + + + + + +"##; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendered_page_contains_no_template_markers_or_capability() { + let page = render( + "proxy.example.com", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + 2 * 1024 * 1024, + 32 * 1024 * 1024, + 16 * 1024, + &SecureRandom::new(), + ); + assert!(!page.body.contains("__")); + assert!(!page.body.contains("bridge=")); + assert!(page.body.contains("X-Up-Seq")); + assert!(page + .content_security_policy + .contains("frame-ancestors http://127.0.0.1:*")); + } +} diff --git a/src/web/frame.rs b/src/web/frame.rs new file mode 100644 index 0000000..0d1de8f --- /dev/null +++ b/src/web/frame.rs @@ -0,0 +1,272 @@ +use bytes::{BufMut, Bytes, BytesMut}; + +use crate::config::WebLimitsConfig; + +/// Fixed WEB frame header size. +pub(crate) const HEADER_BYTES: usize = 8; +/// Initial bidirectional stream credit. +pub(crate) const INITIAL_STREAM_WINDOW: u32 = 4 * 1024 * 1024; +/// Maximum data chunk emitted by the server. +pub(crate) const DATA_CHUNK_BYTES: usize = 64 * 1024; + +/// WEB frame type codes shared with Telegram Desktop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum FrameType { + /// Opens a logical MTProxy stream. + Open = 0x01, + /// Carries logical-stream payload bytes. + Data = 0x02, + /// Closes a logical stream. + Close = 0x03, + /// Returns consumed flow-control credit. + Window = 0x04, + /// Requests an application-level liveness response. + Ping = 0x05, + /// Answers application-level liveness traffic. + Pong = 0x06, + /// Starts one WEB carrier session. + Hello = 0x10, + /// Confirms WEB carrier session creation. + Welcome = 0x11, + /// Terminates a WEB carrier session. + Bye = 0x1f, +} + +impl FrameType { + fn parse(value: u8) -> Option { + match value { + 0x01 => Some(Self::Open), + 0x02 => Some(Self::Data), + 0x03 => Some(Self::Close), + 0x04 => Some(Self::Window), + 0x05 => Some(Self::Ping), + 0x06 => Some(Self::Pong), + 0x10 => Some(Self::Hello), + 0x11 => Some(Self::Welcome), + 0x1f => Some(Self::Bye), + _ => None, + } + } +} + +/// One parsed frame borrowing its payload from the HTTP request body. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Frame<'a> { + /// Parsed frame type. + pub(crate) frame_type: FrameType, + /// Logical 24-bit stream identifier. + pub(crate) stream_id: u32, + /// Borrowed frame payload. + pub(crate) payload: &'a [u8], +} + +/// Protocol parse or shape failure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FrameError { + /// A carrier body contained no frame. + EmptyBatch, + /// A carrier body exceeded the configured frame count. + TooManyFrames, + /// A frame header or payload was truncated. + Incomplete, + /// A frame payload exceeded its configured ceiling. + PayloadLimit, + /// The frame type code is not defined. + UnknownType, + /// A known frame violated direction-specific grammar. + InvalidShape, +} + +/// Parses and validates all frame boundaries without copying payloads. +pub(crate) fn parse_all<'a>( + input: &'a [u8], + limits: &WebLimitsConfig, +) -> std::result::Result>, FrameError> { + if input.is_empty() { + return Err(FrameError::EmptyBatch); + } + let mut remaining = input; + let mut frames = Vec::with_capacity(remaining.len().div_ceil(HEADER_BYTES).min(16)); + while !remaining.is_empty() { + if frames.len() >= limits.max_frames_per_body { + return Err(FrameError::TooManyFrames); + } + if remaining.len() < HEADER_BYTES { + return Err(FrameError::Incomplete); + } + let frame_type = FrameType::parse(remaining[0]).ok_or(FrameError::UnknownType)?; + let stream_id = u32::from(remaining[1]) << 16 + | u32::from(remaining[2]) << 8 + | u32::from(remaining[3]); + let payload_len = u32::from_be_bytes([ + remaining[4], + remaining[5], + remaining[6], + remaining[7], + ]) as usize; + if payload_len > limits.max_frame_payload_bytes { + return Err(FrameError::PayloadLimit); + } + let frame_len = HEADER_BYTES + .checked_add(payload_len) + .ok_or(FrameError::PayloadLimit)?; + if frame_len > remaining.len() { + return Err(FrameError::Incomplete); + } + frames.push(Frame { + frame_type, + stream_id, + payload: &remaining[HEADER_BYTES..frame_len], + }); + remaining = &remaining[frame_len..]; + } + Ok(frames) +} + +/// Enforces the client-to-server frame grammar. +pub(crate) fn validate_client_shape(frame: Frame<'_>) -> std::result::Result<(), FrameError> { + if frame.stream_id == 0 { + return if frame.frame_type == FrameType::Pong && frame.payload.len() <= 64 { + Ok(()) + } else { + Err(FrameError::InvalidShape) + }; + } + match frame.frame_type { + FrameType::Open | FrameType::Close if frame.payload.is_empty() => Ok(()), + FrameType::Data if !frame.payload.is_empty() => Ok(()), + FrameType::Window => window_amount(frame.payload).map(|_| ()), + _ => Err(FrameError::InvalidShape), + } +} + +/// Validates the exact first-session HELLO body. +pub(crate) fn validate_hello(input: &[u8], limits: &WebLimitsConfig) -> bool { + let Ok(frames) = parse_all(input, limits) else { + return false; + }; + frames.len() == 1 + && frames[0].frame_type == FrameType::Hello + && frames[0].stream_id == 0 + && frames[0].payload == [1] +} + +/// Encodes one complete WEB frame. +pub(crate) fn encode(frame_type: FrameType, stream_id: u32, payload: &[u8]) -> Bytes { + let mut output = BytesMut::with_capacity(HEADER_BYTES + payload.len()); + output.put_u8(frame_type as u8); + output.put_u8((stream_id >> 16) as u8); + output.put_u8((stream_id >> 8) as u8); + output.put_u8(stream_id as u8); + output.put_u32(payload.len() as u32); + output.extend_from_slice(payload); + output.freeze() +} + +/// Decodes a non-zero WINDOW delta. +pub(crate) fn window_amount(payload: &[u8]) -> std::result::Result { + let bytes: [u8; 4] = payload.try_into().map_err(|_| FrameError::InvalidShape)?; + let amount = u32::from_be_bytes(bytes); + (amount != 0) + .then_some(amount) + .ok_or(FrameError::InvalidShape) +} + +/// Encodes a WINDOW delta payload. +pub(crate) fn window_payload(amount: u32) -> [u8; 4] { + amount.to_be_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hello_and_welcome_match_reference_bytes() { + let limits = WebLimitsConfig::default(); + let hello = encode(FrameType::Hello, 0, &[1]); + assert_eq!(hello.as_ref(), &hex::decode("100000000000000101").unwrap()); + assert!(validate_hello(&hello, &limits)); + assert_eq!( + encode(FrameType::Welcome, 0, &[]).as_ref(), + &hex::decode("1100000000000000").unwrap() + ); + } + + #[test] + fn parser_rejects_excessive_payload_before_slicing() { + let limits = WebLimitsConfig { + max_frame_payload_bytes: 4, + ..WebLimitsConfig::default() + }; + let frame = encode(FrameType::Data, 1, &[0; 5]); + assert_eq!(parse_all(&frame, &limits), Err(FrameError::PayloadLimit)); + } + + #[test] + fn client_shape_rejects_control_types_on_stream_zero() { + let frame = Frame { + frame_type: FrameType::Ping, + stream_id: 0, + payload: &[], + }; + assert_eq!(validate_client_shape(frame), Err(FrameError::InvalidShape)); + } + + #[test] + fn stream_frames_match_client_reference_vectors() { + assert_eq!( + encode(FrameType::Open, 17, &[]).as_ref(), + &hex::decode("0100001100000000").unwrap() + ); + assert_eq!( + encode(FrameType::Data, 17, b"round trip").as_ref(), + &hex::decode("020000110000000a726f756e642074726970").unwrap() + ); + assert_eq!( + encode(FrameType::Window, 17, &10u32.to_be_bytes()).as_ref(), + &hex::decode("04000011000000040000000a").unwrap() + ); + assert_eq!( + encode(FrameType::Open, 0x00ff_ffff, &[]).as_ref(), + &hex::decode("01ffffff00000000").unwrap() + ); + } + + #[test] + fn parser_rejects_empty_truncated_and_excessive_batches() { + let mut limits = WebLimitsConfig::default(); + assert_eq!(parse_all(&[], &limits), Err(FrameError::EmptyBatch)); + assert_eq!( + parse_all(&hex::decode("0200000100000001").unwrap(), &limits), + Err(FrameError::Incomplete) + ); + limits.max_frames_per_body = 1; + let mut body = encode(FrameType::Pong, 0, &[]).to_vec(); + body.extend_from_slice(&encode(FrameType::Pong, 0, &[])); + assert_eq!(parse_all(&body, &limits), Err(FrameError::TooManyFrames)); + } + + #[test] + fn client_shape_rejects_empty_data_and_zero_window() { + let empty_data = Frame { + frame_type: FrameType::Data, + stream_id: 1, + payload: &[], + }; + let zero_window = Frame { + frame_type: FrameType::Window, + stream_id: 1, + payload: &[0; 4], + }; + assert_eq!( + validate_client_shape(empty_data), + Err(FrameError::InvalidShape) + ); + assert_eq!( + validate_client_shape(zero_window), + Err(FrameError::InvalidShape) + ); + } +} diff --git a/src/web/http.rs b/src/web/http.rs new file mode 100644 index 0000000..8b16df2 --- /dev/null +++ b/src/web/http.rs @@ -0,0 +1,511 @@ +use std::convert::Infallible; +use std::error::Error; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use http_body_util::combinators::UnsyncBoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::header::{self, HeaderName, HeaderValue}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use ipnetwork::IpNetwork; +use parking_lot::Mutex; +use tokio::net::TcpStream; +use tokio_util::sync::CancellationToken; + +use crate::config::{WebClientIpSource, WebRuntimeVhost}; +use crate::web::bridge; +use crate::web::frame::{self, FrameType}; +use crate::web::manager::{ManagerError, WebProcessRuntime}; + +// Response-body activity keeps connection idle accounting lifecycle-correct. +mod activity; +// Body collection retains allocation permits through request processing. +mod body; +// Decoy routing and upstream proxying are isolated from carrier authentication. +mod decoy; +// Canonical request parsing rejects ambiguous credentials before routing. +mod request; +#[cfg(test)] +mod tests; + +use decoy::serve_decoy; +use activity::{ActivityBody, RequestActivity}; +use body::{CollectBodyError, CollectedBody, collect_body}; +use request::{ + bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host, + canonical_u64_header, client_ip, match_profile, +}; + +type BoxError = Box; +type HttpBody = UnsyncBoxBody; +type HttpResponse = Response; + +const CREATE_BODY_LIMIT: usize = 64; +const TRANSPORT_PATHS: [&str; 3] = ["/api/v1/session", "/api/v1/up", "/api/v1/down"]; + +/// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator. +pub(crate) async fn serve_connection( + stream: TcpStream, + peer: SocketAddr, + client_ip_source: WebClientIpSource, + trusted_proxy_cidrs: Arc<[IpNetwork]>, + runtime: Arc, + cancellation: CancellationToken, + connection_permit: tokio::sync::OwnedSemaphorePermit, +) { + let config = runtime.active_generation().config(); + let max_header_bytes = config.web.limits.max_header_bytes; + let header_timeout = Duration::from_secs(config.web.timeouts.header_secs); + let idle_timeout = Duration::from_secs(config.web.timeouts.http_idle_secs); + let last_activity = Arc::new(Mutex::new(Instant::now())); + let service_last_activity = Arc::clone(&last_activity); + let service = service_fn(move |request| { + let runtime = Arc::clone(&runtime); + let trusted_proxy_cidrs = Arc::clone(&trusted_proxy_cidrs); + let last_activity = Arc::clone(&service_last_activity); + let client_ip_source = client_ip_source; + async move { + let activity = RequestActivity::begin(last_activity); + let response = if let Some(_handler_permit) = runtime.try_http_handler() { + handle_request( + request, + peer, + client_ip_source, + &trusted_proxy_cidrs, + runtime, + ) + .await + } else { + service_unavailable() + }; + let response = response.map(|body| { + ActivityBody::new(body, activity) + .boxed_unsync() + }); + Ok::<_, Infallible>(response) + } + }); + let connection = http1::Builder::new() + .timer(TokioTimer::new()) + .header_read_timeout(header_timeout) + .max_buf_size(max_header_bytes) + .keep_alive(true) + .serve_connection(TokioIo::new(stream), service); + tokio::pin!(connection); + let mut idle_check = tokio::time::interval((idle_timeout / 2).max(Duration::from_secs(1))); + idle_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + _ = cancellation.cancelled() => break, + _ = &mut connection => break, + _ = idle_check.tick() => { + if Instant::now().saturating_duration_since(*last_activity.lock()) + >= idle_timeout + { + break; + } + } + } + } + drop(connection_permit); +} + +async fn handle_request( + request: Request, + peer: SocketAddr, + client_ip_source: WebClientIpSource, + trusted_proxy_cidrs: &[IpNetwork], + runtime: Arc, +) -> HttpResponse { + let generation = runtime.active_generation(); + let config = generation.config(); + let Some(web_runtime) = config.web.runtime.as_ref() else { + return generic_not_found(); + }; + let Some(host) = canonical_request_host(&request) else { + return generic_not_found(); + }; + let Some(vhost) = web_runtime.vhosts.get(host).cloned() else { + return generic_not_found(); + }; + let path = request.uri().path(); + if TRANSPORT_PATHS.contains(&path) { + return handle_api( + request, + peer, + client_ip_source, + trusted_proxy_cidrs, + runtime, + vhost, + ) + .await; + } + if path == "/" && matches!(*request.method(), Method::GET | Method::HEAD) { + return handle_root( + request, + peer, + client_ip_source, + trusted_proxy_cidrs, + runtime, + vhost, + ) + .await; + } + serve_decoy(request, vhost, false, &runtime).await +} + +async fn handle_root( + mut request: Request, + peer: SocketAddr, + client_ip_source: WebClientIpSource, + trusted_proxy_cidrs: &[IpNetwork], + runtime: Arc, + vhost: Arc, +) -> HttpResponse { + let (candidate, canonical) = bridge_candidate(request.uri().query()); + let profile = match_profile(&vhost, &candidate); + let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else { + return serve_decoy(request, vhost, false, &runtime).await; + }; + let Some(client_ip) = client_ip( + &request, + peer, + client_ip_source, + trusted_proxy_cidrs, + ) else { + strip_query(&mut request); + return serve_decoy(request, vhost, true, &runtime).await; + }; + let Ok(bootstrap) = runtime.issue_bootstrap(profile, client_ip) else { + strip_query(&mut request); + return serve_decoy(request, vhost, true, &runtime).await; + }; + let generation = runtime.active_generation(); + let config = generation.config(); + let page = bridge::render( + &vhost.host, + &bootstrap, + config.web.limits.carrier_batch_bytes, + config.web.limits.pending_bytes_per_session, + config.web.limits.pending_items_per_session, + &generation.rng, + ); + let mut response = full_response(StatusCode::OK, Bytes::from(page.body)); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + insert_header( + &mut response, + header::CONTENT_SECURITY_POLICY, + &page.content_security_policy, + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response.headers_mut().insert( + HeaderName::from_static("x-dns-prefetch-control"), + HeaderValue::from_static("off"), + ); + insert_header( + &mut response, + HeaderName::from_static("permissions-policy"), + bridge::PERMISSIONS_POLICY, + ); + response +} + +async fn handle_api( + request: Request, + peer: SocketAddr, + client_ip_source: WebClientIpSource, + trusted_proxy_cidrs: &[IpNetwork], + runtime: Arc, + vhost: Arc, +) -> HttpResponse { + if request.uri().query().is_some() + || request.headers().contains_key(header::COOKIE) + || request.headers().contains_key("x-lane-id") + { + return serve_decoy(request, vhost, true, &runtime).await; + } + let Some(client_ip) = client_ip( + &request, + peer, + client_ip_source, + trusted_proxy_cidrs, + ) else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + let Some(token_hash) = bearer_token_hash(&request) else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + match request.uri().path() { + "/api/v1/session" => { + handle_session(request, runtime, vhost, token_hash, client_ip).await + } + "/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await, + "/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await, + _ => serve_decoy(request, vhost, true, &runtime).await, + } +} + +async fn handle_session( + request: Request, + runtime: Arc, + vhost: Arc, + token_hash: crate::web::manager::TokenHash, + client_ip: IpAddr, +) -> HttpResponse { + if request.method() == Method::DELETE { + if request.headers().contains_key(header::CONTENT_TYPE) { + return serve_decoy(request, vhost, true, &runtime).await; + } + let CollectedBody { + request, + body, + _body_budget, + } = match collect_body(request, &runtime, 1, true).await { + Ok(result) => result, + Err(CollectBodyError::Limit) => return service_unavailable(), + Err(CollectBodyError::Invalid(request)) => { + return serve_decoy(request, vhost, true, &runtime).await; + } + }; + if !body.is_empty() || runtime.close_token(token_hash, &vhost.host).is_err() { + return serve_decoy(request, vhost, true, &runtime).await; + } + return carrier_empty(StatusCode::NO_CONTENT); + } + if request.method() != Method::POST || !binary_content_type(&request) { + return serve_decoy(request, vhost, true, &runtime).await; + } + if !runtime.has_bootstrap(token_hash, &vhost.host) { + return serve_decoy(request, vhost, true, &runtime).await; + } + let CollectedBody { + request, + body, + _body_budget, + } = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await { + Ok(result) => result, + Err(CollectBodyError::Limit) => return service_unavailable(), + Err(CollectBodyError::Invalid(request)) => { + return serve_decoy(request, vhost, true, &runtime).await; + } + }; + match runtime.create_session(token_hash, &vhost.host, client_ip, &body) { + Ok(result) => { + let welcome = frame::encode(FrameType::Welcome, 0, &[]); + let mut response = full_response(StatusCode::OK, welcome); + carrier_headers(&mut response); + insert_header( + &mut response, + HeaderName::from_static("x-session-token"), + &result.token, + ); + response.headers_mut().insert( + HeaderName::from_static("x-carrier-mode"), + HeaderValue::from_static("https"), + ); + response.headers_mut().insert( + HeaderName::from_static("x-down-cursor"), + HeaderValue::from_static("0"), + ); + response + } + Err(ManagerError::Limit | ManagerError::Backpressure | ManagerError::Concurrent) => { + service_unavailable() + } + Err(_) => serve_decoy(request, vhost, true, &runtime).await, + } +} + +async fn handle_up( + request: Request, + runtime: Arc, + vhost: Arc, + token_hash: crate::web::manager::TokenHash, +) -> HttpResponse { + if request.method() != Method::POST || !binary_content_type(&request) { + return serve_decoy(request, vhost, true, &runtime).await; + } + let Some(sequence) = canonical_u64_header(&request, "x-up-seq").filter(|value| *value != 0) + else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + let Ok(session) = runtime.get_session(token_hash, &vhost.host) else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + let limit = runtime.active_generation().config().web.limits.max_body_bytes; + let CollectedBody { + request, + body, + _body_budget, + } = match collect_body(request, &runtime, limit, false).await { + Ok(result) => result, + Err(CollectBodyError::Limit) => return service_unavailable(), + Err(CollectBodyError::Invalid(request)) => { + return serve_decoy(request, vhost, true, &runtime).await; + } + }; + match session.process_up(sequence, &body) { + Ok(ack) => { + let mut response = carrier_empty(StatusCode::NO_CONTENT); + insert_header( + &mut response, + HeaderName::from_static("x-up-ack"), + &ack.to_string(), + ); + response + } + Err(ManagerError::Backpressure | ManagerError::Concurrent | ManagerError::Limit) => { + service_unavailable() + } + Err(_) => serve_decoy(request, vhost, true, &runtime).await, + } +} + +async fn handle_down( + request: Request, + runtime: Arc, + vhost: Arc, + token_hash: crate::web::manager::TokenHash, +) -> HttpResponse { + if request.method() != Method::POST || request.headers().contains_key(header::CONTENT_TYPE) { + return serve_decoy(request, vhost, true, &runtime).await; + } + let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + let Ok(session) = runtime.get_session(token_hash, &vhost.host) else { + return serve_decoy(request, vhost, true, &runtime).await; + }; + let CollectedBody { + request, + body, + _body_budget, + } = match collect_body(request, &runtime, 1, true).await { + Ok(result) => result, + Err(CollectBodyError::Limit) => return service_unavailable(), + Err(CollectBodyError::Invalid(request)) => { + return serve_decoy(request, vhost, true, &runtime).await; + } + }; + if !body.is_empty() { + return serve_decoy(request, vhost, true, &runtime).await; + } + match session.poll_down(cursor).await { + Ok(result) if result.body.is_empty() => { + let mut response = carrier_empty(StatusCode::NO_CONTENT); + insert_header( + &mut response, + HeaderName::from_static("x-down-cursor"), + &result.next_cursor.to_string(), + ); + response + } + Ok(result) => { + let mut response = full_response(StatusCode::OK, result.body); + carrier_headers(&mut response); + insert_header( + &mut response, + HeaderName::from_static("x-down-cursor"), + &result.next_cursor.to_string(), + ); + response + } + Err(ManagerError::Concurrent | ManagerError::Backpressure | ManagerError::Limit) => { + service_unavailable() + } + Err(_) => serve_decoy(request, vhost, true, &runtime).await, + } +} + +fn carrier_headers(response: &mut HttpResponse) { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); +} + +fn carrier_empty(status: StatusCode) -> HttpResponse { + let mut response = empty_response(status); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn service_unavailable() -> HttpResponse { + let mut response = carrier_empty(StatusCode::SERVICE_UNAVAILABLE); + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); + response +} + +fn bad_gateway() -> HttpResponse { + full_response( + StatusCode::BAD_GATEWAY, + Bytes::from_static(b"site unavailable\n"), + ) +} + +fn generic_not_found() -> HttpResponse { + full_response( + StatusCode::NOT_FOUND, + Bytes::from_static(b"not found\n"), + ) +} + +fn full_response(status: StatusCode, body: Bytes) -> HttpResponse { + let length = body.len(); + let body = Full::new(body) + .map_err(|never| -> BoxError { match never {} }) + .boxed_unsync(); + let mut response = Response::new(body); + *response.status_mut() = status; + insert_header( + &mut response, + header::CONTENT_LENGTH, + &length.to_string(), + ); + response +} + +fn empty_response(status: StatusCode) -> HttpResponse { + full_response(status, Bytes::new()) +} + +fn insert_header(response: &mut HttpResponse, name: HeaderName, value: &str) { + if let Ok(value) = HeaderValue::from_str(value) { + response.headers_mut().insert(name, value); + } +} + +fn strip_query(request: &mut Request) { + if request.uri().query().is_some() + && let Ok(uri) = request.uri().path().parse() + { + *request.uri_mut() = uri; + } +} diff --git a/src/web/http/activity.rs b/src/web/http/activity.rs new file mode 100644 index 0000000..f6afa28 --- /dev/null +++ b/src/web/http/activity.rs @@ -0,0 +1,69 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Instant; + +use bytes::Bytes; +use hyper::body::{Body, Frame, SizeHint}; +use parking_lot::Mutex; + +use super::{BoxError, HttpBody}; + +/// Request lifecycle guard that refreshes HTTP connection activity on completion. +pub(super) struct RequestActivity { + last_activity: Arc>, +} + +impl RequestActivity { + /// Starts activity accounting for one HTTP request. + pub(super) fn begin(last_activity: Arc>) -> Self { + *last_activity.lock() = Instant::now(); + Self { last_activity } + } +} + +impl Drop for RequestActivity { + fn drop(&mut self) { + *self.last_activity.lock() = Instant::now(); + } +} + +/// Response body wrapper that refreshes activity while downstream data progresses. +pub(super) struct ActivityBody { + inner: HttpBody, + activity: RequestActivity, +} + +impl ActivityBody { + /// Binds one response body to its request activity guard. + pub(super) fn new(inner: HttpBody, activity: RequestActivity) -> Self { + Self { + inner, + activity, + } + } +} + +impl Body for ActivityBody { + type Data = Bytes; + type Error = BoxError; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let result = Pin::new(&mut self.inner).poll_frame(context); + if result.is_ready() { + *self.activity.last_activity.lock() = Instant::now(); + } + result + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} diff --git a/src/web/http/body.rs b/src/web/http/body.rs new file mode 100644 index 0000000..f0a72fe --- /dev/null +++ b/src/web/http/body.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty, Limited}; +use hyper::body::{Body as _, Incoming}; +use hyper::Request; + +use crate::web::manager::WebProcessRuntime; + +/// Collected carrier request retaining its process-wide body reservation. +pub(super) struct CollectedBody { + /// Request head reconstructed without the consumed network body. + pub(super) request: Request>, + /// Fully collected bounded carrier payload. + pub(super) body: Bytes, + /// Byte-budget reservation held through request processing. + pub(super) _body_budget: tokio::sync::OwnedSemaphorePermit, +} + +// Keep rejected requests inline to avoid attacker-controlled allocations on invalid bodies. +#[allow(clippy::large_enum_variant)] +/// Body collection failure with sanitized request context when decoy routing is safe. +pub(super) enum CollectBodyError { + /// The body shape, size, or deadline failed after retaining the request head. + Invalid(Request>), + /// Process-wide body reader or byte capacity is temporarily exhausted. + Limit, +} + +/// Collects one bounded carrier body under reader, byte, and deadline ownership. +pub(super) async fn collect_body( + request: Request, + runtime: &WebProcessRuntime, + limit: usize, + allow_empty: bool, +) -> Result { + let exceeds_limit = request.body().size_hint().lower() > limit as u64 + || request + .body() + .size_hint() + .upper() + .is_some_and(|upper| upper > limit as u64); + let (parts, body) = request.into_parts(); + if exceeds_limit { + return Err(CollectBodyError::Invalid(Request::from_parts( + parts, + Empty::new(), + ))); + } + let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else { + return Err(CollectBodyError::Limit); + }; + let body_timeout = Duration::from_secs( + runtime + .active_generation() + .config() + .web + .timeouts + .body_secs, + ); + let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await { + Ok(Ok(body)) => body.to_bytes(), + _ => { + return Err(CollectBodyError::Invalid(Request::from_parts( + parts, + Empty::new(), + ))); + } + }; + drop(reader_budget); + let request = Request::from_parts(parts, Empty::new()); + if !allow_empty && body.is_empty() { + return Err(CollectBodyError::Invalid(request)); + } + Ok(CollectedBody { + request, + body, + _body_budget: body_budget, + }) +} diff --git a/src/web/http/decoy.rs b/src/web/http/decoy.rs new file mode 100644 index 0000000..b565c2d --- /dev/null +++ b/src/web/http/decoy.rs @@ -0,0 +1,284 @@ +use std::error::Error; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Empty}; +use hyper::header::{self, HeaderName, HeaderValue}; +use hyper::{Method, Request, StatusCode, Uri}; +use hyper_util::rt::TokioIo; +use tokio::net::TcpStream; + +use super::{ + BoxError, HttpBody, HttpResponse, bad_gateway, full_response, generic_not_found, + insert_header, +}; +use crate::config::{WebRuntimeDecoy, WebRuntimeVhost}; +use crate::web::manager::WebProcessRuntime; + +/// Serves the configured ordinary site after optionally removing carrier material. +pub(super) async fn serve_decoy( + mut request: Request, + vhost: Arc, + sanitize_transport: bool, + runtime: &WebProcessRuntime, +) -> HttpResponse +where + B: hyper::body::Body + Send + 'static, + B::Error: Error + Send + Sync + 'static, +{ + if sanitize_transport { + sanitize_transport_request(&mut request); + } + let (parts, body) = request.into_parts(); + let body = if sanitize_transport { + Empty::::new() + .map_err(|never| -> BoxError { match never {} }) + .boxed_unsync() + } else { + body.map_err(|error| -> BoxError { Box::new(error) }) + .boxed_unsync() + }; + let request = Request::from_parts(parts, body); + match &vhost.decoy { + WebRuntimeDecoy::StaticDirectory(site) => serve_static(request, site), + WebRuntimeDecoy::HttpUpstream { addr, authority } => { + proxy_to_upstream( + request, + *addr, + authority, + Duration::from_secs(vhost.decoy_header_secs), + runtime, + ) + .await + } + } +} + +fn serve_static(request: Request, site: &crate::config::WebStaticSite) -> HttpResponse { + if !matches!(*request.method(), Method::GET | Method::HEAD) { + return static_entry(request, site, None, StatusCode::NOT_FOUND); + } + let path = request.uri().path(); + let resolved = resolve_static_path(path, site); + let status = if resolved.is_some() { + StatusCode::OK + } else { + StatusCode::NOT_FOUND + }; + static_entry(request, site, resolved, status) +} + +fn static_entry( + request: Request, + site: &crate::config::WebStaticSite, + route: Option<&str>, + status: StatusCode, +) -> HttpResponse { + let fallback = format!("/{}", site.index); + let not_found = site.assets.contains_key("/404.html").then_some("/404.html"); + let route = route.or(not_found).unwrap_or(&fallback); + let Some(asset) = site.assets.get(route) else { + return generic_not_found(); + }; + let not_modified = status == StatusCode::OK + && request + .headers() + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + == Some(asset.etag.as_str()); + let response_body = if request.method() == Method::HEAD || not_modified { + Bytes::new() + } else { + asset.body.clone() + }; + let mut response = full_response( + if not_modified { + StatusCode::NOT_MODIFIED + } else { + status + }, + response_body, + ); + insert_header(&mut response, header::CONTENT_TYPE, asset.content_type); + insert_header(&mut response, header::ETAG, &asset.etag); + insert_header( + &mut response, + header::CONTENT_LENGTH, + &asset.body.len().to_string(), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + if status.is_client_error() || request.uri().query().is_some() { + HeaderValue::from_static("no-store") + } else { + HeaderValue::from_static("public, max-age=300") + }, + ); + response.headers_mut().insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'self'; style-src 'self'; img-src 'self'; worker-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"), + ); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response.headers_mut().insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + response +} + +fn resolve_static_path<'a>( + path: &str, + site: &'a crate::config::WebStaticSite, +) -> Option<&'a str> { + if !path.starts_with('/') + || path.contains('\\') + || path.contains("//") + || path.split('/').any(|part| matches!(part, "." | "..")) + { + return None; + } + let root; + let route = if path == "/" { + root = format!("/{}", site.index); + root.as_str() + } else { + path + }; + if site.assets.contains_key(route) { + return site.assets.get_key_value(route).map(|(key, _)| key.as_str()); + } + if route == "/favicon.ico" && site.assets.contains_key("/favicon.svg") { + return Some("/favicon.svg"); + } + if !route.rsplit('/').next().unwrap_or_default().contains('.') { + let html = format!("{route}.html"); + return site + .assets + .get_key_value(&html) + .map(|(key, _)| key.as_str()); + } + None +} + +async fn proxy_to_upstream( + mut request: Request, + addr: SocketAddr, + authority: &str, + header_timeout: Duration, + runtime: &WebProcessRuntime, +) -> HttpResponse { + remove_hop_by_hop(request.headers_mut()); + if let Ok(host) = HeaderValue::from_str(authority) { + request.headers_mut().insert(header::HOST, host); + } + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + let Ok(uri) = path_and_query.parse::() else { + return bad_gateway(); + }; + *request.uri_mut() = uri; + let stream = match tokio::time::timeout(header_timeout, TcpStream::connect(addr)).await { + Ok(Ok(stream)) => stream, + _ => return bad_gateway(), + }; + let max_header_bytes = runtime + .active_generation() + .config() + .web + .limits + .max_header_bytes; + let mut builder = hyper::client::conn::http1::Builder::new(); + builder.max_buf_size(max_header_bytes); + let (mut sender, connection) = match tokio::time::timeout( + header_timeout, + builder.handshake(TokioIo::new(stream)), + ) + .await + { + Ok(Ok(parts)) => parts, + _ => return bad_gateway(), + }; + runtime.spawn_auxiliary(async move { + let _ = connection.await; + }); + let mut response = match tokio::time::timeout(header_timeout, sender.send_request(request)).await + { + Ok(Ok(response)) => response, + _ => return bad_gateway(), + }; + remove_hop_by_hop(response.headers_mut()); + response.map(|body| { + body.map_err(|error| -> BoxError { Box::new(error) }) + .boxed_unsync() + }) +} + +fn sanitize_transport_request(request: &mut Request) { + for name in [ + header::AUTHORIZATION, + header::CONTENT_LENGTH, + header::CONTENT_TYPE, + header::UPGRADE, + HeaderName::from_static("sec-websocket-key"), + HeaderName::from_static("sec-websocket-protocol"), + HeaderName::from_static("sec-websocket-version"), + HeaderName::from_static("x-down-cursor"), + HeaderName::from_static("x-lane-id"), + HeaderName::from_static("x-up-seq"), + ] { + request.headers_mut().remove(name); + } + request + .headers_mut() + .insert(header::CONNECTION, HeaderValue::from_static("close")); +} + +fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) { + let nominated = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| HeaderName::from_bytes(value.trim().as_bytes()).ok()) + .collect::>(); + for name in nominated { + headers.remove(name); + } + for name in [ + header::CONNECTION, + header::PROXY_AUTHENTICATE, + header::PROXY_AUTHORIZATION, + header::TE, + header::TRAILER, + header::TRANSFER_ENCODING, + header::UPGRADE, + HeaderName::from_static("keep-alive"), + HeaderName::from_static("proxy-connection"), + ] { + headers.remove(name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_resolver_rejects_rewritten_paths() { + let site = crate::config::WebStaticSite { + assets: std::collections::BTreeMap::new(), + index: "index.html".to_string(), + }; + assert!(resolve_static_path("/../index.html", &site).is_none()); + assert!(resolve_static_path("//index.html", &site).is_none()); + } +} diff --git a/src/web/http/request.rs b/src/web/http/request.rs new file mode 100644 index 0000000..8a8a442 --- /dev/null +++ b/src/web/http/request.rs @@ -0,0 +1,240 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use base64::Engine as _; +use hyper::header; +use hyper::Request; +use ipnetwork::IpNetwork; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::config::{ + WebClientIpSource, WebRuntimeProfile, WebRuntimeVhost, +}; +use crate::web::manager::TokenHash; + +/// Parses one lowercase canonical Host value restricted to the public HTTPS port. +pub(super) fn canonical_request_host(request: &Request) -> Option<&str> { + let values = request.headers().get_all(header::HOST); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + let authority = value.parse::().ok()?; + if authority.port_u16().is_some_and(|port| port != 443) { + return None; + } + let host = value.strip_suffix(":443").unwrap_or(value); + if authority.host() != host || host.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return None; + } + Some(host) +} + +/// Accepts one canonical forwarded client address from an explicitly trusted peer. +pub(super) fn client_ip( + request: &Request, + peer: SocketAddr, + source: WebClientIpSource, + trusted_proxy_cidrs: &[IpNetwork], +) -> Option { + if !trusted_proxy_cidrs + .iter() + .any(|network| network.contains(peer.ip())) + { + return None; + } + let header_name = match source { + WebClientIpSource::XForwardedFor => "x-forwarded-for", + }; + let values = request.headers().get_all(header_name); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() + || value.is_empty() + || value.trim() != value + || value.contains(',') + { + return None; + } + let ip = value.parse::().ok()?; + (ip.to_string() == value).then_some(ip) +} + +/// Decodes an exact canonical bridge query without allocating credential strings. +pub(super) fn bridge_candidate(query: Option<&str>) -> ([u8; 32], bool) { + let mut candidate = [0u8; 32]; + let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else { + return (candidate, false); + }; + if value.len() != 43 { + return (candidate, false); + } + let mut decoded = [0u8; 32]; + let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode_slice(value, &mut decoded) + else { + return (candidate, false); + }; + let mut canonical = [0u8; 43]; + let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode_slice(decoded, &mut canonical) + else { + return (candidate, false); + }; + if decoded_len != decoded.len() + || encoded_len != canonical.len() + || !bool::from(canonical.ct_eq(value.as_bytes())) + { + return (candidate, false); + } + candidate = decoded; + (candidate, true) +} + +/// Matches a capability in constant time across every profile of one virtual host. +pub(super) fn match_profile( + vhost: &WebRuntimeVhost, + candidate: &[u8; 32], +) -> Option> { + let mut matched = None; + for profile in &vhost.profiles { + if bool::from(profile.capability.ct_eq(candidate)) { + matched = Some(Arc::clone(profile)); + } + } + matched +} + +/// Validates and hashes one canonical bearer credential for map lookup. +pub(super) fn bearer_token_hash(request: &Request) -> Option { + let values = request.headers().get_all(header::AUTHORIZATION); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() || !value.starts_with("Bearer ") || value.matches(' ').count() != 1 + { + return None; + } + let token = value.strip_prefix("Bearer ")?; + if token.len() != 43 { + return None; + } + let mut decoded = [0u8; 32]; + let decoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode_slice(token, &mut decoded) + .ok()?; + let mut canonical = [0u8; 43]; + let encoded_len = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode_slice(decoded, &mut canonical) + .ok()?; + (decoded_len == decoded.len() + && encoded_len == canonical.len() + && bool::from(canonical.ct_eq(token.as_bytes()))) + .then(|| Sha256::digest(decoded).into()) +} + +/// Checks the exact carrier media type without accepting duplicate headers. +pub(super) fn binary_content_type(request: &Request) -> bool { + let values = request.headers().get_all(header::CONTENT_TYPE); + let mut values = values.iter(); + let value = values.next().and_then(|value| value.to_str().ok()); + values.next().is_none() + && value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream")) +} + +/// Parses one canonical unsigned decimal carrier sequence header. +pub(super) fn canonical_u64_header( + request: &Request, + name: &'static str, +) -> Option { + let values = request.headers().get_all(name); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() + || value.is_empty() + || value.starts_with('+') + || (value.len() > 1 && value.starts_with('0')) + { + return None; + } + let parsed = value.parse::().ok()?; + (parsed.to_string() == value).then_some(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_bridge_query_rejects_aliases() { + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]); + assert!(bridge_candidate(Some(&format!("bridge={token}"))).1); + assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1); + assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1); + } + + #[test] + fn host_and_forwarded_identity_require_canonical_single_values() { + let request = Request::builder() + .header(header::HOST, "proxy.example.com:443") + .header("x-forwarded-for", "192.0.2.10") + .body(()) + .unwrap(); + assert_eq!( + canonical_request_host(&request), + Some("proxy.example.com") + ); + let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()]; + assert_eq!( + client_ip( + &request, + "127.0.0.1:40000".parse().unwrap(), + WebClientIpSource::XForwardedFor, + &trusted, + ), + Some("192.0.2.10".parse().unwrap()) + ); + + let uppercase = Request::builder() + .header(header::HOST, "Proxy.Example.com") + .body(()) + .unwrap(); + assert!(canonical_request_host(&uppercase).is_none()); + let appended = Request::builder() + .header("x-forwarded-for", "192.0.2.10, 198.51.100.4") + .body(()) + .unwrap(); + assert!( + client_ip( + &appended, + "127.0.0.1:40000".parse().unwrap(), + WebClientIpSource::XForwardedFor, + &trusted, + ) + .is_none() + ); + } + + #[test] + fn bearer_and_sequence_headers_reject_noncanonical_aliases() { + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]); + let request = Request::builder() + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header("x-up-seq", "17") + .body(()) + .unwrap(); + assert_eq!( + bearer_token_hash(&request), + Some(Sha256::digest([1u8; 32]).into()) + ); + assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17)); + + let leading_zero = Request::builder() + .header("x-up-seq", "017") + .body(()) + .unwrap(); + assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none()); + } +} diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs new file mode 100644 index 0000000..86f81e8 --- /dev/null +++ b/src/web/http/tests.rs @@ -0,0 +1,206 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use base64::Engine as _; +use bytes::Bytes; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; + +use super::serve_connection; +use crate::config::{ + ProxyConfig, WebClientIpSource, WebRuntimeConfig, WebRuntimeDecoy, + WebRuntimeProfile, WebRuntimeVhost, WebSecretMode, WebStaticAsset, WebStaticSite, +}; +use crate::maestro::generation::test_runtime_generation; +use crate::web::frame::{self, FrameType}; +use crate::web::manager::WebProcessRuntime; + +fn runtime_config(capability: [u8; 32]) -> ProxyConfig { + let profile = Arc::new(WebRuntimeProfile { + host: "proxy.example.com".to_string(), + public_addr: "203.0.113.10:443".parse().unwrap(), + user: "alice".to_string(), + secret_mode: WebSecretMode::Plain, + capability, + max_sessions: 4, + max_streams: 16, + max_streams_per_session: 4, + }); + let mut assets = BTreeMap::new(); + assets.insert( + "/index.html".to_string(), + WebStaticAsset { + body: Bytes::from_static(b"decoy"), + content_type: "text/html; charset=utf-8", + etag: "\"test\"".to_string(), + }, + ); + let site = Arc::new(WebStaticSite { + assets, + index: "index.html".to_string(), + }); + let vhost = Arc::new(WebRuntimeVhost { + host: "proxy.example.com".to_string(), + decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)), + decoy_header_secs: 1, + profiles: vec![Arc::clone(&profile)], + }); + let mut vhosts = BTreeMap::new(); + vhosts.insert("proxy.example.com".to_string(), vhost); + vhosts.insert( + "other.example.com".to_string(), + Arc::new(WebRuntimeVhost { + host: "other.example.com".to_string(), + decoy: WebRuntimeDecoy::StaticDirectory(site), + decoy_header_secs: 1, + profiles: Vec::new(), + }), + ); + let mut config = ProxyConfig::default(); + config.web.enabled = true; + config.web.limits.max_bootstraps_per_ip = 1; + config.web.timeouts.shutdown_secs = 1; + config.web.runtime = Some(Arc::new(WebRuntimeConfig { + vhosts, + profiles: vec![profile], + })); + config +} + +async fn request( + listener: &TcpListener, + runtime: &Arc, + request: Vec, +) -> Vec { + let addr = listener.local_addr().unwrap(); + let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr)); + let (server, peer) = accepted.unwrap(); + let mut client = client.unwrap(); + let permit = runtime.try_http_connection().unwrap(); + let task = tokio::spawn(serve_connection( + server, + peer, + WebClientIpSource::XForwardedFor, + Arc::from(["127.0.0.1/32".parse().unwrap()]), + Arc::clone(runtime), + CancellationToken::new(), + permit, + )); + client.write_all(&request).await.unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).await.unwrap(); + task.await.unwrap(); + response +} + +fn split_response(response: &[u8]) -> (&[u8], &[u8]) { + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap(); + (&response[..separator], &response[separator + 4..]) +} + +fn response_header<'a>(headers: &'a [u8], name: &str) -> &'a str { + std::str::from_utf8(headers) + .unwrap() + .lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(header, value)| header.eq_ignore_ascii_case(name).then_some(value.trim())) + .unwrap() +} + +#[tokio::test] +async fn https_carrier_bootstraps_and_closes_one_session() { + let capability = [7u8; 32]; + let generation = test_runtime_generation(1, runtime_config(capability)); + let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation))); + let runtime = WebProcessRuntime::start(Arc::clone(&active_runtime)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + let wrong_family = format!( + "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 2001:db8::10\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let wrong_family_response = request(&listener, &runtime, wrong_family).await; + let (_, wrong_family_body) = split_response(&wrong_family_response); + assert!(!wrong_family_body + .windows(11) + .any(|value| value == b"bootstrap='")); + let root = format!( + "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let root_response = request(&listener, &runtime, root).await; + let (root_headers, root_body) = split_response(&root_response); + assert!(root_headers.starts_with(b"HTTP/1.1 200")); + let root_body = std::str::from_utf8(root_body).unwrap(); + let bootstrap = root_body + .split_once("bootstrap='") + .and_then(|(_, suffix)| suffix.split_once('\'')) + .map(|(token, _)| token) + .unwrap(); + assert_eq!(bootstrap.len(), 43); + + let hello = frame::encode(FrameType::Hello, 0, &[1]); + let mut wrong_host = format!( + "POST /api/v1/session HTTP/1.1\r\nHost: other.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + hello.len() + ) + .into_bytes(); + wrong_host.extend_from_slice(&hello); + let wrong_host_response = request(&listener, &runtime, wrong_host).await; + assert!(wrong_host_response.starts_with(b"HTTP/1.1 404")); + + let mut create = format!( + "POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + hello.len() + ) + .into_bytes(); + let create_retry = create.clone(); + create.extend_from_slice(&hello); + let mut create_retry = create_retry; + create_retry.extend_from_slice(&hello); + let create_response = request(&listener, &runtime, create).await; + let (create_headers, create_body) = split_response(&create_response); + assert!(create_headers.starts_with(b"HTTP/1.1 200")); + assert_eq!(response_header(create_headers, "x-carrier-mode"), "https"); + assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[])); + let session = response_header(create_headers, "x-session-token"); + assert_eq!(session.len(), 43); + + let replacement = test_runtime_generation(2, runtime_config(capability)); + active_runtime.store(Arc::clone(&replacement)); + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let retry_response = request(&listener, &runtime, create_retry).await; + let (retry_headers, retry_body) = split_response(&retry_response); + assert!(retry_headers.starts_with(b"HTTP/1.1 200")); + assert_eq!(response_header(retry_headers, "x-session-token"), session); + assert_eq!(retry_body, frame::encode(FrameType::Welcome, 0, &[])); + + let next_root = format!( + "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let next_root_response = request(&listener, &runtime, next_root).await; + let (_, next_root_body) = split_response(&next_root_response); + assert!(next_root_body.windows(11).any(|value| value == b"bootstrap='")); + + let close = format!( + "DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let close_retry = close.clone(); + let close_response = request(&listener, &runtime, close).await; + assert!(close_response.starts_with(b"HTTP/1.1 204")); + let close_retry_response = request(&listener, &runtime, close_retry).await; + assert!(close_retry_response.starts_with(b"HTTP/1.1 204")); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; + replacement.stop_sessions().await; + replacement.stop_background_tasks().await; +} diff --git a/src/web/manager.rs b/src/web/manager.rs new file mode 100644 index 0000000..896d20a --- /dev/null +++ b/src/web/manager.rs @@ -0,0 +1,532 @@ +use std::future::Future; +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use parking_lot::Mutex; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore}; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use zeroize::Zeroizing; + +use crate::config::{WebLimitsConfig, WebRuntimeProfile}; +use crate::maestro::generation::RuntimeGeneration; +use crate::web::frame; +use crate::web::session::WebSession; + +// Credential maps, quotas, and token-bucket helpers remain private to the manager. +mod state; +// Stream admission and synthetic tuple ownership are process-scoped. +mod admission; +// Shutdown and expiry work remain outside request-path coordination. +mod lifecycle; +use state::{ + Bootstrap, ManagerState, allow_rate, control_item_reserve, decrement_map, + evict_oldest_unused_bootstrap, matching_profile, new_unique_token, profile_key, + remove_expired_locked, +}; + +const TOKEN_BYTES: usize = 32; +const CLEANUP_INTERVAL: Duration = Duration::from_secs(1); + +/// Stable hash key used for bootstrap and session credentials. +pub(crate) type TokenHash = [u8; TOKEN_BYTES]; +/// Stable non-allocating key used for per-profile quotas. +pub(crate) type ProfileKey = [u8; TOKEN_BYTES]; + +/// WEB manager operation failure category. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ManagerError { + /// Credential, hostname, or ownership validation failed. + Authentication, + /// Bounded queue capacity is temporarily unavailable. + Backpressure, + /// A configured admission or rate ceiling was reached. + Limit, + /// Carrier framing or sequencing violated the protocol. + Protocol, + /// The operation conflicts with another in-flight operation. + Concurrent, + /// The process or session has stopped accepting work. + Closed, +} + +/// Successful idempotent session creation result. +pub(crate) struct CreateResult { + /// Opaque bearer token for the created or replayed session. + pub(crate) token: String, +} + +/// Process-owned bounded WEB credential, session, and memory coordinator. +pub(crate) struct WebProcessRuntime { + active_runtime: Arc>, + limits: WebLimitsConfig, + state: Mutex, + http_connections: Arc, + http_handlers: Arc, + body_readers: Arc, + body_bytes: Arc, + stream_handshakes: Arc, + budget_notify: Arc, + budget_saturated: AtomicBool, + shutdown: CancellationToken, + tasks: TaskTracker, + sessions_created: AtomicU64, + sessions_closed: AtomicU64, + streams_opened: AtomicU64, + streams_rejected: AtomicU64, + bytes_up: AtomicU64, + bytes_down: AtomicU64, + limit_hits: AtomicU64, +} + +impl WebProcessRuntime { + /// Starts one process-scoped manager using immutable allocation ceilings. + pub(crate) fn start( + active_runtime: Arc>, + ) -> Arc { + let limits = active_runtime.load().config().web.limits.clone(); + let runtime = Arc::new(Self { + active_runtime, + http_connections: Arc::new(Semaphore::new(limits.max_http_connections)), + http_handlers: Arc::new(Semaphore::new(limits.max_http_handlers)), + body_readers: Arc::new(Semaphore::new(limits.max_body_readers)), + body_bytes: Arc::new(Semaphore::new(limits.max_body_bytes_global)), + stream_handshakes: Arc::new(Semaphore::new(limits.max_stream_handshakes)), + limits, + state: Mutex::new(ManagerState::default()), + budget_notify: Arc::new(Notify::new()), + budget_saturated: AtomicBool::new(false), + shutdown: CancellationToken::new(), + tasks: TaskTracker::new(), + sessions_created: AtomicU64::new(0), + sessions_closed: AtomicU64::new(0), + streams_opened: AtomicU64::new(0), + streams_rejected: AtomicU64::new(0), + bytes_up: AtomicU64::new(0), + bytes_down: AtomicU64::new(0), + limit_hits: AtomicU64::new(0), + }); + let weak = Arc::downgrade(&runtime); + let shutdown = runtime.shutdown.clone(); + runtime.tasks.spawn(async move { + let mut interval = tokio::time::interval(CLEANUP_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + _ = interval.tick() => { + let Some(runtime) = weak.upgrade() else { + break; + }; + runtime.cleanup(); + } + } + } + }); + runtime + } + + /// Loads the currently active generation without retaining older generations. + pub(crate) fn active_generation(&self) -> Arc { + self.active_runtime.load_full() + } + + /// Reserves one accepted HTTP connection. + pub(crate) fn try_http_connection(&self) -> Option { + let permit = Arc::clone(&self.http_connections).try_acquire_owned().ok(); + if permit.is_none() { + self.record_limit_hit(); + } + permit + } + + /// Reserves one concurrently executing HTTP request handler. + pub(crate) fn try_http_handler(&self) -> Option { + let permit = Arc::clone(&self.http_handlers).try_acquire_owned().ok(); + if permit.is_none() { + self.record_limit_hit(); + } + permit + } + + /// Reserves one logical stream in the inner MTProxy handshake phase. + pub(crate) fn try_stream_handshake(&self) -> Option { + let permit = Arc::clone(&self.stream_handshakes) + .try_acquire_owned() + .ok(); + if permit.is_none() { + self.record_stream_rejected(); + } + permit + } + + /// Spawns one process-owned auxiliary task with shutdown cancellation. + pub(crate) fn spawn_auxiliary(&self, future: F) + where + F: Future + Send + 'static, + { + let shutdown = self.shutdown.clone(); + self.tasks.spawn(async move { + tokio::select! { + _ = shutdown.cancelled() => {} + _ = future => {} + } + }); + } + + /// Reserves one body reader and its declared bounded body allocation. + pub(crate) fn try_body_budget( + &self, + bytes: usize, + ) -> Option<(OwnedSemaphorePermit, OwnedSemaphorePermit)> { + let Some(bytes) = u32::try_from(bytes).ok() else { + self.record_limit_hit(); + return None; + }; + let Some(reader) = Arc::clone(&self.body_readers).try_acquire_owned().ok() else { + self.record_limit_hit(); + return None; + }; + let Some(body) = Arc::clone(&self.body_bytes) + .try_acquire_many_owned(bytes) + .ok() + else { + self.record_limit_hit(); + return None; + }; + Some((reader, body)) + } + + /// Issues a one-use bootstrap credential for the active generation. + pub(crate) fn issue_bootstrap( + &self, + profile: Arc, + client_ip: IpAddr, + ) -> std::result::Result { + let generation = self.active_generation(); + let config = generation.config(); + let profile = config + .web + .runtime + .as_ref() + .and_then(|runtime| matching_profile(runtime, &profile)) + .ok_or(ManagerError::Authentication)?; + if !config.web.enabled + || profile.public_addr.is_ipv4() != client_ip.is_ipv4() + || !generation.proxy_shared.is_user_enabled(&profile.user) + { + return Err(ManagerError::Closed); + } + let now = Instant::now(); + let mut state = self.state.lock(); + remove_expired_locked(&mut state, now); + if state.closed + || state.bootstraps_per_ip.get(&client_ip).copied().unwrap_or(0) + >= self.limits.max_bootstraps_per_ip + || !allow_rate( + &mut state.bootstrap_rate, + now, + self.limits.new_bootstraps_per_minute, + self.limits.new_bootstraps_burst, + ) + { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return Err(ManagerError::Limit); + } + if state.bootstraps.len() >= self.limits.max_bootstraps_global + && !evict_oldest_unused_bootstrap(&mut state) + { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return Err(ManagerError::Limit); + } + let Some((token, hash)) = new_unique_token(&generation, &state) else { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return Err(ManagerError::Limit); + }; + state.bootstraps.insert( + hash, + Bootstrap { + generation_id: generation.id, + expires_at: now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs), + issued_at: now, + issuance_ip: client_ip, + profile, + body_digest: [0; TOKEN_BYTES], + session_token: Zeroizing::new(String::new()), + session: None, + used: false, + }, + ); + *state.bootstraps_per_ip.entry(client_ip).or_insert(0) += 1; + Ok(token) + } + + /// Checks whether a bootstrap token is live before reading a request body. + pub(crate) fn has_bootstrap(&self, hash: TokenHash, host: &str) -> bool { + let generation_id = self.active_runtime.load().id; + let now = Instant::now(); + let state = self.state.lock(); + state.bootstraps.get(&hash).is_some_and(|entry| { + entry.profile.host == host + && now <= entry.expires_at + && (entry.generation_id == generation_id + || entry.used && entry.session.is_some()) + }) + } + + /// Creates a session exactly once or replays the original successful result. + pub(crate) fn create_session( + self: &Arc, + bootstrap_hash: TokenHash, + host: &str, + client_ip: IpAddr, + body: &[u8], + ) -> std::result::Result { + if !frame::validate_hello(body, &self.limits) { + return Err(ManagerError::Protocol); + } + let body_digest: TokenHash = Sha256::digest(body).into(); + let generation = self.active_generation(); + let config = generation.config(); + let now = Instant::now(); + let mut state = self.state.lock(); + remove_expired_locked(&mut state, now); + let Some(entry) = state.bootstraps.get(&bootstrap_hash) else { + return Err(ManagerError::Authentication); + }; + if entry.profile.host != host || now > entry.expires_at { + return Err(ManagerError::Authentication); + } + if entry.used { + let digest_matches = bool::from(entry.body_digest.ct_eq(&body_digest)); + if !digest_matches { + return Err(ManagerError::Authentication); + } + if entry.session.is_none() { + return Err(ManagerError::Authentication); + } + return Ok(CreateResult { + token: entry.session_token.as_str().to_owned(), + }); + } + if entry.generation_id != generation.id { + return Err(ManagerError::Authentication); + } + if state.closed || !config.web.enabled { + return Err(ManagerError::Closed); + } + let profile = config + .web + .runtime + .as_ref() + .and_then(|runtime| matching_profile(runtime, &entry.profile)) + .filter(|profile| { + profile.public_addr.is_ipv4() == client_ip.is_ipv4() + && generation.proxy_shared.is_user_enabled(&profile.user) + }) + .ok_or(ManagerError::Authentication)?; + let profile_key = profile_key(&profile); + if state.sessions.len() >= self.limits.max_sessions_global + || state.sessions_per_ip.get(&client_ip).copied().unwrap_or(0) + >= self.limits.max_sessions_per_ip + || state + .sessions_per_profile + .get(&profile_key) + .copied() + .unwrap_or(0) + >= profile.max_sessions + || !allow_rate( + &mut state.session_rate, + now, + self.limits.new_sessions_per_minute, + self.limits.new_sessions_burst, + ) + { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return Err(ManagerError::Limit); + } + let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return Err(ManagerError::Limit); + }; + let session = WebSession::new( + Arc::downgrade(self), + session_hash, + client_ip, + profile, + profile_key, + self.limits.clone(), + config.web.timeouts.clone(), + ); + state.sessions.insert(session_hash, Arc::clone(&session)); + *state.sessions_per_ip.entry(client_ip).or_insert(0) += 1; + *state.sessions_per_profile.entry(profile_key).or_insert(0) += 1; + let entry = state + .bootstraps + .get_mut(&bootstrap_hash) + .ok_or(ManagerError::Authentication)?; + entry.used = true; + entry.body_digest = body_digest; + entry.session_token = Zeroizing::new(session_token.clone()); + entry.session = Some(Arc::clone(&session)); + let issuance_ip = entry.issuance_ip; + decrement_map(&mut state.bootstraps_per_ip, &issuance_ip); + self.sessions_created.fetch_add(1, Ordering::Relaxed); + Ok(CreateResult { + token: session_token, + }) + } + + /// Resolves an authenticated session token. + pub(crate) fn get_session( + &self, + hash: TokenHash, + host: &str, + ) -> std::result::Result, ManagerError> { + self.state + .lock() + .sessions + .get(&hash) + .cloned() + .filter(|session| session.matches_host(host)) + .ok_or(ManagerError::Authentication) + } + + /// Closes a live token and accepts bounded tombstone retries. + pub(crate) fn close_token( + &self, + hash: TokenHash, + host: &str, + ) -> std::result::Result<(), ManagerError> { + let state = self.state.lock(); + let session = state + .sessions + .get(&hash) + .filter(|session| session.matches_host(host)) + .cloned(); + let closed = state + .closed_tokens + .get(&hash) + .is_some_and(|closed| closed.host == host); + drop(state); + if let Some(session) = session { + session.close(); + return Ok(()); + } + closed.then_some(()).ok_or(ManagerError::Authentication) + } + + /// Reserves bounded process-wide queue capacity for data or control traffic. + pub(crate) fn try_reserve_pending( + &self, + bytes: usize, + items: usize, + control: bool, + downlink: bool, + ) -> bool { + let mut state = self.state.lock(); + let data_byte_limit = self + .limits + .pending_bytes_global + .saturating_sub(self.limits.control_bytes_global); + let control_item_reserve = control_item_reserve(&self.limits); + let data_item_limit = self + .limits + .pending_items_global + .saturating_sub(control_item_reserve); + if state.closed { + return false; + } + let fits = if control { + bytes <= self.limits.control_bytes_global + && items <= control_item_reserve + && state.pending_bytes + <= self.limits.pending_bytes_global.saturating_sub(bytes) + && state.pending_items + <= self.limits.pending_items_global.saturating_sub(items) + && state.pending_control_bytes + <= self.limits.control_bytes_global.saturating_sub(bytes) + && state.pending_control_items + <= control_item_reserve.saturating_sub(items) + } else { + let data_bytes = state + .pending_bytes + .saturating_sub(state.pending_control_bytes); + let data_items = state + .pending_items + .saturating_sub(state.pending_control_items); + let (byte_limit, item_limit) = if downlink { + let uplink_bytes = self + .limits + .max_body_bytes + .saturating_add( + self.limits + .max_frames_per_body + .saturating_mul(crate::web::session::QUEUE_ITEM_COST), + ); + ( + data_byte_limit.saturating_sub(uplink_bytes), + data_item_limit.saturating_sub(self.limits.max_frames_per_body), + ) + } else { + (data_byte_limit, data_item_limit) + }; + bytes <= byte_limit + && items <= item_limit + && data_bytes <= byte_limit - bytes + && data_items <= item_limit - items + }; + if !fits { + self.budget_saturated.store(true, Ordering::Release); + self.record_limit_hit(); + return false; + } + state.pending_bytes += bytes; + state.pending_items += items; + if control { + state.pending_control_bytes += bytes; + state.pending_control_items += items; + } + true + } + + /// Releases process-wide queue capacity and wakes blocked relay writers. + pub(crate) fn release_pending(&self, bytes: usize, items: usize, control: bool) { + let mut state = self.state.lock(); + state.pending_bytes = state.pending_bytes.saturating_sub(bytes); + state.pending_items = state.pending_items.saturating_sub(items); + if control { + state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes); + state.pending_control_items = state.pending_control_items.saturating_sub(items); + } + drop(state); + if self.budget_saturated.swap(false, Ordering::AcqRel) { + self.budget_notify.notify_waiters(); + } + } + + /// Returns the shared notification source for global queue capacity changes. + pub(crate) fn budget_notify(&self) -> Arc { + Arc::clone(&self.budget_notify) + } + + /// Accounts one successfully committed carrier uplink body. + pub(crate) fn record_up(&self, bytes: usize) { + self.bytes_up.fetch_add(bytes as u64, Ordering::Relaxed); + } + + /// Accounts one emitted carrier downlink body. + pub(crate) fn record_down(&self, bytes: usize) { + self.bytes_down.fetch_add(bytes as u64, Ordering::Relaxed); + } + + fn record_limit_hit(&self) { + self.limit_hits.fetch_add(1, Ordering::Relaxed); + } + +} diff --git a/src/web/manager/admission.rs b/src/web/manager/admission.rs new file mode 100644 index 0000000..61a3520 --- /dev/null +++ b/src/web/manager/admission.rs @@ -0,0 +1,130 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::Ordering; +use std::time::Instant; + +use super::state::{ + allocate_stream_port, allow_rate, decrement_map, release_stream_port, +}; +use super::{ProfileKey, WebProcessRuntime}; + +impl WebProcessRuntime { + /// Reserves one process-wide and per-profile live logical-stream slot. + pub(crate) fn try_acquire_stream( + &self, + profile_key: ProfileKey, + max_streams: usize, + client_ip: IpAddr, + public_addr: SocketAddr, + ) -> Option { + let now = Instant::now(); + let mut state = self.state.lock(); + if state.closed + || state.streams_live >= self.limits.max_streams_global + || state + .streams_per_profile + .get(&profile_key) + .copied() + .unwrap_or(0) + >= max_streams + || !allow_rate( + &mut state.stream_rate, + now, + self.limits.new_streams_per_minute, + self.limits.new_streams_burst, + ) + { + self.streams_rejected.fetch_add(1, Ordering::Relaxed); + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return None; + } + let Some(peer_port) = allocate_stream_port(&mut state, client_ip, public_addr) else { + self.streams_rejected.fetch_add(1, Ordering::Relaxed); + self.limit_hits.fetch_add(1, Ordering::Relaxed); + return None; + }; + state.streams_live += 1; + *state + .streams_per_profile + .entry(profile_key) + .or_insert(0) += 1; + self.streams_opened.fetch_add(1, Ordering::Relaxed); + Some(peer_port) + } + + /// Releases one live logical-stream slot after its relay task exits. + pub(crate) fn release_stream( + &self, + profile_key: ProfileKey, + client_ip: IpAddr, + public_addr: SocketAddr, + peer_port: u16, + ) { + let mut state = self.state.lock(); + if !release_stream_port(&mut state, client_ip, public_addr, peer_port) { + return; + } + state.streams_live = state.streams_live.saturating_sub(1); + decrement_map(&mut state.streams_per_profile, &profile_key); + } + + /// Records a logical stream rejected outside manager quota acquisition. + pub(crate) fn record_stream_rejected(&self) { + self.streams_rejected.fetch_add(1, Ordering::Relaxed); + self.record_limit_hit(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arc_swap::ArcSwap; + + use super::*; + use crate::config::ProxyConfig; + use crate::maestro::generation::test_runtime_generation; + use crate::web::session::QUEUE_ITEM_COST; + + #[tokio::test] + async fn global_downlink_budget_preserves_one_maximum_uplink_batch() { + let generation = test_runtime_generation(1, ProxyConfig::default()); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation))); + let control_items = super::super::state::control_item_reserve(&runtime.limits); + let data_bytes = runtime + .limits + .pending_bytes_global + .saturating_sub(runtime.limits.control_bytes_global); + let data_items = runtime + .limits + .pending_items_global + .saturating_sub(control_items); + let uplink_bytes = runtime + .limits + .max_body_bytes + .saturating_add(runtime.limits.max_frames_per_body * QUEUE_ITEM_COST); + let downlink_bytes = data_bytes - uplink_bytes; + let downlink_items = data_items - runtime.limits.max_frames_per_body; + + assert!(runtime.try_reserve_pending( + downlink_bytes, + downlink_items, + false, + true, + )); + assert!(runtime.try_reserve_pending( + uplink_bytes, + runtime.limits.max_frames_per_body, + false, + false, + )); + assert!(!runtime.try_reserve_pending(1, 1, false, true)); + + runtime.release_pending(downlink_bytes, downlink_items, false); + runtime.release_pending( + uplink_bytes, + runtime.limits.max_frames_per_body, + false, + ); + runtime.shutdown().await; + } +} diff --git a/src/web/manager/lifecycle.rs b/src/web/manager/lifecycle.rs new file mode 100644 index 0000000..8c365c9 --- /dev/null +++ b/src/web/manager/lifecycle.rs @@ -0,0 +1,149 @@ +use std::net::IpAddr; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use tracing::info; + +use super::{ProfileKey, TokenHash, WebProcessRuntime}; +use super::state::{ + ClosedToken, decrement_map, remove_bootstrap_locked, remove_expired_locked, +}; + +impl WebProcessRuntime { + /// Removes one closed session and retains a bounded host-bound replay marker. + pub(crate) fn session_finished( + &self, + hash: TokenHash, + client_ip: IpAddr, + profile_key: ProfileKey, + profile_host: &str, + ) { + let mut state = self.state.lock(); + if state.sessions.remove(&hash).is_none() { + return; + } + decrement_map(&mut state.sessions_per_ip, &client_ip); + decrement_map(&mut state.sessions_per_profile, &profile_key); + let expiry = Instant::now() + + Duration::from_secs( + self.active_runtime + .load() + .config() + .web + .timeouts + .bootstrap_lifetime_secs, + ); + state.closed_tokens.insert( + hash, + ClosedToken { + expires_at: expiry, + host: profile_host.to_string(), + }, + ); + while state.closed_tokens.len() > self.limits.max_sessions_global.saturating_mul(16) { + let Some(oldest) = state + .closed_tokens + .iter() + .min_by_key(|(_, closed)| closed.expires_at) + .map(|(hash, _)| *hash) + else { + break; + }; + state.closed_tokens.remove(&oldest); + } + let bootstrap_hashes = state + .bootstraps + .iter() + .filter_map(|(bootstrap_hash, bootstrap)| { + bootstrap + .session + .as_ref() + .is_some_and(|session| session.token_hash() == hash) + .then_some(*bootstrap_hash) + }) + .collect::>(); + for bootstrap_hash in bootstrap_hashes { + remove_bootstrap_locked(&mut state, bootstrap_hash); + } + self.sessions_closed.fetch_add(1, Ordering::Relaxed); + } + + /// Stops issuance, closes all sessions, and joins bounded child work. + pub(crate) async fn shutdown(&self) { + self.shutdown.cancel(); + let sessions = { + let mut state = self.state.lock(); + state.closed = true; + state.bootstraps.clear(); + state.bootstraps_per_ip.clear(); + state.sessions.values().cloned().collect::>() + }; + for session in &sessions { + session.close(); + } + let timeout_secs = self + .active_runtime + .load() + .config() + .web + .timeouts + .shutdown_secs; + let waits = async { + for session in sessions { + session.wait().await; + } + }; + let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), waits).await; + self.tasks.close(); + let _ = tokio::time::timeout(Duration::from_secs(timeout_secs), self.tasks.wait()).await; + let (sessions_live, streams_live, pending_bytes, pending_items) = { + let state = self.state.lock(); + ( + state.sessions.len(), + state.streams_live, + state.pending_bytes, + state.pending_items, + ) + }; + info!( + target: "telemt::web", + sessions_created = self.sessions_created.load(Ordering::Relaxed), + sessions_closed = self.sessions_closed.load(Ordering::Relaxed), + sessions_live, + streams_opened = self.streams_opened.load(Ordering::Relaxed), + streams_rejected = self.streams_rejected.load(Ordering::Relaxed), + streams_live, + pending_bytes, + pending_items, + bytes_up = self.bytes_up.load(Ordering::Relaxed), + bytes_down = self.bytes_down.load(Ordering::Relaxed), + limit_hits = self.limit_hits.load(Ordering::Relaxed), + "WEB runtime stopped" + ); + } + + /// Expires credentials and closes idle sessions without holding locks across callbacks. + pub(super) fn cleanup(&self) { + let generation_id = self.active_runtime.load().id; + let now = Instant::now(); + let sessions = { + let mut state = self.state.lock(); + remove_expired_locked(&mut state, now); + let stale_bootstraps = state + .bootstraps + .iter() + .filter_map(|(hash, bootstrap)| { + (bootstrap.generation_id != generation_id && !bootstrap.used) + .then_some(*hash) + }) + .collect::>(); + for hash in stale_bootstraps { + remove_bootstrap_locked(&mut state, hash); + } + state.sessions.values().cloned().collect::>() + }; + for session in sessions.into_iter().filter(|session| session.is_idle(now)) { + session.close(); + } + } +} diff --git a/src/web/manager/state.rs b/src/web/manager/state.rs new file mode 100644 index 0000000..811d3da --- /dev/null +++ b/src/web/manager/state.rs @@ -0,0 +1,293 @@ +use std::collections::{HashMap, HashSet}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Instant; + +use base64::Engine as _; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use super::{ProfileKey, TOKEN_BYTES, TokenHash}; +use crate::config::{WebLimitsConfig, WebRuntimeConfig, WebRuntimeProfile}; +use crate::maestro::generation::RuntimeGeneration; +use crate::web::session::WebSession; + +/// One issued bootstrap and optional idempotent session-creation replay state. +pub(super) struct Bootstrap { + /// Generation that issued the bootstrap. + pub(super) generation_id: u64, + /// Credential and replay-state expiry deadline. + pub(super) expires_at: Instant, + /// Stable ordering point used for bounded eviction. + pub(super) issued_at: Instant, + /// Forwarded client address that owns this credential. + pub(super) issuance_ip: IpAddr, + /// Immutable profile selected during capability validation. + pub(super) profile: Arc, + /// Digest of the accepted HELLO body for idempotent retry matching. + pub(super) body_digest: TokenHash, + /// Zeroizing copy returned only for an exact session-creation retry. + pub(super) session_token: Zeroizing, + /// Created session retained while retry replay remains valid. + pub(super) session: Option>, + /// Distinguishes unused issuance quota from completed creation replay state. + pub(super) used: bool, +} + +/// Bounded replay marker for one explicitly or naturally closed session token. +pub(super) struct ClosedToken { + /// Deadline after which the token hash may be forgotten. + pub(super) expires_at: Instant, + /// Canonical host that owned the session. + pub(super) host: String, +} + +/// Token-bucket state for one process-wide creation class. +#[derive(Default)] +pub(super) struct RateState { + tokens: f64, + last: Option, +} + +struct StreamPortState { + active: HashSet, + next: u16, +} + +/// Process-wide WEB registries and quota accounting protected by one short lock. +#[derive(Default)] +pub(super) struct ManagerState { + /// Bootstrap credentials indexed by their SHA-256 token hash. + pub(super) bootstraps: HashMap, + /// Unused bootstrap ownership counts by forwarded client address. + pub(super) bootstraps_per_ip: HashMap, + /// Live sessions indexed by bearer-token hash. + pub(super) sessions: HashMap>, + /// Recently closed token hashes retained for idempotent DELETE semantics. + pub(super) closed_tokens: HashMap, + /// Live session counts by forwarded client address. + pub(super) sessions_per_ip: HashMap, + /// Live session counts by stable profile key. + pub(super) sessions_per_profile: HashMap, + /// Live relay-task counts by stable profile key. + pub(super) streams_per_profile: HashMap, + /// Process-wide live relay-task count. + pub(super) streams_live: usize, + stream_ports: HashMap<(IpAddr, SocketAddr), StreamPortState>, + /// Total process-wide queued byte reservation. + pub(super) pending_bytes: usize, + /// Total process-wide queued item reservation. + pub(super) pending_items: usize, + /// Portion of queued bytes charged to the control reserve. + pub(super) pending_control_bytes: usize, + /// Portion of queued items charged to the control reserve. + pub(super) pending_control_items: usize, + /// Bootstrap issuance rate limiter. + pub(super) bootstrap_rate: RateState, + /// Session creation rate limiter. + pub(super) session_rate: RateState, + /// Logical-stream creation rate limiter. + pub(super) stream_rate: RateState, + /// Process shutdown admission latch. + pub(super) closed: bool, +} + +/// Generates one collision-checked credential and its stable hash key. +pub(super) fn new_unique_token( + generation: &RuntimeGeneration, + state: &ManagerState, +) -> Option<(String, TokenHash)> { + for _ in 0..8 { + let mut raw = [0u8; TOKEN_BYTES]; + generation.rng.fill(&mut raw); + let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); + let hash = Sha256::digest(raw).into(); + if !state.bootstraps.contains_key(&hash) + && !state.sessions.contains_key(&hash) + && !state.closed_tokens.contains_key(&hash) + { + return Some((token, hash)); + } + } + None +} + +/// Returns the precomputed capability as the stable process profile key. +pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey { + profile.capability +} + +/// Re-resolves an issued profile against the active generation without weakening identity. +pub(super) fn matching_profile( + runtime: &WebRuntimeConfig, + expected: &WebRuntimeProfile, +) -> Option> { + runtime + .profiles + .iter() + .find(|profile| { + profile.host == expected.host + && profile.public_addr == expected.public_addr + && profile.user == expected.user + && profile.secret_mode == expected.secret_mode + && profile.capability == expected.capability + }) + .cloned() +} + +/// Applies one token-bucket admission decision at a caller-supplied monotonic time. +pub(super) fn allow_rate( + state: &mut RateState, + now: Instant, + per_minute: u32, + burst: u32, +) -> bool { + let burst = f64::from(burst); + if let Some(last) = state.last { + let elapsed = now.saturating_duration_since(last).as_secs_f64(); + state.tokens = + (state.tokens + elapsed * f64::from(per_minute) / 60.0).min(burst); + } else { + state.tokens = burst; + } + state.last = Some(now); + if state.tokens < 1.0 { + return false; + } + state.tokens -= 1.0; + true +} + +/// Evicts the oldest unused bootstrap while preserving used retry state. +pub(super) fn evict_oldest_unused_bootstrap(state: &mut ManagerState) -> bool { + let Some(hash) = state + .bootstraps + .iter() + .filter(|(_, bootstrap)| !bootstrap.used) + .min_by_key(|(_, bootstrap)| bootstrap.issued_at) + .map(|(hash, _)| *hash) + else { + return false; + }; + remove_bootstrap_locked(state, hash); + true +} + +/// Removes expired bootstrap and closed-token entries while the manager lock is held. +pub(super) fn remove_expired_locked(state: &mut ManagerState, now: Instant) { + let expired = state + .bootstraps + .iter() + .filter_map(|(hash, bootstrap)| (now > bootstrap.expires_at).then_some(*hash)) + .collect::>(); + for hash in expired { + remove_bootstrap_locked(state, hash); + } + state + .closed_tokens + .retain(|_, closed| now <= closed.expires_at); +} + +/// Removes one bootstrap and releases its per-address issuance quota when unused. +pub(super) fn remove_bootstrap_locked(state: &mut ManagerState, hash: TokenHash) { + let Some(bootstrap) = state.bootstraps.remove(&hash) else { + return; + }; + if !bootstrap.used { + decrement_map(&mut state.bootstraps_per_ip, &bootstrap.issuance_ip); + } +} + +/// Decrements one counted owner and removes its map entry at zero. +pub(super) fn decrement_map(values: &mut HashMap, key: &Q) +where + K: std::borrow::Borrow + std::hash::Hash + Eq, + Q: std::hash::Hash + Eq + ?Sized, +{ + let remove = if let Some(value) = values.get_mut(key) { + *value = value.saturating_sub(1); + *value == 0 + } else { + false + }; + if remove { + values.remove(key); + } +} + +/// Computes the process-wide item reserve required for session control progress. +pub(super) fn control_item_reserve(limits: &WebLimitsConfig) -> usize { + limits.max_sessions_global.saturating_mul( + 16usize.saturating_add(limits.max_streams_per_session.saturating_mul(3)), + ) +} + +/// Allocates a non-zero source port unique among live streams for one KDF route. +pub(super) fn allocate_stream_port( + state: &mut ManagerState, + client_ip: IpAddr, + public_addr: SocketAddr, +) -> Option { + let ports = state + .stream_ports + .entry((client_ip, public_addr)) + .or_insert_with(|| StreamPortState { + active: HashSet::new(), + next: 1, + }); + for _ in 0..u16::MAX { + let candidate = ports.next; + ports.next = ports.next.checked_add(1).unwrap_or(1); + if ports.active.insert(candidate) { + return Some(candidate); + } + } + None +} + +/// Releases one source port and reclaims empty per-route allocator state. +pub(super) fn release_stream_port( + state: &mut ManagerState, + client_ip: IpAddr, + public_addr: SocketAddr, + peer_port: u16, +) -> bool { + let key = (client_ip, public_addr); + let Some(ports) = state.stream_ports.get_mut(&key) else { + return false; + }; + let removed = ports.active.remove(&peer_port); + if ports.active.is_empty() { + state.stream_ports.remove(&key); + } + removed +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthetic_ports_are_unique_per_live_route_and_state_is_reclaimed() { + let mut state = ManagerState::default(); + let client_ip = "192.0.2.10".parse().unwrap(); + let public_addr = "203.0.113.10:443".parse().unwrap(); + let first = allocate_stream_port(&mut state, client_ip, public_addr).unwrap(); + let second = allocate_stream_port(&mut state, client_ip, public_addr).unwrap(); + + assert_ne!(first, second); + assert!(release_stream_port( + &mut state, + client_ip, + public_addr, + first, + )); + assert!(release_stream_port( + &mut state, + client_ip, + public_addr, + second, + )); + assert!(state.stream_ports.is_empty()); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..b880985 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,14 @@ +//! Bounded WEB carrier ingress behind a trusted external TLS terminator. + +/// Browser bridge generation for the serialized HTTPS carrier. +pub(crate) mod bridge; +/// Shared binary frame codec and protocol constants. +pub(crate) mod frame; +/// Plain HTTP ingress and decoy routing behind external TLS termination. +pub(crate) mod http; +/// Process-wide credentials, quotas, memory budgets, and shutdown ownership. +pub(crate) mod manager; +/// Resumable carrier sessions and logical-stream state machines. +pub(crate) mod session; +/// AsyncRead and AsyncWrite adapter for one logical MTProxy stream. +pub(crate) mod stream; diff --git a/src/web/session.rs b/src/web/session.rs new file mode 100644 index 0000000..0646fe2 --- /dev/null +++ b/src/web/session.rs @@ -0,0 +1,355 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io; +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::task::{Context, Poll, Waker}; +use std::time::{Duration, Instant}; + +use bytes::{Bytes, BytesMut}; +use parking_lot::Mutex; +use tokio::io::ReadBuf; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +use crate::config::{WebLimitsConfig, WebRuntimeProfile, WebTimeoutsConfig}; +use crate::web::frame::{self, FrameType}; +use crate::web::manager::{ProfileKey, TokenHash, WebProcessRuntime}; + +// Backend tasks own generation admission and authenticated MTProxy relay lifetimes. +mod backend; +// Downlink queues own cursor replay, flow control, and memory reservations. +mod downlink; +// Uplink batches own exactly-once sequencing and client-frame validation. +mod uplink; + +/// Conservative allocator and container overhead charged to every queued item. +pub(crate) const QUEUE_ITEM_COST: usize = 256; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PendingClass { + Uplink, + Downlink, + Control, +} + +struct InboundChunk { + bytes: Bytes, + offset: usize, +} + +struct StreamState { + inbound: VecDeque, + receive_window: u32, + send_credit: u64, + read_waker: Option, + write_waker: Option, +} + +struct QueuedFrame { + encoded: BytesMut, + frame_type: FrameType, + stream_id: u32, + control: bool, + cost: usize, +} + +struct DownBatch { + body: Bytes, + base_cursor: u64, + next_cursor: u64, + data_bytes: usize, + data_items: usize, + control_bytes: usize, + control_items: usize, +} + +struct SessionState { + streams: HashMap, + active_peer_ports: HashSet, + closed_streams: HashSet, + closed_order: VecDeque, + pending_frames: VecDeque, + pending_windows: HashMap, + unacked: Option, + down_cursor: u64, + down_epoch: u64, + last_up_sequence: u64, + last_up_digest: TokenHash, + pending_bytes: usize, + pending_items: usize, + pending_control_bytes: usize, + pending_control_items: usize, + last_activity: Instant, + closed: bool, +} + +/// One bounded WEB carrier session containing logical MTProxy streams. +pub(crate) struct WebSession { + manager: std::sync::Weak, + token_hash: TokenHash, + client_ip: IpAddr, + profile: Arc, + profile_key: ProfileKey, + limits: WebLimitsConfig, + timeouts: WebTimeoutsConfig, + state: Mutex, + down_notify: Arc, + cancel: CancellationToken, + tasks_live: AtomicUsize, + tasks_done: Arc, + finished: AtomicBool, + up_active: AtomicBool, +} + +/// One successful downlink poll result. +pub(crate) struct PollResult { + /// Encoded downlink frame batch, or an empty long-poll result. + pub(crate) body: Bytes, + /// Cursor the client must present on its next downlink request. + pub(crate) next_cursor: u64, +} + +impl WebSession { + #[allow(clippy::too_many_arguments)] + /// Creates one carrier session with immutable ownership and allocation policy. + pub(crate) fn new( + manager: std::sync::Weak, + token_hash: TokenHash, + client_ip: IpAddr, + profile: Arc, + profile_key: ProfileKey, + limits: WebLimitsConfig, + timeouts: WebTimeoutsConfig, + ) -> Arc { + Arc::new(Self { + manager, + token_hash, + client_ip, + profile, + profile_key, + limits, + timeouts, + state: Mutex::new(SessionState { + streams: HashMap::new(), + active_peer_ports: HashSet::new(), + closed_streams: HashSet::new(), + closed_order: VecDeque::new(), + pending_frames: VecDeque::new(), + pending_windows: HashMap::new(), + unacked: None, + down_cursor: 0, + down_epoch: 0, + last_up_sequence: 0, + last_up_digest: [0; 32], + pending_bytes: 0, + pending_items: 0, + pending_control_bytes: 0, + pending_control_items: 0, + last_activity: Instant::now(), + closed: false, + }), + down_notify: Arc::new(Notify::new()), + cancel: CancellationToken::new(), + tasks_live: AtomicUsize::new(0), + tasks_done: Arc::new(Notify::new()), + finished: AtomicBool::new(false), + up_active: AtomicBool::new(false), + }) + } + + /// Returns the stable hashed token identity without exposing the credential. + pub(crate) fn token_hash(&self) -> TokenHash { + self.token_hash + } + + /// Checks the canonical virtual host that owns this bearer session. + pub(crate) fn matches_host(&self, host: &str) -> bool { + self.profile.host == host + } + + /// Closes carrier state while relay tasks retain their admission until exit. + pub(crate) fn close(&self) { + let (data_bytes, data_items, control_bytes, control_items) = { + let mut state = self.state.lock(); + if state.closed { + return; + } + state.closed = true; + for stream in state.streams.values_mut() { + if let Some(waker) = stream.read_waker.take() { + waker.wake(); + } + if let Some(waker) = stream.write_waker.take() { + waker.wake(); + } + } + state.streams.clear(); + state.pending_frames.clear(); + state.pending_windows.clear(); + state.unacked = None; + let control_bytes = state.pending_control_bytes; + let control_items = state.pending_control_items; + let data_bytes = state.pending_bytes.saturating_sub(control_bytes); + let data_items = state.pending_items.saturating_sub(control_items); + state.pending_bytes = 0; + state.pending_items = 0; + state.pending_control_bytes = 0; + state.pending_control_items = 0; + (data_bytes, data_items, control_bytes, control_items) + }; + self.cancel.cancel(); + self.down_notify.notify_waiters(); + if let Some(manager) = self.manager.upgrade() { + manager.release_pending(data_bytes, data_items, false); + manager.release_pending(control_bytes, control_items, true); + if !self.finished.swap(true, Ordering::AcqRel) { + manager.session_finished( + self.token_hash, + self.client_ip, + self.profile_key, + &self.profile.host, + ); + } + } + } + + /// Waits for all logical-stream tasks after admission has closed. + pub(crate) async fn wait(&self) { + loop { + let notified = self.tasks_done.notified(); + if self.tasks_live.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + } + + /// Returns whether reconnect grace elapsed without activity. + pub(crate) fn is_idle(&self, now: Instant) -> bool { + let state = self.state.lock(); + !state.closed + && now.saturating_duration_since(state.last_activity) + >= Duration::from_secs(self.timeouts.reconnect_grace_secs) + } + + /// Polls client-to-server bytes and returns consumed flow-control credit. + pub(super) fn poll_read( + &self, + stream_id: u32, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + let mut state = self.state.lock(); + let (count, finished) = { + let Some(stream) = state.streams.get_mut(&stream_id) else { + return Poll::Ready(Ok(())); + }; + let Some(chunk) = stream.inbound.front_mut() else { + stream.read_waker = Some(cx.waker().clone()); + return Poll::Pending; + }; + let available = &chunk.bytes[chunk.offset..]; + let count = available.len().min(output.remaining()); + output.put_slice(&available[..count]); + chunk.offset += count; + let finished = chunk.offset == chunk.bytes.len(); + if finished { + stream.inbound.pop_front(); + } + stream.receive_window = stream.receive_window.saturating_add(count as u32); + (count, finished) + }; + let overhead = if finished { QUEUE_ITEM_COST } else { 0 }; + self.release_locked(&mut state, count + overhead, usize::from(finished), false); + if !self.queue_window_locked(&mut state, stream_id, count as u32) { + drop(state); + self.close(); + return Poll::Ready(Err(io::Error::other("WEB session control budget exhausted"))); + } + Poll::Ready(Ok(())) + } + + /// Polls server-to-client writes against stream credit and bounded queues. + pub(super) fn poll_write( + &self, + stream_id: u32, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let mut state = self.state.lock(); + let Some(stream) = state.streams.get_mut(&stream_id) else { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "WEB logical stream is closed", + ))); + }; + let count = input + .len() + .min(frame::DATA_CHUNK_BYTES) + .min(self.limits.max_frame_payload_bytes) + .min(stream.send_credit as usize); + if count == 0 { + stream.write_waker = Some(cx.waker().clone()); + return Poll::Pending; + } + if !self.queue_data_locked(&mut state, stream_id, &input[..count]) { + if let Some(stream) = state.streams.get_mut(&stream_id) { + stream.write_waker = Some(cx.waker().clone()); + } + return Poll::Pending; + } + let Some(stream) = state.streams.get_mut(&stream_id) else { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "WEB logical stream is closed", + ))); + }; + stream.send_credit -= count as u64; + state.last_activity = Instant::now(); + drop(state); + self.down_notify.notify_waiters(); + Poll::Ready(Ok(count)) + } + + /// Returns the process queue-capacity notification source while the manager lives. + pub(super) fn budget_notify(&self) -> Option> { + self.manager.upgrade().map(|manager| manager.budget_notify()) + } + + fn release_stream_reservation(&self, peer_port: u16) { + let removed = self.state.lock().active_peer_ports.remove(&peer_port); + if removed + && let Some(manager) = self.manager.upgrade() + { + manager.release_stream( + self.profile_key, + self.client_ip, + self.profile.public_addr, + peer_port, + ); + } + } +} + +fn inbound_queue_cost(queue: &VecDeque) -> (usize, usize) { + let bytes = queue.iter().fold(0usize, |total, chunk| { + total.saturating_add(chunk.bytes.len().saturating_sub(chunk.offset) + QUEUE_ITEM_COST) + }); + (bytes, queue.len()) +} + +fn remember_closed(state: &mut SessionState, stream_id: u32, limit: usize) { + if !state.closed_streams.insert(stream_id) { + return; + } + state.closed_order.push_back(stream_id); + while state.closed_order.len() > limit { + if let Some(oldest) = state.closed_order.pop_front() { + state.closed_streams.remove(&oldest); + } + } +} diff --git a/src/web/session/backend.rs b/src/web/session/backend.rs new file mode 100644 index 0000000..50f6374 --- /dev/null +++ b/src/web/session/backend.rs @@ -0,0 +1,168 @@ +use std::io; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use crate::web::frame::FrameType; +use crate::web::stream::WebLogicalStream; +use crate::proxy::shared_state::ConntrackClosePolicy; + +use super::{WebSession, inbound_queue_cost, remember_closed}; + +impl WebSession { + /// Starts one owned inner handshake and relay task for an admitted stream. + pub(super) fn spawn_stream(self: &Arc, stream_id: u32, peer_port: u16) { + let Some(manager) = self.manager.upgrade() else { + self.stream_finished(stream_id, peer_port); + return; + }; + let generation = manager.active_generation(); + let Ok(connection_permit) = generation.max_connections.clone().try_acquire_owned() else { + manager.record_stream_rejected(); + self.stream_finished(stream_id, peer_port); + return; + }; + let Some(handshake_permit) = manager.try_stream_handshake() else { + self.stream_finished(stream_id, peer_port); + return; + }; + let deps = generation.client_runtime_deps(); + let replay_checker = Arc::clone(&generation.replay_checker); + let session = Arc::clone(self); + let cancel = self.cancel.clone(); + self.tasks_live.fetch_add(1, Ordering::AcqRel); + let spawned = generation.spawn_session(async move { + let _connection_permit = connection_permit; + let _completion = StreamCompletion { + session: Arc::clone(&session), + stream_id, + peer_port, + }; + let stream = WebLogicalStream::new(Arc::clone(&session), stream_id); + tokio::select! { + _ = cancel.cancelled() => {} + _ = run_stream( + Arc::clone(&session), + stream, + deps, + replay_checker, + handshake_permit, + peer_port, + ) => {} + } + }); + if !spawned { + self.tasks_live.fetch_sub(1, Ordering::AcqRel); + self.stream_finished(stream_id, peer_port); + self.tasks_done.notify_waiters(); + } + } + + fn stream_finished(&self, stream_id: u32, peer_port: u16) { + let (queued, reserved) = { + let mut state = self.state.lock(); + let reserved = state.active_peer_ports.remove(&peer_port); + let queued = state.streams.remove(&stream_id).map(|stream| { + let (bytes, items) = inbound_queue_cost(&stream.inbound); + self.release_locked(&mut state, bytes, items, false); + remember_closed( + &mut state, + stream_id, + self.limits.max_tombstones_per_session, + ); + self.queue_control_locked(&mut state, FrameType::Close, stream_id, &[]) + }); + (queued, reserved) + }; + if reserved + && let Some(manager) = self.manager.upgrade() + { + manager.release_stream( + self.profile_key, + self.client_ip, + self.profile.public_addr, + peer_port, + ); + } + if let Some(queued) = queued { + if !queued { + self.close(); + } + self.down_notify.notify_waiters(); + } + } +} + +struct StreamCompletion { + session: Arc, + stream_id: u32, + peer_port: u16, +} + +impl Drop for StreamCompletion { + fn drop(&mut self) { + self.session.stream_finished(self.stream_id, self.peer_port); + if self.session.tasks_live.fetch_sub(1, Ordering::AcqRel) == 1 { + self.session.tasks_done.notify_waiters(); + } + } +} + +async fn run_stream( + session: Arc, + stream: WebLogicalStream, + deps: crate::proxy::authenticated::ClientRuntimeDeps, + replay_checker: Arc, + handshake_permit: tokio::sync::OwnedSemaphorePermit, + peer_port: u16, +) { + use tokio::io::AsyncReadExt; + + use crate::protocol::constants::HANDSHAKE_LEN; + use crate::proxy::authenticated::run_authenticated; + use crate::proxy::handshake::handle_mtproto_handshake_for_web_user; + + let (mut reader, writer) = tokio::io::split(stream); + let mut handshake = [0u8; HANDSHAKE_LEN]; + let peer = std::net::SocketAddr::new(session.client_ip, peer_port); + deps.stats.increment_connects_all(); + let handshake_result = tokio::time::timeout( + Duration::from_secs(session.timeouts.stream_handshake_secs), + async { + reader.read_exact(&mut handshake).await?; + Ok::<_, io::Error>( + handle_mtproto_handshake_for_web_user( + &handshake, + reader, + writer, + peer, + &deps.config, + &replay_checker, + &session.profile.user, + session.profile.secret_mode, + &deps.shared, + ) + .await, + ) + }, + ) + .await; + drop(handshake_permit); + let Ok(Ok(crate::error::HandshakeResult::Success((reader, writer, success)))) = + handshake_result + else { + deps.stats + .increment_connects_bad_with_class("web_mtproto_bad_client"); + return; + }; + let _ = run_authenticated( + reader, + writer, + success, + deps, + session.profile.public_addr, + peer, + ConntrackClosePolicy::Suppress, + ) + .await; +} diff --git a/src/web/session/downlink.rs b/src/web/session/downlink.rs new file mode 100644 index 0000000..40ef15d --- /dev/null +++ b/src/web/session/downlink.rs @@ -0,0 +1,494 @@ +use std::time::{Duration, Instant}; + +use bytes::{BufMut, Bytes, BytesMut}; + +use super::{ + DownBatch, PendingClass, PollResult, QUEUE_ITEM_COST, QueuedFrame, SessionState, + WebSession, +}; +use crate::web::frame::{self, FrameType}; +use crate::web::manager::ManagerError; + +impl WebSession { + /// Polls pending downlink frames with cursor replay and newest-poll-wins semantics. + pub(crate) async fn poll_down(&self, cursor: u64) -> Result { + let epoch = { + let mut state = self.state.lock(); + if state.closed { + return Err(ManagerError::Closed); + } + state.last_activity = Instant::now(); + if let Some(unacked) = &state.unacked { + if cursor == unacked.base_cursor { + return Ok(PollResult { + body: unacked.body.clone(), + next_cursor: unacked.next_cursor, + }); + } + if cursor != unacked.next_cursor { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + self.release_unacked_locked(&mut state); + } else if cursor != state.down_cursor { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + state.down_epoch = state.down_epoch.wrapping_add(1).max(1); + state.down_epoch + }; + self.down_notify.notify_waiters(); + + let deadline = Duration::from_secs(self.timeouts.long_poll_secs); + let poll = async { + loop { + let notified = self.down_notify.notified(); + { + let mut state = self.state.lock(); + if state.down_epoch != epoch { + return Ok(PollResult { + body: Bytes::new(), + next_cursor: cursor, + }); + } + if !state.pending_frames.is_empty() { + let batch = match self.take_down_batch_locked(&mut state, cursor) { + Ok(batch) => batch, + Err(error) => { + drop(state); + self.close(); + return Err(error); + } + }; + let result = PollResult { + body: batch.body.clone(), + next_cursor: batch.next_cursor, + }; + if let Some(manager) = self.manager.upgrade() { + manager.record_down(result.body.len()); + } + state.unacked = Some(batch); + return Ok(result); + } + if state.closed { + return Err(ManagerError::Closed); + } + } + notified.await; + } + }; + match tokio::time::timeout(deadline, poll).await { + Ok(result) => result, + Err(_) => { + let mut state = self.state.lock(); + if state.down_epoch == epoch { + state.last_activity = Instant::now(); + } + Ok(PollResult { + body: Bytes::new(), + next_cursor: cursor, + }) + } + } + } + + /// Reserves session and process queue capacity while the session lock is held. + pub(super) fn reserve_locked( + &self, + state: &mut SessionState, + bytes: usize, + items: usize, + class: PendingClass, + ) -> bool { + if bytes == 0 && items == 0 { + return true; + } + let data_byte_limit = self + .limits + .pending_bytes_per_session + .saturating_sub(self.limits.control_bytes_per_session); + let item_reserve = 16usize.saturating_add( + self.limits.max_streams_per_session.saturating_mul(3), + ); + let data_item_limit = self + .limits + .pending_items_per_session + .saturating_sub(item_reserve); + if state.closed { + return false; + } + let control = class == PendingClass::Control; + let fits = if control { + bytes <= self.limits.control_bytes_per_session + && items <= item_reserve + && state.pending_bytes + <= self.limits.pending_bytes_per_session.saturating_sub(bytes) + && state.pending_items + <= self.limits.pending_items_per_session.saturating_sub(items) + && state.pending_control_bytes + <= self.limits.control_bytes_per_session.saturating_sub(bytes) + && state.pending_control_items <= item_reserve.saturating_sub(items) + } else { + let data_bytes = state + .pending_bytes + .saturating_sub(state.pending_control_bytes); + let data_items = state + .pending_items + .saturating_sub(state.pending_control_items); + let (byte_limit, item_limit) = if class == PendingClass::Downlink { + let uplink_bytes = self + .limits + .max_body_bytes + .saturating_add( + self.limits + .max_frames_per_body + .saturating_mul(QUEUE_ITEM_COST), + ); + ( + data_byte_limit.saturating_sub(uplink_bytes), + data_item_limit.saturating_sub(self.limits.max_frames_per_body), + ) + } else { + (data_byte_limit, data_item_limit) + }; + bytes <= byte_limit + && items <= item_limit + && data_bytes <= byte_limit - bytes + && data_items <= item_limit - items + }; + if !fits { + return false; + } + let Some(manager) = self.manager.upgrade() else { + return false; + }; + if !manager.try_reserve_pending( + bytes, + items, + control, + class == PendingClass::Downlink, + ) { + return false; + } + state.pending_bytes += bytes; + state.pending_items += items; + if control { + state.pending_control_bytes += bytes; + state.pending_control_items += items; + } + true + } + + /// Releases session and process queue capacity while the session lock is held. + pub(super) fn release_locked( + &self, + state: &mut SessionState, + bytes: usize, + items: usize, + control: bool, + ) { + state.pending_bytes = state.pending_bytes.saturating_sub(bytes); + state.pending_items = state.pending_items.saturating_sub(items); + if control { + state.pending_control_bytes = state.pending_control_bytes.saturating_sub(bytes); + state.pending_control_items = state.pending_control_items.saturating_sub(items); + } + if let Some(manager) = self.manager.upgrade() { + manager.release_pending(bytes, items, control); + } + } + + /// Coalesces one flow-control update into the bounded control queue. + pub(super) fn queue_window_locked( + &self, + state: &mut SessionState, + stream_id: u32, + amount: u32, + ) -> bool { + if amount == 0 { + return true; + } + if let Some(index) = state.pending_windows.get(&stream_id).copied() + && let Some(queued) = state.pending_frames.get_mut(index) + { + let previous = u32::from_be_bytes( + queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4] + .try_into() + .unwrap_or([0; 4]), + ); + if let Some(total) = previous.checked_add(amount) { + queued.encoded[frame::HEADER_BYTES..frame::HEADER_BYTES + 4] + .copy_from_slice(&total.to_be_bytes()); + self.down_notify.notify_waiters(); + return true; + } + } + self.queue_control_locked( + state, + FrameType::Window, + stream_id, + &frame::window_payload(amount), + ) + } + + /// Appends one control frame under both reserved queue budgets. + pub(super) fn queue_control_locked( + &self, + state: &mut SessionState, + frame_type: FrameType, + stream_id: u32, + payload: &[u8], + ) -> bool { + self.queue_frame_locked(state, frame_type, stream_id, payload, true) + } + + /// Appends one server-to-client DATA frame under downlink data budgets. + pub(super) fn queue_data_locked( + &self, + state: &mut SessionState, + stream_id: u32, + payload: &[u8], + ) -> bool { + let can_coalesce = state.pending_frames.back().is_some_and(|last| { + last.frame_type == FrameType::Data + && last.stream_id == stream_id + && last.encoded.len() - frame::HEADER_BYTES + payload.len() + <= self.limits.max_frame_payload_bytes + }); + if can_coalesce { + if !self.reserve_locked(state, payload.len(), 0, PendingClass::Downlink) { + return false; + } + let Some(last) = state.pending_frames.back_mut() else { + return false; + }; + last.encoded.extend_from_slice(payload); + last.cost += payload.len(); + let payload_len = (last.encoded.len() - frame::HEADER_BYTES) as u32; + last.encoded[4..8].copy_from_slice(&payload_len.to_be_bytes()); + return true; + } + self.queue_frame_locked(state, FrameType::Data, stream_id, payload, false) + } + + fn queue_frame_locked( + &self, + state: &mut SessionState, + frame_type: FrameType, + stream_id: u32, + payload: &[u8], + control: bool, + ) -> bool { + let cost = frame::HEADER_BYTES + payload.len() + QUEUE_ITEM_COST; + let class = if control { + PendingClass::Control + } else { + PendingClass::Downlink + }; + if !self.reserve_locked(state, cost, 1, class) { + return false; + } + let mut encoded = BytesMut::with_capacity(frame::HEADER_BYTES + payload.len()); + encoded.put_u8(frame_type as u8); + encoded.put_u8((stream_id >> 16) as u8); + encoded.put_u8((stream_id >> 8) as u8); + encoded.put_u8(stream_id as u8); + encoded.put_u32(payload.len() as u32); + encoded.extend_from_slice(payload); + let index = state.pending_frames.len(); + state.pending_frames.push_back(QueuedFrame { + encoded, + frame_type, + stream_id, + control, + cost, + }); + if frame_type == FrameType::Window { + state.pending_windows.insert(stream_id, index); + } + self.down_notify.notify_waiters(); + true + } + + fn take_down_batch_locked( + &self, + state: &mut SessionState, + cursor: u64, + ) -> Result { + let next_cursor = state + .down_cursor + .checked_add(1) + .ok_or(ManagerError::Protocol)?; + let mut count = 0usize; + let mut body_len = 0usize; + for queued in &state.pending_frames { + if count >= self.limits.max_frames_per_body + || (count != 0 + && body_len.saturating_add(queued.encoded.len()) + > self.limits.carrier_batch_bytes) + { + break; + } + body_len += queued.encoded.len(); + count += 1; + } + let mut body = BytesMut::with_capacity(body_len); + let mut data_bytes = 0usize; + let mut data_items = 0usize; + let mut control_bytes = 0usize; + let mut control_items = 0usize; + for index in 0..count { + let Some(queued) = state.pending_frames.get(index) else { + break; + }; + if queued.frame_type == FrameType::Window + && state.pending_windows.get(&queued.stream_id) == Some(&index) + { + state.pending_windows.remove(&queued.stream_id); + } + } + for _ in 0..count { + let Some(queued) = state.pending_frames.pop_front() else { + break; + }; + body.extend_from_slice(&queued.encoded); + if queued.control { + control_bytes += queued.cost; + control_items += 1; + } else { + data_bytes += queued.cost; + data_items += 1; + } + } + for index in state.pending_windows.values_mut() { + *index = index.saturating_sub(count); + } + state.down_cursor = next_cursor; + Ok(DownBatch { + body: body.freeze(), + base_cursor: cursor, + next_cursor, + data_bytes, + data_items, + control_bytes, + control_items, + }) + } + + fn release_unacked_locked(&self, state: &mut SessionState) { + let Some(batch) = state.unacked.take() else { + return; + }; + self.release_locked(state, batch.data_bytes, batch.data_items, false); + self.release_locked(state, batch.control_bytes, batch.control_items, true); + for stream in state.streams.values_mut() { + if let Some(waker) = stream.write_waker.take() { + waker.wake(); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + use std::sync::Arc; + + use crate::config::{ + WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig, + }; + use crate::web::manager::WebProcessRuntime; + + fn session() -> Arc { + let profile = Arc::new(WebRuntimeProfile { + host: "proxy.example.com".to_string(), + public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), + user: "alice".to_string(), + secret_mode: WebSecretMode::Plain, + capability: [0; 32], + max_sessions: 1, + max_streams: 1, + max_streams_per_session: 1, + }); + WebSession::new( + std::sync::Weak::::new(), + [1; 32], + "192.0.2.10".parse().unwrap(), + profile, + [2; 32], + WebLimitsConfig::default(), + WebTimeoutsConfig::default(), + ) + } + + fn queue_close(session: &WebSession) { + let encoded = frame::encode(FrameType::Close, 1, &[]); + session.state.lock().pending_frames.push_back(QueuedFrame { + encoded: BytesMut::from(encoded.as_ref()), + frame_type: FrameType::Close, + stream_id: 1, + control: true, + cost: frame::HEADER_BYTES + QUEUE_ITEM_COST, + }); + } + + #[tokio::test] + async fn downlink_replays_unacknowledged_batch_byte_for_byte() { + let session = session(); + queue_close(&session); + let first = session.poll_down(0).await.unwrap(); + let replay = session.poll_down(0).await.unwrap(); + assert_eq!(first.next_cursor, 1); + assert_eq!(replay.next_cursor, 1); + assert_eq!(first.body, replay.body); + } + + #[tokio::test] + async fn invalid_or_overflowing_cursor_closes_session() { + let invalid = session(); + assert!(matches!( + invalid.poll_down(1).await, + Err(ManagerError::Protocol) + )); + assert!(invalid.state.lock().closed); + + let overflow = session(); + { + let mut state = overflow.state.lock(); + state.down_cursor = u64::MAX; + } + queue_close(&overflow); + assert!(matches!( + overflow.poll_down(u64::MAX).await, + Err(ManagerError::Protocol) + )); + assert!(overflow.state.lock().closed); + } + + #[tokio::test] + async fn newer_poll_supersedes_older_poll_without_closing_session() { + let session = session(); + let first_session = Arc::clone(&session); + let first = tokio::spawn(async move { first_session.poll_down(0).await }); + while session.state.lock().down_epoch < 1 { + tokio::task::yield_now().await; + } + let second_session = Arc::clone(&session); + let second = tokio::spawn(async move { second_session.poll_down(0).await }); + while session.state.lock().down_epoch < 2 { + tokio::task::yield_now().await; + } + let superseded = tokio::time::timeout(Duration::from_secs(1), first) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(superseded.body.is_empty()); + assert_eq!(superseded.next_cursor, 0); + assert!(!session.state.lock().closed); + second.abort(); + } +} diff --git a/src/web/session/uplink.rs b/src/web/session/uplink.rs new file mode 100644 index 0000000..d2285d1 --- /dev/null +++ b/src/web/session/uplink.rs @@ -0,0 +1,431 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Instant; + +use bytes::Bytes; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use super::{ + InboundChunk, PendingClass, QUEUE_ITEM_COST, SessionState, StreamState, WebSession, + inbound_queue_cost, remember_closed, +}; +use crate::web::frame::{self, Frame, FrameType}; +use crate::web::manager::{ManagerError, TokenHash}; + +impl WebSession { + /// Applies one exactly-once uplink batch. + pub(crate) fn process_up( + self: &Arc, + sequence: u64, + body: &[u8], + ) -> Result { + if self + .up_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(ManagerError::Concurrent); + } + let _uplink = UplinkGuard(&self.up_active); + let frames = match frame::parse_all(body, &self.limits) { + Ok(frames) => frames, + Err(_) => { + self.close(); + return Err(ManagerError::Protocol); + } + }; + if frames + .iter() + .copied() + .any(|value| frame::validate_client_shape(value).is_err()) + { + self.close(); + return Err(ManagerError::Protocol); + } + let digest: TokenHash = Sha256::digest(body).into(); + let mut opened = Vec::new(); + let result = { + let mut state = self.state.lock(); + if state.closed { + return Err(ManagerError::Closed); + } + state.last_activity = Instant::now(); + if sequence == state.last_up_sequence && sequence != 0 { + return if bool::from(state.last_up_digest.ct_eq(&digest)) { + Ok(sequence) + } else { + drop(state); + self.close(); + Err(ManagerError::Protocol) + }; + } + if sequence == 0 || sequence != state.last_up_sequence.saturating_add(1) { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + if !validate_batch(&state, &frames) { + drop(state); + self.close(); + return Err(ManagerError::Protocol); + } + let (reserve_bytes, reserve_items) = inbound_reservation(&state, &frames); + if !self.reserve_locked( + &mut state, + reserve_bytes, + reserve_items, + PendingClass::Uplink, + ) { + return Err(ManagerError::Backpressure); + } + let mut unused_bytes = reserve_bytes; + let mut unused_items = reserve_items; + let applied = self.apply_batch_locked( + &mut state, + &frames, + &mut opened, + &mut unused_bytes, + &mut unused_items, + ); + self.release_locked(&mut state, unused_bytes, unused_items, false); + if !applied { + Err(ManagerError::Closed) + } else { + state.last_up_sequence = sequence; + state.last_up_digest = digest; + Ok(sequence) + } + }; + if matches!(result, Err(ManagerError::Backpressure)) { + return result; + } + if result.is_err() { + self.close(); + for (_, peer_port) in opened { + self.release_stream_reservation(peer_port); + } + return result; + } + for (stream_id, peer_port) in opened { + self.spawn_stream(stream_id, peer_port); + } + if let Some(manager) = self.manager.upgrade() { + manager.record_up(body.len()); + } + result + } + + fn apply_batch_locked( + &self, + state: &mut SessionState, + frames: &[Frame<'_>], + opened: &mut Vec<(u32, u16)>, + unused_bytes: &mut usize, + unused_items: &mut usize, + ) -> bool { + for value in frames { + if value.stream_id == 0 { + continue; + } + let was_closed = state.closed_streams.contains(&value.stream_id); + match value.frame_type { + FrameType::Open => { + let Some(peer_port) = self.reserve_stream_locked(state) else { + remember_closed( + state, + value.stream_id, + self.limits.max_tombstones_per_session, + ); + if !self.queue_control_locked( + state, + FrameType::Close, + value.stream_id, + &[], + ) { + return false; + } + continue; + }; + state.streams.insert( + value.stream_id, + StreamState { + inbound: VecDeque::new(), + receive_window: frame::INITIAL_STREAM_WINDOW, + send_credit: u64::from(frame::INITIAL_STREAM_WINDOW), + read_waker: None, + write_waker: None, + }, + ); + opened.push((value.stream_id, peer_port)); + } + FrameType::Data if !was_closed => { + let Some(stream) = state.streams.get_mut(&value.stream_id) else { + return false; + }; + stream.receive_window -= value.payload.len() as u32; + stream.inbound.push_back(InboundChunk { + bytes: Bytes::copy_from_slice(value.payload), + offset: 0, + }); + *unused_bytes = unused_bytes + .saturating_sub(value.payload.len() + QUEUE_ITEM_COST); + *unused_items = unused_items.saturating_sub(1); + if let Some(waker) = stream.read_waker.take() { + waker.wake(); + } + } + FrameType::Window if !was_closed => { + let Some(stream) = state.streams.get_mut(&value.stream_id) else { + return false; + }; + let amount = frame::window_amount(value.payload).unwrap_or(0); + stream.send_credit = stream + .send_credit + .saturating_add(u64::from(amount)) + .min(u64::from(u32::MAX)); + if let Some(waker) = stream.write_waker.take() { + waker.wake(); + } + } + FrameType::Close if !was_closed => { + let Some(stream) = state.streams.remove(&value.stream_id) else { + return false; + }; + let (bytes, items) = inbound_queue_cost(&stream.inbound); + self.release_locked(state, bytes, items, false); + remember_closed( + state, + value.stream_id, + self.limits.max_tombstones_per_session, + ); + if let Some(waker) = stream.read_waker { + waker.wake(); + } + if let Some(waker) = stream.write_waker { + waker.wake(); + } + } + FrameType::Data | FrameType::Window | FrameType::Close => {} + _ => return false, + } + } + true + } + + fn reserve_stream_locked(&self, state: &mut SessionState) -> Option { + if state.active_peer_ports.len() >= self.profile.max_streams_per_session { + return None; + } + let manager = self.manager.upgrade()?; + let peer_port = manager.try_acquire_stream( + self.profile_key, + self.profile.max_streams, + self.client_ip, + self.profile.public_addr, + )?; + if state.active_peer_ports.insert(peer_port) { + return Some(peer_port); + } + manager.release_stream( + self.profile_key, + self.client_ip, + self.profile.public_addr, + peer_port, + ); + None + } +} + +struct UplinkGuard<'a>(&'a AtomicBool); + +impl Drop for UplinkGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +fn validate_batch(state: &SessionState, frames: &[Frame<'_>]) -> bool { + let mut live = state + .streams + .iter() + .map(|(id, stream)| (*id, (stream.receive_window, stream.send_credit))) + .collect::>(); + let mut closed = HashSet::new(); + for value in frames { + if value.stream_id == 0 { + if value.frame_type != FrameType::Pong { + return false; + } + continue; + } + let was_closed = state.closed_streams.contains(&value.stream_id) + || closed.contains(&value.stream_id); + match value.frame_type { + FrameType::Open => { + if live.contains_key(&value.stream_id) || was_closed { + return false; + } + live.insert( + value.stream_id, + ( + frame::INITIAL_STREAM_WINDOW, + u64::from(frame::INITIAL_STREAM_WINDOW), + ), + ); + } + FrameType::Data if !was_closed => { + let Some((receive_window, send_credit)) = live.get_mut(&value.stream_id) else { + return false; + }; + let Ok(payload_len) = u32::try_from(value.payload.len()) else { + return false; + }; + if payload_len > *receive_window { + return false; + } + *receive_window -= payload_len; + let _ = send_credit; + } + FrameType::Window if !was_closed => { + let Some((_, send_credit)) = live.get_mut(&value.stream_id) else { + return false; + }; + let Ok(amount) = frame::window_amount(value.payload) else { + return false; + }; + *send_credit = send_credit + .saturating_add(u64::from(amount)) + .min(u64::from(u32::MAX)); + } + FrameType::Close if !was_closed => { + if live.remove(&value.stream_id).is_none() { + return false; + } + closed.insert(value.stream_id); + } + FrameType::Data | FrameType::Window | FrameType::Close => {} + _ => return false, + } + } + true +} + +fn inbound_reservation(state: &SessionState, frames: &[Frame<'_>]) -> (usize, usize) { + let mut live = state.streams.keys().copied().collect::>(); + let mut bytes = 0usize; + let mut items = 0usize; + for value in frames { + match value.frame_type { + FrameType::Open => { + live.insert(value.stream_id); + } + FrameType::Data if live.contains(&value.stream_id) => { + bytes = bytes.saturating_add(value.payload.len() + QUEUE_ITEM_COST); + items = items.saturating_add(1); + } + FrameType::Close => { + live.remove(&value.stream_id); + } + _ => {} + } + } + (bytes, items) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + use crate::config::{ + WebLimitsConfig, WebRuntimeProfile, WebSecretMode, WebTimeoutsConfig, + }; + use crate::web::manager::WebProcessRuntime; + + fn session() -> Arc { + let profile = Arc::new(WebRuntimeProfile { + host: "proxy.example.com".to_string(), + public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), + user: "alice".to_string(), + secret_mode: WebSecretMode::Plain, + capability: [0; 32], + max_sessions: 1, + max_streams: 1, + max_streams_per_session: 1, + }); + WebSession::new( + std::sync::Weak::::new(), + [1; 32], + "192.0.2.10".parse().unwrap(), + profile, + [2; 32], + WebLimitsConfig::default(), + WebTimeoutsConfig::default(), + ) + } + + #[test] + fn uplink_retry_commits_only_one_exact_body() { + let session = session(); + let first = frame::encode(FrameType::Pong, 0, &[1, 2, 3]); + assert_eq!(session.process_up(1, &first), Ok(1)); + assert_eq!(session.process_up(1, &first), Ok(1)); + + let changed = frame::encode(FrameType::Pong, 0, &[1, 2, 4]); + assert_eq!(session.process_up(1, &changed), Err(ManagerError::Protocol)); + assert!(session.state.lock().closed); + } + + #[test] + fn concurrent_uplink_does_not_commit_sequence() { + let session = session(); + let body = frame::encode(FrameType::Pong, 0, &[]); + session.up_active.store(true, Ordering::Release); + assert_eq!( + session.process_up(1, &body), + Err(ManagerError::Concurrent) + ); + assert_eq!(session.state.lock().last_up_sequence, 0); + session.up_active.store(false, Ordering::Release); + assert_eq!(session.process_up(1, &body), Ok(1)); + } + + #[test] + fn backpressured_uplink_does_not_commit_or_close() { + let session = session(); + { + let mut state = session.state.lock(); + state.streams.insert( + 1, + StreamState { + inbound: VecDeque::new(), + receive_window: frame::INITIAL_STREAM_WINDOW, + send_credit: u64::from(frame::INITIAL_STREAM_WINDOW), + read_waker: None, + write_waker: None, + }, + ); + state.pending_bytes = session.limits.pending_bytes_per_session; + } + let body = frame::encode(FrameType::Data, 1, &[1]); + + assert_eq!( + session.process_up(1, &body), + Err(ManagerError::Backpressure) + ); + let state = session.state.lock(); + assert!(!state.closed); + assert_eq!(state.last_up_sequence, 0); + assert!(state.streams.get(&1).unwrap().inbound.is_empty()); + } + + #[test] + fn uplink_gap_is_fatal() { + let session = session(); + let body = frame::encode(FrameType::Pong, 0, &[]); + assert_eq!(session.process_up(2, &body), Err(ManagerError::Protocol)); + assert!(session.state.lock().closed); + } +} diff --git a/src/web/stream.rs b/src/web/stream.rs new file mode 100644 index 0000000..2ad9389 --- /dev/null +++ b/src/web/stream.rs @@ -0,0 +1,83 @@ +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::futures::OwnedNotified; + +use crate::web::session::WebSession; + +/// Async byte stream that maps one WEB stream identifier onto carrier frames. +pub(crate) struct WebLogicalStream { + session: Arc, + stream_id: u32, + budget_wait: Option>>, +} + +impl WebLogicalStream { + /// Binds a virtual byte stream to one live carrier stream identifier. + pub(crate) fn new(session: Arc, stream_id: u32) -> Self { + Self { + session, + stream_id, + budget_wait: None, + } + } +} + +impl AsyncRead for WebLogicalStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + self.session.poll_read(self.stream_id, cx, output) + } +} + +impl AsyncWrite for WebLogicalStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + let result = self.session.poll_write(self.stream_id, cx, input); + if !result.is_pending() { + self.budget_wait = None; + return result; + } + + // Register before retrying so a concurrent global-capacity release cannot be lost. + loop { + if self.budget_wait.is_none() + && let Some(notify) = self.session.budget_notify() + { + self.budget_wait = Some(Box::pin(notify.notified_owned())); + } + let Some(wait) = self.budget_wait.as_mut() else { + break; + }; + if wait.as_mut().poll(cx).is_pending() { + break; + } + self.budget_wait = None; + } + match self.session.poll_write(self.stream_id, cx, input) { + Poll::Ready(result) => { + self.budget_wait = None; + Poll::Ready(result) + } + Poll::Pending => Poll::Pending, + } + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +}