From cedab4910e28b27e9ec397aaa6712160a47b1839 Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Sat, 19 Sep 2026 19:47:16 +0200 Subject: [PATCH] feat: complete wgcloak v1 transports --- Cargo.lock | 272 +++++++++ Cargo.toml | 7 + README.md | 14 +- benches/protocol_transports.rs | 151 +++++ docs/BENCHMARKS.md | 91 +++ docs/DEVELOPMENT_STATE.md | 136 +++-- docs/FUZZING.md | 49 ++ docs/IMPLEMENTATION_CHECKLIST.md | 146 +++-- docs/OPERATIONS.md | 23 + docs/PROTOCOL.md | 6 +- examples/client.advanced.toml | 1 + examples/client.minimal.toml | 4 + examples/server.advanced.toml | 4 + examples/server.minimal.toml | 4 + fuzz/.gitignore | 2 + fuzz/Cargo.toml | 40 ++ fuzz/corpus/fuzz_config_toml/malformed | 1 + fuzz/corpus/fuzz_config_toml/minimal | 4 + .../fuzz_direct_packet/malformed_header | 1 + fuzz/corpus/fuzz_direct_packet/minimal | 1 + .../fuzz_http3_parser/malformed_capsule | 1 + fuzz/corpus/fuzz_http3_parser/minimal | 1 + fuzz/corpus/fuzz_tls_frame/malformed_length | 1 + fuzz/corpus/fuzz_tls_frame/minimal | 1 + fuzz/fuzz_targets/fuzz_config_toml.rs | 15 + fuzz/fuzz_targets/fuzz_direct_packet.rs | 15 + fuzz/fuzz_targets/fuzz_http3_parser.rs | 26 + fuzz/fuzz_targets/fuzz_tls_frame.rs | 24 + packaging/systemd/wgcloak-client.service | 1 + packaging/systemd/wgcloak-server.service | 1 + src/client/supervisor.rs | 39 +- src/config.rs | 130 +++- src/control.rs | 230 +++---- src/server/mod.rs | 100 ++- src/transport/direct_udp.rs | 168 ++++-- src/transport/http3.rs | 159 +++-- src/transport/mod.rs | 198 +++++- src/transport/quic.rs | 63 +- src/transport/tls_tcp.rs | 7 + tests/control.rs | 91 +++ tests/direct_udp.rs | 119 +++- tests/encrypted_runtime.rs | 568 ++++++++++++++++++ tests/http3_integration.rs | 26 +- tests/integration_transports.rs | 50 ++ tests/server_capabilities.rs | 50 ++ tests/supervisor.rs | 166 ++++- tests/tls_tcp.rs | 42 ++ 47 files changed, 2849 insertions(+), 400 deletions(-) create mode 100644 benches/protocol_transports.rs create mode 100644 docs/BENCHMARKS.md create mode 100644 docs/FUZZING.md create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/corpus/fuzz_config_toml/malformed create mode 100644 fuzz/corpus/fuzz_config_toml/minimal create mode 100644 fuzz/corpus/fuzz_direct_packet/malformed_header create mode 100644 fuzz/corpus/fuzz_direct_packet/minimal create mode 100644 fuzz/corpus/fuzz_http3_parser/malformed_capsule create mode 100644 fuzz/corpus/fuzz_http3_parser/minimal create mode 100644 fuzz/corpus/fuzz_tls_frame/malformed_length create mode 100644 fuzz/corpus/fuzz_tls_frame/minimal create mode 100644 fuzz/fuzz_targets/fuzz_config_toml.rs create mode 100644 fuzz/fuzz_targets/fuzz_direct_packet.rs create mode 100644 fuzz/fuzz_targets/fuzz_http3_parser.rs create mode 100644 fuzz/fuzz_targets/fuzz_tls_frame.rs create mode 100644 tests/encrypted_runtime.rs create mode 100644 tests/server_capabilities.rs diff --git a/Cargo.lock b/Cargo.lock index d1d2bbe..83e8833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -67,6 +73,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" @@ -100,6 +112,12 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.4.6" @@ -133,6 +151,33 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.7" @@ -223,6 +268,73 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -244,6 +356,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "equivalent" version = "1.0.2" @@ -437,6 +555,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -449,6 +578,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -493,12 +628,32 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -639,6 +794,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -651,6 +815,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -686,6 +856,34 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -833,6 +1031,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -842,6 +1060,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1073,6 +1303,19 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1262,6 +1505,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -1520,6 +1773,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1547,6 +1810,7 @@ dependencies = [ "base64", "bytes", "clap", + "criterion", "h3", "h3-datagram", "h3-quinn", @@ -1554,12 +1818,14 @@ dependencies = [ "hkdf", "hmac", "http", + "libc", "quinn", "rand 0.8.8", "rustls", "rustls-native-certs", "secrecy", "serde", + "serde_json", "sha2", "socket2 0.5.10", "subtle", @@ -1705,3 +1971,9 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index fb4e26f..db37b5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,11 +17,13 @@ http = "1" hkdf = "0.12" hmac = "0.12" hex = "0.4" +libc = "0.2" quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "platform-verifier"] } rand = "0.8" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } secrecy = "0.8" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" sha2 = "0.10" socket2 = "0.5" subtle = "2.6" @@ -36,4 +38,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } zeroize = "1.8" [dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } tempfile = "3.13" + +[[bench]] +name = "protocol_transports" +harness = false diff --git a/README.md b/README.md index 0f5126d..4aaec5b 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,14 @@ cargo test --all-targets cargo build --release ``` +`cargo test --all-targets` also runs the `harness = false` Criterion target as +its smoke check; use `cargo bench --bench protocol_transports` for measured +benchmark results. + For a local host installation: ```bash -sudo install -Dm755 target/release/wgcloak /usr/local/bin/wgcloak +sudo install -Dm755 target/release/wgcloak /usr/bin/wgcloak sudo install -d -o root -g wgcloak -m0750 /etc/wgcloak/clients ``` @@ -68,10 +72,16 @@ wgcloak client --config /etc/wgcloak/client.toml See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for control commands, systemd deployment, shutdown behavior, and troubleshooting pointers. +Fuzzing setup and parser invariants are documented in +[`docs/FUZZING.md`](docs/FUZZING.md). + ## systemd The units in [`packaging/systemd/`](packaging/systemd/) assume a dedicated `wgcloak` user, `/usr/bin/wgcloak`, and configuration under `/etc/wgcloak/`. +With the supplied units, use `/run/wgcloak-client/wgcloak.sock` for client +commands and `/run/wgcloak-server/wgcloak.sock` for server commands, for +example `wgcloak stats --socket /run/wgcloak-client/wgcloak.sock`. Review the paths and enabled listeners before installing. The server unit contains an optional, commented `CAP_NET_BIND_SERVICE` setting for ports below 1024. Normal operation needs neither `CAP_NET_ADMIN` nor `CAP_SYS_ADMIN`. @@ -93,6 +103,8 @@ cluster servers, or provide multiple simultaneous local client tunnels. - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): runtime roles and ownership. - [`docs/PROTOCOL.md`](docs/PROTOCOL.md): wire protocol and transport behavior. - [`docs/OPERATIONS.md`](docs/OPERATIONS.md): setup, control, and systemd. +- [`docs/BENCHMARKS.md`](docs/BENCHMARKS.md): deterministic benchmark commands, + environment reporting, and datagram-size guidance. - [`docs/SECURITY.md`](docs/SECURITY.md): threat model and permissions. - [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md): common failures. - [`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md): diff --git a/benches/protocol_transports.rs b/benches/protocol_transports.rs new file mode 100644 index 0000000..db6a170 --- /dev/null +++ b/benches/protocol_transports.rs @@ -0,0 +1,151 @@ +use std::collections::HashMap; + +use bytes::Bytes; +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use wgcloak::{ + protocol::{ + direct::{parse_packet_bounded, DataPacket}, + replay::ReplayWindow, + }, + transport::{http3, tls_tcp::FrameCodec}, +}; + +const SECRET: &[u8] = b"benchmark-secret-with-at-least-32-bytes"; +const SESSION_KEY: [u8; 32] = [0x42; 32]; +const SESSION_ID: u64 = 0x0102_0304_0506_0708; + +fn bench_direct_protocol(c: &mut Criterion) { + let mut group = c.benchmark_group("direct_protocol"); + for size in [64usize, 512, 1200] { + let payload = Bytes::from(vec![0x5a; size]); + let packet = DataPacket::authenticated(&SESSION_KEY, SESSION_ID, 7, payload); + group.throughput(Throughput::Bytes(size as u64)); + group.bench_with_input( + BenchmarkId::new("encode_parse_mac", size), + &packet, + |b, packet| { + b.iter(|| { + let wire = packet.encode().expect("benchmark packet encodes"); + let parsed = parse_packet_bounded(black_box(&wire), size) + .expect("benchmark packet parses"); + let parsed = match parsed { + wgcloak::protocol::Packet::Data(data) => data, + _ => panic!("benchmark packet changed type"), + }; + black_box(parsed.verify_authenticator(&SESSION_KEY)); + }); + }, + ); + } + group.finish(); +} + +fn bench_tls_frames(c: &mut Criterion) { + let codec = FrameCodec::new(1200).expect("valid frame maximum"); + let first = vec![0x11; 512]; + let second = vec![0x22; 512]; + let single = codec.encode(&first).expect("frame encodes"); + let mut coalesced = single.clone(); + coalesced.extend_from_slice(&codec.encode(&second).expect("frame encodes")); + let mut group = c.benchmark_group("tls_frames"); + group.throughput(Throughput::Bytes(single.len() as u64)); + group.bench_function("encode", |b| { + b.iter(|| black_box(codec.encode(black_box(&first)).expect("frame encodes"))) + }); + group.bench_function("decode_split", |b| { + b.iter(|| { + let mut decoder = FrameCodec::new(1200).expect("valid frame maximum"); + let midpoint = single.len() / 2; + black_box( + decoder + .feed(&single[..midpoint]) + .expect("first half accepted"), + ); + black_box( + decoder + .feed(&single[midpoint..]) + .expect("second half accepted"), + ); + }); + }); + group.bench_function("decode_coalesced", |b| { + b.iter(|| { + let mut decoder = FrameCodec::new(1200).expect("valid frame maximum"); + black_box(decoder.feed(&coalesced).expect("coalesced frames accepted")); + }); + }); + group.finish(); +} + +fn bench_http3(c: &mut Criterion) { + let challenge = http3::AuthChallenge { bytes: [0x33; 32] }; + let nonce = [0x44; 16]; + let client_id = "benchmark-client"; + let response = http3::encode_auth_response(&challenge, client_id, &nonce, SECRET) + .expect("auth response encodes"); + let mut clients = HashMap::new(); + clients.insert(client_id.to_owned(), SECRET.to_vec()); + let datagram_payload = vec![0x55; 1200]; + let mut group = c.benchmark_group("http3_primitives"); + group.throughput(Throughput::Bytes(datagram_payload.len() as u64)); + group.bench_function("datagram_encode_decode", |b| { + b.iter(|| { + let encoded = http3::encode_http_datagram( + http3::CONNECT_UDP_CONTEXT_ID, + black_box(&datagram_payload), + 1200, + ) + .expect("HTTP Datagram encodes"); + black_box( + http3::decode_http_datagram(&encoded, http3::CONNECT_UDP_CONTEXT_ID, 1200) + .expect("HTTP Datagram decodes"), + ); + }); + }); + group.bench_function("capsule_encode_decode", |b| { + b.iter(|| { + let encoded = + http3::encode_capsule(0x2a, black_box(&challenge.bytes)).expect("capsule encodes"); + black_box(http3::decode_capsule(&encoded).expect("capsule decodes")); + }); + }); + group.bench_function("auth_response_verify", |b| { + b.iter(|| { + black_box( + http3::verify_auth_response(&challenge, &response, &clients) + .expect("auth response verifies"), + ); + }); + }); + group.finish(); +} + +fn bench_replay(c: &mut Criterion) { + let sequence: Vec = (1..=1024).collect(); + c.bench_function("replay_window_sequential_1024", |b| { + b.iter(|| { + let mut window = ReplayWindow::new(); + for sequence in &sequence { + black_box(window.check_and_record(*sequence)); + } + }); + }); + c.bench_function("replay_window_duplicate_check", |b| { + b.iter(|| { + let mut window = ReplayWindow::new(); + for sequence in 1..=1024 { + window.check_and_record(sequence); + } + black_box(window.check_and_record(512)); + }); + }); +} + +criterion_group!( + protocol_transports, + bench_direct_protocol, + bench_tls_frames, + bench_http3, + bench_replay +); +criterion_main!(protocol_transports); diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..500f803 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,91 @@ +# Benchmarking + +wgcloak has a deterministic Criterion target for protocol and transport +building blocks. It does not open sockets, require certificates, or depend on +network timing. Because the target is declared with `harness = false`, +`cargo test --all-targets` executes it as a smoke check; that run is not a +measured benchmark report. + +## Run it + +```bash +# Compile the benchmark without running it. +cargo bench --bench protocol_transports --no-run + +# Run the complete local benchmark and write Criterion reports under target/. +cargo bench --bench protocol_transports + +# Run one group while iterating. +cargo bench --bench protocol_transports -- direct_protocol +``` + +The target measures: + +- direct authenticated DATA packet encode, bounded parse, and MAC verification + for 64, 512, and 1200-byte opaque payloads; +- TLS/TCP frame encoding plus incremental split-input and coalesced-input + decoding; +- HTTP/3 HTTP Datagram context encoding/decoding, capsule encoding/decoding, + and CONNECT-UDP challenge-response verification; +- replay-window sequential acceptance and duplicate rejection. + +The benchmark input and cryptographic keys are fixed, non-secret test values. +They are not production credentials. Criterion's default warm-up, sample, and +measurement settings are suitable for a first comparison; keep those settings +stable when comparing commits. + +## Environment report + +Record at least the following with published results: + +```text +date/time (UTC): +commit: +host/CPU: +RAM: +OS/kernel: +rustc --version: +cargo --version: +governor/power profile: +command and Criterion filter: +notes (background load, thermal state, VM/container): +``` + +Do not compare values from different CPU governors, virtualized hosts, or +substantially different Rust/dependency lockfiles without noting the change. +Criterion reports are local artifacts and are not committed by default. + +## Network benchmark status + +No loopback UDP relay benchmark is enabled yet. A socket benchmark would be +environment-sensitive: scheduler load, kernel socket buffers, loopback queueing, +and task runtime configuration can dominate the result. Add it as an explicit +opt-in benchmark (or a separate executable) only when it reports packet loss, +latency percentiles, CPU context, socket buffer settings, and a bounded run +duration. It must not run as part of `cargo test` or the deterministic Criterion +group. + +## Datagram-size analysis and recommendation + +The configured default application payload is **1200 bytes**. The project-wide +configurable safety ceiling is **16,384 bytes**; transport framing and path +capabilities may produce a lower effective session maximum. The wire overheads +are: + +| Path | Per-datagram overhead relevant to the configured payload | +| --- | --- | +| Direct UDP | 32-byte authenticated DATA header; the u16 packet limit is theoretical, not an MTU guarantee | +| TLS/TCP | 2-byte big-endian frame length; TCP preserves framing but does not provide an MTU bound | +| HTTP/3 CONNECT-UDP | HTTP Datagram context varint plus h3-quinn's quarter-stream varint; the implementation subtracts both before accepting payload | +| QUIC DATAGRAM | QUIC/path framing and UDP/IP headers are path-dependent; the peer's negotiated maximum is authoritative | + +Recommendation: retain **1200 bytes as the practical default** for ordinary +Internet paths. Users may configure larger values up to **16,384 bytes** for +jumbo/custom paths, subject to each transport's negotiated/path-specific +effective maximum. Boundary tests should cover each transport, including HTTP/3 +stream/context overhead at both one-byte and multi-byte varint widths. + +The direct protocol and TLS framing have larger u16 wire limits, but those +limits are not safe project configuration values. The 16,384-byte ceiling +keeps parser/session allocations bounded. HTTP/3 and QUIC limits remain +negotiated/path-dependent and are bounded further before allocation and send. diff --git a/docs/DEVELOPMENT_STATE.md b/docs/DEVELOPMENT_STATE.md index 73e028a..09b3170 100644 --- a/docs/DEVELOPMENT_STATE.md +++ b/docs/DEVELOPMENT_STATE.md @@ -1,71 +1,103 @@ # Development state -Checkpoint: 2026-09-15 end of day. Branch: `main`. Latest commit: -`586ff8f` (`checkpoint: integrate authenticated transports`). The working tree -is clean. Config and client-ID changes are included in this commit. The -resumed recovery-only assignments after this commit made no edits. +Checkpoint: 2026-09-19. Branch: `main`. HEAD: `49ec9d8` +(`docs: refresh development checkpoint`). The working tree is not clean: this +checkout contains concurrent implementation, documentation, benchmark, fuzz, +and integration-test changes. No commit was created for this checkpoint. ## Implemented behavior -- The package has a reusable Rust library and a thin binary. Integration tests - import the library; module unit tests run once in the library test target. +- The package provides a reusable Rust library and thin binary. Integration + tests use the library rather than path-including production modules. - Client and server runtimes support authenticated direct UDP, QUIC DATAGRAM, HTTP/3 CONNECT-UDP with HTTP Datagrams, and TLS/TCP length-framed fallback. TLS client connections use normal certificate-chain and hostname verification. - Shared-secret authentication precedes encrypted server backend-session - activation. -- Client `auto` follows configured order and bounded probes, stops on - authentication rejection, and reconnects with jittered 250 ms–30 s backoff. - Successful connections reset the backoff. Client control supports explicit - reconnect. -- The server runs enabled listeners concurrently, rejects incompatible UDP - listener port conflicts, aggregates listener/session statistics, and gives - sessions isolated backend UDP sockets. Admission limits cover sessions, - per-IP sessions, pending handshakes, and authentication attempts. -- The Unix control socket uses a private runtime directory and mode `0600`, - refuses an active socket, and removes only a verified stale socket. `status`, - `stats`, and `shutdown` are supported; `reconnect` is client-only. Config and - certificate/key reload remain unsupported. -- A client currently tracks one most-recent local UDP application peer, so - concurrent local sources can replace the return target. Direct UDP - keepalives are one-way; silent peer loss may require explicit reconnect or a - local transport error to be noticed. + Shared-secret authentication precedes encrypted backend-session activation. +- `auto` follows configured transport order, uses bounded probes/retries, stops + on authentication rejection, and reconnects with jittered 250 ms–30 s + backoff. Successful connections reset the backoff. +- Enabled server listeners run concurrently, reject incompatible UDP bind + conflicts, isolate backend UDP sockets per authenticated session, enforce + admission limits, and publish aggregate status/statistics. +- The Unix control socket uses a private runtime directory and `0600` socket + permissions. It refuses active sockets, removes only verified stale sockets, + parses/serializes newline-delimited JSON structurally, limits concurrent + clients, and bounds idle/oversized requests. `status`, `stats`, `shutdown`, + and client-only `reconnect` are supported. `reload` explicitly reports + unsupported. +- Secret-file loading opens one descriptor, uses Linux `O_NOFOLLOW` and + `O_NONBLOCK`, validates a regular file and permissions from that descriptor, + and reads the opened descriptor. Secret contents are not logged. +- Systemd templates run as an unprivileged user with hardening enabled; + `CAP_NET_BIND_SERVICE` is documented as an optional, narrowly scoped aid for + ports below 1024. No WireGuard, TUN/TAP, routing, or firewall management is + implemented. +- `tests/encrypted_runtime.rs` covers daemon-level QUIC/TLS forwarding, + authentication rejection, cancellation, session identity, and QUIC restart + reconnect. `tests/server_capabilities.rs` covers the server datagram + capability boundary. +- A deterministic Criterion target exists in `benches/protocol_transports.rs` + for protocol/framing/HTTP Datagram/replay microbenchmarks. The repository + also contains four isolated cargo-fuzz targets under `fuzz/` for direct + packets, TLS frames, HTTP/3 parsing, and TOML parsing. -## Verification +## Current worktree inventory -The latest full gates passed on commit `586ff8f`, including the config and -client-ID changes: +Modified files include `Cargo.toml`, `Cargo.lock`, `README.md`, +`docs/OPERATIONS.md`, both advanced/minimal server examples, both systemd +templates, runtime/config/control/server/transport sources, and related tests. +Untracked assets include `benches/`, `fuzz/`, `docs/BENCHMARKS.md`, +`docs/FUZZING.md`, `tests/encrypted_runtime.rs`, and +`tests/server_capabilities.rs`. `docs/DEVELOPMENT_STATE.md` and +`docs/IMPLEMENTATION_CHECKLIST.md` are also modified by this reconciliation. +Treat these as shared worktree state; do not discard them with a cleanup +command. + +## Verification performed in this worktree + +The following checks passed: - `cargo fmt --all -- --check` - `cargo check --all-targets` - `cargo clippy --all-targets -- -D warnings` -- `cargo test --all-targets --quiet` — 76 tests passed. +- `cargo test --tests -- --test-threads=1` — 95 tests passed +- `cargo bench --bench protocol_transports --no-run` +- `cargo check --manifest-path fuzz/Cargo.toml` -The tree was clean at this checkpoint. No uncommitted file inventory applies. +`cargo-fuzz` is not installed on this host, so no fuzzing session or sanitizer +run is claimed. No network/loopback benchmark was run; only deterministic +benchmark compilation was checked. These gates ran against the shared dirty +worktree, not a clean checkout. -## Remaining work, prioritized +## Remaining work -1. Add `tests/encrypted_runtime.rs` for daemon-level QUIC and TLS proof through - the client runtime, encrypted transport, server runtime, and UDP echo - backend. Cover auth rejection, session independence, control status/stats, - and bounded shutdown. Lower-level transport tests are not a substitute. -2. Start fuzzing: `fuzz/**` and `docs/FUZZING.md` are unstarted. Cover packet - parsers, authentication/framing boundaries, and malformed transport input. -3. Add benchmarks; benchmark targets and baselines are unstarted. -4. Decide a practical maximum datagram-size cap and justify it against transport - overhead and MTU constraints; add boundary/config tests. -5. Decide whether config and certificate/key reload are optional or required by - the spec/product scope. They are currently unsupported. Finish a final - spec-to-implementation audit and update the acceptance checklist with - evidence; do not claim unsupported behavior. +Required before an unconditional release sign-off: -## First tasks for tomorrow +1. Re-run the documented gates from the final clean checkout after concurrent + changes are integrated and reviewed. +2. Install/use `cargo-fuzz` and run bounded smoke sessions for each target; + retain the compile-only result as the current evidence until then. +3. Make and test a final practical datagram-size-cap decision. The benchmark + analysis recommends 1200 bytes, while configuration still permits the + larger protocol-bound ceiling. -1. Implement the focused daemon-level QUIC/TLS test in `tests/encrypted_runtime.rs`, - then run `cargo test --test encrypted_runtime -- --nocapture`. -2. Run the full gate sequence after the test lands: - `cargo fmt --all -- --check`, `cargo check --all-targets`, - `cargo clippy --all-targets -- -D warnings`, and - `cargo test --all-targets --quiet`. -3. Review the spec checklist, decide the datagram cap and reload scope, and - record acceptance evidence plus the unstarted fuzz/benchmark work. +Optional or explicitly out of scope for the current prototype: + +- Config and certificate/key reload remain unsupported; the control command + reports this explicitly. Spec language makes certificate reload desirable + but optional and does not require full hot reload for the first milestone. +- A network benchmark remains opt-in work because its results depend on host + scheduling, kernel buffers, MTU, and runtime conditions. The deterministic + microbenchmarks are present and compile. +- A measured benchmark report and sanitizer fuzz report are release artifacts, + not evidence currently available from this host. + +## First three recovery/release tasks + +1. Review and commit the shared implementation/docs/bench/fuzz work as one + coherent checkpoint, then repeat the full gates from that clean checkout. +2. Run the four cargo-fuzz targets for bounded smoke sessions and record the + toolchain/host results in the fuzzing notes. +3. Decide whether to enforce the documented 1200-byte practical cap or retain + the protocol-bound configurable ceiling, then add the corresponding + boundary evidence and update the acceptance checklist. diff --git a/docs/FUZZING.md b/docs/FUZZING.md new file mode 100644 index 0000000..880f9f6 --- /dev/null +++ b/docs/FUZZING.md @@ -0,0 +1,49 @@ +# Fuzzing + +The repository includes an isolated [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz) +crate under `fuzz/`. It depends on the public `wgcloak` library by path and +does not open sockets, read files, or start async runtimes. Each target limits +the input passed to a parser to 64 KiB; payload/frame/datagram copies are +limited to 16 KiB where the API supports a bound. + +## Install and run + +Install `cargo-fuzz` once, then run a short smoke fuzzing session from the +repository root: + +```bash +cargo install cargo-fuzz +cargo fuzz run fuzz_direct_packet -- -max_total_time=30 +cargo fuzz run fuzz_tls_frame -- -max_total_time=30 +cargo fuzz run fuzz_http3_parser -- -max_total_time=30 +cargo fuzz run fuzz_config_toml -- -max_total_time=30 +``` + +The corpus directories under `fuzz/corpus/` contain minimal and malformed +seeds. Add minimized findings there with the target that discovered them. + +For a compile-only check when `cargo-fuzz` is unavailable: + +```bash +cargo check --manifest-path fuzz/Cargo.toml +``` + +`cargo-fuzz` uses libFuzzer and sanitizer instrumentation for fuzz builds. +For an explicit sanitizer run, set the sanitizer supported by the local LLVM +toolchain, for example: + +```bash +RUSTFLAGS="-Zsanitizer=address" cargo fuzz run fuzz_direct_packet --sanitizer address -- -max_total_time=60 +``` + +## Target invariants + +- Direct packet parsing rejects malformed/truncated/oversized input without + panicking and checks the DATA bound before copying its payload. +- TLS/TCP framing handles arbitrary incremental chunks, rejects oversized + length headers, and reports truncated final frames without panicking. +- HTTP/3 capsule, context-ID, and authentication message parsers reject + malformed varints, capsule lengths, contexts, and auth payloads safely. +- TOML parsing accepts only valid UTF-8 input and performs structural parsing + in memory; the target deliberately does not call validation that could load + file-backed secrets. diff --git a/docs/IMPLEMENTATION_CHECKLIST.md b/docs/IMPLEMENTATION_CHECKLIST.md index 3cd851b..2c64ec2 100644 --- a/docs/IMPLEMENTATION_CHECKLIST.md +++ b/docs/IMPLEMENTATION_CHECKLIST.md @@ -1,62 +1,108 @@ # Implementation and acceptance checklist -This is a coordinator checklist, not a claim that v1 is signed off. The current -runtime has code paths for all four carriers, configured auto selection, -client reconnect backoff, and local control commands. Mark an acceptance item -only with code evidence, tests, and an integration result; record the test -command or issue beside each checked item. +This is an evidence-based coordinator checklist. A checked item identifies +current code and tests; it is not a claim that the dirty worktree is an +unconditionally released artifact. The latest evidence was collected on +2026-09-19 with serial integration tests. ## Milestones 1–6 -- [ ] **1 — authenticated direct relay:** local UDP listener; direct UDP - handshake; HMAC-authenticated data; replay window; keepalive/expiry; one - backend UDP socket per authenticated client; multi-client isolation; TOML; - logs and basic stats. -- [ ] **2 — QUIC DATAGRAM:** certificate loading and hostname verification; - client challenge/response; datagrams; keepalive/MTU; reconnect; multi-client - handling without core API redesign. -- [ ] **3 — automatic selection:** `mode = "auto"`; configured order across - direct UDP, QUIC, HTTP/3, and TLS/TCP; bounded probes/retries; auth rejection - stops probing; jittered reconnect; status reports selected transport. -- [ ] **4 — HTTP/3 CONNECT-UDP:** standard CONNECT-UDP/HTTP Datagram semantics; - authentication; backend mapping; reuse QUIC infrastructure where practical. -- [ ] **5 — TLS/TCP fallback:** TLS listener; client authentication; bounded - big-endian length framing; bidirectional datagram reconstruction; fallback - integration; expected head-of-line behavior documented. -- [ ] **6 — operational polish:** Unix control socket; status/stats/reconnect - commands; config validation; systemd units; capability guidance; benchmarks; - fuzzing; installation docs. Config and certificate/key reload are not - implemented; the control command explicitly reports reload as unsupported. +- [x] **1 — authenticated direct relay:** `src/transport/direct_udp.rs` and + `src/protocol/*` implement the handshake, HMAC data, replay, keepalive, + expiry, backend mapping, and statistics. Evidence: `tests/direct_udp.rs` + covers round trip/auth rejection, concurrent clients, malformed traffic, + source-port change, expiry, and backend failure; `tests/runtime_lifecycle.rs` + covers daemon control/session cleanup. +- [x] **2 — QUIC DATAGRAM:** `src/transport/quic.rs` performs certificate and + hostname verification, challenge authentication, datagrams, limits, and + reconnect support. Evidence: `tests/quic.rs` and the QUIC cases in + `tests/encrypted_runtime.rs` cover round trip, bad secret, hostname mismatch, + multiple clients, cancellation, and server restart. +- [x] **3 — automatic selection:** `src/client/supervisor.rs` and runtime + selection implement configured order, bounded probes/retries, auth-stop, and + jittered reconnect. Evidence: `tests/supervisor.rs`, `tests/reconnect.rs`, + and `tests/http3_runtime.rs`. +- [x] **4 — HTTP/3 CONNECT-UDP:** `src/transport/http3.rs` uses extended + CONNECT and HTTP Datagrams with authentication and backend session mapping. + Evidence: `tests/http3.rs`, `tests/http3_integration.rs`, and the daemon + relay/isolation cases in `tests/http3_runtime.rs`. +- [x] **5 — TLS/TCP fallback:** `src/transport/tls_tcp.rs` implements TLS, + authenticated bounded u16-BE framing, and bidirectional datagrams. Evidence: + `tests/tls_tcp.rs`, `tests/integration_transports.rs`, and TLS cases in + `tests/encrypted_runtime.rs`; the TLS/TCP head-of-line tradeoff is documented + in `docs/PROTOCOL.md`. +- [ ] **6 — operational polish:** Control, validation, hardened systemd units, + capability guidance, deterministic benchmark assets, and fuzz targets are + present (`src/control.rs`, `tests/control.rs`, `packaging/systemd/`, + `benches/`, `fuzz/`). Leave this milestone open until final clean-checkout + gates, bounded cargo-fuzz runs, and the practical datagram cap decision are + recorded. Config/certificate reload remains explicitly unsupported. ## v1 completion criteria (spec §89) -- [ ] 1. Multiple clients concurrently reach one UDP backend. -- [ ] 2. No WireGuard-specific logic exists. -- [ ] 3. Direct UDP works reliably. -- [ ] 4. QUIC DATAGRAM works reliably. -- [ ] 5. HTTP/3 CONNECT-UDP works. -- [ ] 6. TLS/TCP fallback works. -- [ ] 7. Automatic transport selection works. -- [ ] 8. Authentication is enforced on every public transport. -- [ ] 9. Direct UDP has replay and integrity protection. -- [ ] 10. Direct UDP NAT rebinding works. -- [ ] 11. Sessions expire and clean up correctly. -- [ ] 12. The server survives malformed and unauthenticated traffic. -- [ ] 13. The daemon runs unprivileged except optional bind capability. -- [ ] 14. TOML configuration is clearly validated. -- [ ] 15. Meaningful status and statistics are available. -- [ ] 16. Reconnects occur automatically. -- [ ] 17. Automated integration tests cover multi-client operation. +- [x] 1. Multiple clients concurrently reach one UDP backend — + `tests/direct_udp.rs::concurrent_clients_use_independent_backend_sockets`, + `tests/encrypted_runtime.rs`, and `tests/http3_runtime.rs`. +- [x] 2. No WireGuard-specific logic exists — source/runtime scope is generic + UDP relay code; WireGuard is documented only as a non-goal in `README.md` and + `docs/SECURITY.md`. +- [x] 3. Direct UDP works reliably — `tests/direct_udp.rs` round-trip, + malformed-input, rebinding, expiry, and backend-failure coverage. +- [x] 4. QUIC DATAGRAM works reliably — `tests/quic.rs` and encrypted runtime + forwarding/restart coverage. +- [x] 5. HTTP/3 CONNECT-UDP works — HTTP/3 unit, integration, and runtime tests. +- [x] 6. TLS/TCP fallback works — TLS framing/auth tests and both TLS integration + suites. +- [x] 7. Automatic transport selection works — `tests/supervisor.rs` and + `tests/reconnect.rs` cover order, fallback, retries, and recovery. +- [x] 8. Authentication is enforced on every public transport — bad-secret + cases in `tests/direct_udp.rs`, `tests/quic.rs`, `tests/http3_integration.rs`, + `tests/tls_tcp.rs`, and `tests/encrypted_runtime.rs`. +- [x] 9. Direct UDP has replay and integrity protection — protocol tests plus + authenticated flags/replay cases in `tests/direct_udp.rs`. +- [x] 10. Direct UDP NAT rebinding works — + `tests/direct_udp.rs::authenticated_source_port_change_updates_return_peer`. +- [x] 11. Sessions expire and clean up correctly — direct expiry, transport + cancellation, HTTP/3 close, and lifecycle cases across `tests/direct_udp.rs`, + `tests/http3_integration.rs`, `tests/http3_runtime.rs`, and + `tests/encrypted_runtime.rs`. +- [x] 12. The server survives malformed and unauthenticated traffic — + `tests/direct_udp.rs` malformed/auth-rejection cases and encrypted bad-secret + integration tests. +- [x] 13. The daemon runs unprivileged except optional privileged-port + capability — `User=wgcloak`, hardening, and commented narrow + `CAP_NET_BIND_SERVICE` guidance in both `packaging/systemd/*.service` files. +- [x] 14. TOML configuration is clearly validated — `src/config.rs` tests and + `tests/server_capabilities.rs` cover role, transport, listener, and datagram + boundaries. +- [x] 15. Meaningful status and statistics are available — `src/control.rs`, + `tests/control.rs`, `tests/runtime_lifecycle.rs`, and aggregate-stat tests in + `tests/http3_runtime.rs`. +- [x] 16. Reconnects occur automatically — bounded supervisor backoff and + server-restart proof in `tests/reconnect.rs` and `tests/encrypted_runtime.rs`. +- [x] 17. Automated integration tests cover multi-client operation — direct, + encrypted, and HTTP/3 runtime integration suites. ## Required evidence before v1 sign-off -- [ ] Unit tests cover parsing, malformed input, auth, key derivation, replay, - config validation, and timeout logic. -- [ ] Integration tests cover direct client/server, concurrent clients, +- [x] Unit tests cover parsing, malformed input, authentication, key + derivation, replay, config validation, timeout logic, control limits, and + secret-file safety. Evidence: library tests plus `tests/control.rs` and + config secret tests. +- [x] Integration tests cover direct client/server, concurrent clients, source-port change, auth rejection, restart/reconnect, backend outage, - oversized datagrams, and expiry; later transports have equivalent coverage. -- [ ] Fuzz targets exercise direct packets, TCP frames, and auth messages. -- [ ] `cargo test`, formatting, linting, and any documented integration/bench - commands pass from a clean checkout. -- [ ] Review confirms no payload/secret logging, no unsafe privilege grant, and - no accidental WireGuard/routing/firewall coupling. + oversized datagrams, expiry, and equivalent encrypted transport coverage. +- [x] Fuzz targets exist for direct packets, TLS frames, HTTP/3 parsing, and + TOML parsing, and `cargo check --manifest-path fuzz/Cargo.toml` passes. + No fuzzing execution or sanitizer result is claimed because `cargo-fuzz` is + not installed on the current host. +- [ ] `cargo test`, formatting, linting, and documented integration/bench + commands pass from a clean checkout. Current dirty-worktree evidence is: + `cargo fmt --all -- --check`, `cargo check --all-targets`, strict all-target + Clippy, serial `cargo test --tests -- --test-threads=1` (95 passed), and + `cargo bench --bench protocol_transports --no-run`. +- [x] Review confirms no payload/secret logging, no unsafe privilege grant, + and no accidental WireGuard/routing/firewall coupling. Evidence: + `docs/SECURITY.md`, hardened systemd templates, source audit, and the + authentication/control/secret-file tests. This review does not waive the + clean-checkout and fuzz-execution items above. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index bbf2617..aba2f0e 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -24,6 +24,12 @@ sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/client.toml sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/server.toml ``` +`network.max_datagram_size` defaults to 1200 bytes and may be raised for a +known jumbo/custom path up to the project-wide 16,384-byte safety ceiling. +The effective session maximum can be lower for a particular QUIC, HTTP/3, or +direct path after transport framing and negotiation; TLS/TCP still enforces +the configured cap at both endpoints. + ## Start, stop, and reconnect Foreground operation is useful during initial setup: @@ -84,6 +90,23 @@ Review and copy the units under `packaging/systemd/`. They assume binary `/etc/wgcloak/`. Each unit creates a private runtime directory and sets `XDG_RUNTIME_DIR` there. +The resulting control paths are distinct and deterministic: + +- client: `/run/wgcloak-client/wgcloak.sock` +- server: `/run/wgcloak-server/wgcloak.sock` + +Use the explicit path when administering a unit from a shell outside its +service environment: + +```bash +wgcloak status --socket /run/wgcloak-client/wgcloak.sock +wgcloak stats --socket /run/wgcloak-client/wgcloak.sock +wgcloak reconnect --socket /run/wgcloak-client/wgcloak.sock +wgcloak status --socket /run/wgcloak-server/wgcloak.sock +wgcloak stats --socket /run/wgcloak-server/wgcloak.sock +wgcloak shutdown --socket /run/wgcloak-server/wgcloak.sock +``` + ```bash sudo install -Dm0644 packaging/systemd/wgcloak-client.service /etc/systemd/system/ sudo systemctl daemon-reload diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index db15b88..3c5f3ac 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -44,8 +44,10 @@ timeout (target default 120 seconds), then its backend socket and replay state are released. The core rejects oversized application datagrams, increments a counter, and -logs at debug/warn level. v1 does not fragment datagrams; a conservative -starting limit is 1200 bytes. +logs at debug/warn level. v1 does not fragment datagrams. The default +application payload limit is 1200 bytes; configuration accepts values up to +the project-wide 16,384-byte safety ceiling. The effective session maximum may +be lower after transport framing and path negotiation. ## Encapsulated transports diff --git a/examples/client.advanced.toml b/examples/client.advanced.toml index 5c755fd..01f9a5d 100644 --- a/examples/client.advanced.toml +++ b/examples/client.advanced.toml @@ -33,6 +33,7 @@ path = "/tunnel" port = 443 [network] +# Default application payload; configurable up to the 16384-byte safety ceiling. max_datagram_size = 1200 [logging] diff --git a/examples/client.minimal.toml b/examples/client.minimal.toml index 9b9ac27..9181d3b 100644 --- a/examples/client.minimal.toml +++ b/examples/client.minimal.toml @@ -11,3 +11,7 @@ listen = "127.0.0.1:51821" [transport] mode = "auto" + +[network] +# Default application payload; configurable up to the 16384-byte safety ceiling. +max_datagram_size = 1200 diff --git a/examples/server.advanced.toml b/examples/server.advanced.toml index 7a6a876..5acc890 100644 --- a/examples/server.advanced.toml +++ b/examples/server.advanced.toml @@ -6,6 +6,10 @@ listen = "0.0.0.0" [backend] target = "127.0.0.1:51820" +[network] +# Default application payload; configurable up to the 16384-byte safety ceiling. +max_datagram_size = 1200 + [transports.direct_udp] enabled = true port = 51830 diff --git a/examples/server.minimal.toml b/examples/server.minimal.toml index 252bb26..0b26e38 100644 --- a/examples/server.minimal.toml +++ b/examples/server.minimal.toml @@ -7,6 +7,10 @@ listen = "0.0.0.0" [backend] target = "127.0.0.1:51820" +[network] +# Default application payload; configurable up to the 16384-byte safety ceiling. +max_datagram_size = 1200 + # Direct UDP only. Other transports can be enabled explicitly when needed. [transports.direct_udp] enabled = true diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..e9e2199 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..d2d92da --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "wgcloak-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +wgcloak = { path = ".." } + +[[bin]] +name = "fuzz_direct_packet" +path = "fuzz_targets/fuzz_direct_packet.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_tls_frame" +path = "fuzz_targets/fuzz_tls_frame.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_http3_parser" +path = "fuzz_targets/fuzz_http3_parser.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_config_toml" +path = "fuzz_targets/fuzz_config_toml.rs" +test = false +doc = false +bench = false diff --git a/fuzz/corpus/fuzz_config_toml/malformed b/fuzz/corpus/fuzz_config_toml/malformed new file mode 100644 index 0000000..3b8406c --- /dev/null +++ b/fuzz/corpus/fuzz_config_toml/malformed @@ -0,0 +1 @@ +[[client diff --git a/fuzz/corpus/fuzz_config_toml/minimal b/fuzz/corpus/fuzz_config_toml/minimal new file mode 100644 index 0000000..cc817a0 --- /dev/null +++ b/fuzz/corpus/fuzz_config_toml/minimal @@ -0,0 +1,4 @@ +[client] +server = "127.0.0.1:51820" +id = "fuzz" +secret = "ZmFrZQ==" diff --git a/fuzz/corpus/fuzz_direct_packet/malformed_header b/fuzz/corpus/fuzz_direct_packet/malformed_header new file mode 100644 index 0000000..05c3824 --- /dev/null +++ b/fuzz/corpus/fuzz_direct_packet/malformed_header @@ -0,0 +1 @@ +\x01\x10\x00\xff diff --git a/fuzz/corpus/fuzz_direct_packet/minimal b/fuzz/corpus/fuzz_direct_packet/minimal new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fuzz/corpus/fuzz_direct_packet/minimal @@ -0,0 +1 @@ + diff --git a/fuzz/corpus/fuzz_http3_parser/malformed_capsule b/fuzz/corpus/fuzz_http3_parser/malformed_capsule new file mode 100644 index 0000000..8f70dcb --- /dev/null +++ b/fuzz/corpus/fuzz_http3_parser/malformed_capsule @@ -0,0 +1 @@ +\x2a\x20 diff --git a/fuzz/corpus/fuzz_http3_parser/minimal b/fuzz/corpus/fuzz_http3_parser/minimal new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fuzz/corpus/fuzz_http3_parser/minimal @@ -0,0 +1 @@ + diff --git a/fuzz/corpus/fuzz_tls_frame/malformed_length b/fuzz/corpus/fuzz_tls_frame/malformed_length new file mode 100644 index 0000000..bf0a9bc --- /dev/null +++ b/fuzz/corpus/fuzz_tls_frame/malformed_length @@ -0,0 +1 @@ +\xff\xff diff --git a/fuzz/corpus/fuzz_tls_frame/minimal b/fuzz/corpus/fuzz_tls_frame/minimal new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fuzz/corpus/fuzz_tls_frame/minimal @@ -0,0 +1 @@ + diff --git a/fuzz/fuzz_targets/fuzz_config_toml.rs b/fuzz/fuzz_targets/fuzz_config_toml.rs new file mode 100644 index 0000000..dda7826 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_config_toml.rs @@ -0,0 +1,15 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use wgcloak::config::Config; + +const MAX_INPUT: usize = 64 * 1024; + +fuzz_target!(|input: &[u8]| { + let input = &input[..input.len().min(MAX_INPUT)]; + // Parsing only: Config::from_str never reads secret files. Validation is + // intentionally not called because it may resolve file-backed secrets. + if let Ok(text) = std::str::from_utf8(input) { + let _ = Config::from_str(text); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_direct_packet.rs b/fuzz/fuzz_targets/fuzz_direct_packet.rs new file mode 100644 index 0000000..c61c299 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_direct_packet.rs @@ -0,0 +1,15 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use wgcloak::protocol::direct::parse_packet_bounded; + +const MAX_INPUT: usize = 64 * 1024; +const MAX_DATA_PAYLOAD: usize = 16 * 1024; + +fuzz_target!(|input: &[u8]| { + // Keep the harness itself bounded even if the engine is configured with a + // larger input size. The parser performs structural checks before copying + // DATA payloads. + let input = &input[..input.len().min(MAX_INPUT)]; + let _ = parse_packet_bounded(input, MAX_DATA_PAYLOAD); +}); diff --git a/fuzz/fuzz_targets/fuzz_http3_parser.rs b/fuzz/fuzz_targets/fuzz_http3_parser.rs new file mode 100644 index 0000000..54600a3 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_http3_parser.rs @@ -0,0 +1,26 @@ +#![no_main] + +use std::collections::HashMap; + +use libfuzzer_sys::fuzz_target; +use wgcloak::transport::http3::{ + decode_auth_challenge, decode_capsule, decode_http_datagram, is_auth_accept, + verify_auth_response, AuthChallenge, +}; + +const MAX_INPUT: usize = 64 * 1024; +const MAX_DATAGRAM: usize = 16 * 1024; + +fuzz_target!(|input: &[u8]| { + let input = &input[..input.len().min(MAX_INPUT)]; + // All of these are in-memory parsers. decode_http_datagram bounds the + // copied payload, while the capsule/auth parsers only copy this bounded + // input or fixed-size fields. + let _ = decode_http_datagram(input, 0, MAX_DATAGRAM); + let _ = decode_capsule(input); + let _ = decode_auth_challenge(input); + let _ = is_auth_accept(input); + let challenge = AuthChallenge { bytes: [0; 32] }; + let clients = HashMap::new(); + let _ = verify_auth_response(&challenge, input, &clients); +}); diff --git a/fuzz/fuzz_targets/fuzz_tls_frame.rs b/fuzz/fuzz_targets/fuzz_tls_frame.rs new file mode 100644 index 0000000..341c031 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_tls_frame.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use wgcloak::transport::tls_tcp::FrameCodec; + +const MAX_INPUT: usize = 64 * 1024; +const MAX_FRAME: usize = 16 * 1024; +const CHUNK: usize = 97; + +fuzz_target!(|input: &[u8]| { + let input = &input[..input.len().min(MAX_INPUT)]; + let Ok(mut codec) = FrameCodec::new(MAX_FRAME) else { + return; + }; + + // Feed deliberately uneven chunks so header/payload state transitions are + // exercised independently of TCP read boundaries. + for chunk in input.chunks(CHUNK) { + if codec.feed(chunk).is_err() { + break; + } + } + let _ = codec.finish(); +}); diff --git a/packaging/systemd/wgcloak-client.service b/packaging/systemd/wgcloak-client.service index 35972df..1a6fdfe 100644 --- a/packaging/systemd/wgcloak-client.service +++ b/packaging/systemd/wgcloak-client.service @@ -17,6 +17,7 @@ UMask=0077 RuntimeDirectory=wgcloak-client RuntimeDirectoryMode=0700 Environment=XDG_RUNTIME_DIR=/run/wgcloak-client +# Control socket: /run/wgcloak-client/wgcloak.sock # Process and filesystem hardening. Review paths if the deployment changes. NoNewPrivileges=true diff --git a/packaging/systemd/wgcloak-server.service b/packaging/systemd/wgcloak-server.service index 7d53e4c..4ec4b2b 100644 --- a/packaging/systemd/wgcloak-server.service +++ b/packaging/systemd/wgcloak-server.service @@ -17,6 +17,7 @@ UMask=0077 RuntimeDirectory=wgcloak-server RuntimeDirectoryMode=0700 Environment=XDG_RUNTIME_DIR=/run/wgcloak-server +# Control socket: /run/wgcloak-server/wgcloak.sock # Process and filesystem hardening. Review paths if the deployment changes. NoNewPrivileges=true diff --git a/src/client/supervisor.rs b/src/client/supervisor.rs index 8ccad64..4bcc590 100644 --- a/src/client/supervisor.rs +++ b/src/client/supervisor.rs @@ -21,16 +21,13 @@ pub type SessionWatchFuture<'a> = pub trait TransportProbe: Send + Sync + 'static { fn connect(&self, mode: TransportMode) -> ProbeFuture; - /// Wait for the established session to fail. The default keeps the - /// historical supervisor behavior for probes without an external - /// lifecycle signal. Concrete adapters should implement this using their - /// connection/driver task or cancellation signal, never `recv_datagram`. + /// Wait for the established session to fail. Implementations must use a + /// connection/driver task or cancellation signal, never `recv_datagram`, + /// so application datagrams remain owned by the runtime data plane. fn wait_for_session_failure<'a>( &'a self, - _session: &'a dyn TransportSession, - ) -> SessionWatchFuture<'a> { - Box::pin(std::future::pending()) - } + session: &'a dyn TransportSession, + ) -> SessionWatchFuture<'a>; } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -199,6 +196,10 @@ impl TransportSupervisor

{ if let Err(error @ TransportError::AuthenticationRejected(_)) = result { return Err(error); } + if let Err(error @ TransportError::Unsupported(_)) = result { + s.state = SupervisorState::Stopped; + return Err(error); + } drop(s); let delay = self.backoff_delay(); self.status.lock().await.backoff = delay; @@ -244,6 +245,17 @@ impl TransportProbe for Singl let connector = self.connector.clone(); Box::pin(async move { connector.connect().await }) } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> SessionWatchFuture<'a> { + Box::pin(async { + Err(TransportError::Unsupported( + "session lifecycle monitoring is not available for this connector".into(), + )) + }) + } } /// Milestone 1 adapter. Unsupported carriers fail as connectivity errors, so @@ -271,4 +283,15 @@ impl TransportProbe for DirectUdpProbe { .await }) } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> SessionWatchFuture<'a> { + Box::pin(async { + Err(TransportError::Unsupported( + "session lifecycle monitoring is not available for direct UDP".into(), + )) + }) + } } diff --git a/src/config.rs b/src/config.rs index 5b62242..4039c49 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,7 +13,11 @@ use tracing::{info, warn}; const DEFAULT_MAX_DATAGRAM_SIZE: u16 = 1200; const MIN_SECRET_BYTES: usize = 32; -const MAX_DATAGRAM_SIZE: u16 = u16::MAX - 32; +/// Project-wide safety ceiling for one opaque application datagram payload. +/// +/// Individual transports may negotiate a smaller effective maximum based on +/// their framing and path capabilities. +pub const MAX_APPLICATION_DATAGRAM_SIZE: u16 = 16_384; const MAX_KEEPALIVE_SECS: u64 = 86_400; const MAX_IDLE_TIMEOUT_SECS: u64 = 86_400; const MAX_PROBE_TIMEOUT_MS: u64 = 300_000; @@ -375,6 +379,8 @@ pub struct ServerConfig { pub server: ServerListenConfig, pub backend: BackendConfig, #[serde(default)] + pub network: NetworkConfig, + #[serde(default)] pub transports: ServerTransportsConfig, #[serde(default)] pub tls: TlsConfig, @@ -390,6 +396,7 @@ pub struct ServerConfig { impl ServerConfig { pub fn validate(&self) -> Result<(), ConfigError> { validate_host(&self.server.listen, "server.listen")?; + validate_max_datagram(self.network.max_datagram_size)?; let backend = self.backend.target.parse::().map_err(|_| { ConfigError::Invalid("backend.target must be a socket address".into()) @@ -744,13 +751,37 @@ fn validate_secret_source(source: &SecretSource, label: &str) -> Result<(), Conf } fn read_secret_file(path: &Path) -> Result, ConfigError> { - #[cfg(unix)] + use std::fs::OpenOptions; + + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(target_os = "linux")] { - use std::os::unix::fs::PermissionsExt; - let metadata = fs::metadata(path).map_err(|source| ConfigError::SecretRead { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options + .open(path) + .map_err(|source| ConfigError::SecretRead { path: path.to_path_buf(), source, })?; + let metadata = file.metadata().map_err(|source| ConfigError::SecretRead { + path: path.to_path_buf(), + source, + })?; + if !metadata.file_type().is_file() { + return Err(ConfigError::SecretRead { + path: path.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "secret path is not a regular file", + ), + }); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; let mode = metadata.permissions().mode() & 0o777; if mode & 0o077 != 0 { return Err(ConfigError::SecretPermissions { @@ -759,10 +790,14 @@ fn read_secret_file(path: &Path) -> Result, ConfigError> { }); } } - fs::read(path).map_err(|source| ConfigError::SecretRead { - path: path.to_path_buf(), - source, - }) + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut file, &mut bytes).map_err(|source| { + ConfigError::SecretRead { + path: path.to_path_buf(), + source, + } + })?; + Ok(bytes) } fn decode_secret(value: &str, label: &str) -> Result, ConfigError> { @@ -936,6 +971,23 @@ fn validate_http3_path(path: &str, label: &str) -> Result<(), ConfigError> { } } +/// Format a configured host and port for socket APIs. +/// +/// The server listen setting accepts both raw IPv6 literals and bracketed +/// literals. Socket APIs require brackets around a raw IPv6 host once a port +/// is appended, while an already complete socket address is preserved. +pub(crate) fn format_host_port(host: &str, port: u16) -> String { + if host.parse::().is_ok() { + host.to_owned() + } else if host.starts_with('[') { + format!("{host}:{port}") + } else if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + fn validate_udp_listener_ports(config: &ServerConfig) -> Result<(), ConfigError> { let udp_listeners = [ ( @@ -974,9 +1026,9 @@ fn validate_max_datagram(value: u16) -> Result<(), ConfigError> { Err(ConfigError::Invalid( "network.max_datagram_size must be > 0".into(), )) - } else if value > MAX_DATAGRAM_SIZE { + } else if value > MAX_APPLICATION_DATAGRAM_SIZE { Err(ConfigError::Invalid(format!( - "network.max_datagram_size must be <= {MAX_DATAGRAM_SIZE}" + "network.max_datagram_size must be <= {MAX_APPLICATION_DATAGRAM_SIZE}" ))) } else { Ok(()) @@ -1009,6 +1061,19 @@ mod tests { .to_string() } + #[test] + fn server_network_and_ipv6_listener_defaults_are_stable() { + let config = match Config::from_str(&server_text("::1", "")) { + Ok(Config::Server(config)) => config, + other => panic!("unexpected config result: {other:?}"), + }; + assert_eq!(config.network.max_datagram_size, DEFAULT_MAX_DATAGRAM_SIZE); + assert_eq!(format_host_port("::1", 443), "[::1]:443"); + assert_eq!(format_host_port("[::1]", 443), "[::1]:443"); + assert_eq!(format_host_port("127.0.0.1", 443), "127.0.0.1:443"); + assert_eq!(format_host_port("127.0.0.1:8443", 443), "127.0.0.1:8443"); + } + fn with_test_secrets(example: &str) -> String { example .lines() @@ -1154,7 +1219,7 @@ mod tests { "keepalive_secs", ), ("[network]\nmax_datagram_size=0\n", "max_datagram_size"), - ("[network]\nmax_datagram_size=65504\n", "max_datagram_size"), + ("[network]\nmax_datagram_size=16385\n", "max_datagram_size"), ] { let error = validation_error(&client_text("vpn.example.com", extra)); assert!(error.contains(expected), "expected {expected:?}: {error}"); @@ -1171,11 +1236,19 @@ mod tests { .unwrap(); Config::from_str(&client_text( "vpn.example.com", - "[network]\nmax_datagram_size=65503\n", + "[network]\nmax_datagram_size=16384\n", )) .unwrap() .validate() .expect("the direct packet payload ceiling is a valid limit"); + + for value in [0, 16_385] { + let error = validation_error(&client_text( + "vpn.example.com", + &format!("[network]\nmax_datagram_size={value}\n"), + )); + assert!(error.contains("network.max_datagram_size")); + } } #[test] @@ -1186,6 +1259,20 @@ mod tests { .validate() .unwrap_or_else(|error| panic!("expected {host:?} to validate: {error}")); } + Config::from_str(&server_text( + "127.0.0.1", + "[network]\nmax_datagram_size=16384\n", + )) + .unwrap() + .validate() + .expect("the project datagram ceiling is a valid server limit"); + for value in [0, 16_385] { + let error = validation_error(&server_text( + "127.0.0.1", + &format!("[network]\nmax_datagram_size={value}\n"), + )); + assert!(error.contains("network.max_datagram_size")); + } for host in [ "", "127.0.0.1:443", @@ -1326,4 +1413,23 @@ mod tests { Err(ConfigError::SecretPermissions { .. }) )); } + + #[cfg(target_os = "linux")] + #[test] + fn secret_file_rejects_symlinks_before_reading_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("secret"); + let link = dir.path().join("secret-link"); + fs::write(&target, SECRET).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let source = SecretSource { + secret_file: Some(link), + secret: None, + }; + assert!(matches!( + source.load("test"), + Err(ConfigError::SecretRead { .. }) + )); + } } diff --git a/src/control.rs b/src/control.rs index 49b538a..eb5270e 100644 --- a/src/control.rs +++ b/src/control.rs @@ -1,6 +1,7 @@ //! Bounded newline-delimited JSON control socket for local administration. use crate::stats::StatsSnapshot; +use serde_json::{json, Map, Value}; #[cfg(unix)] use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::{ @@ -16,15 +17,19 @@ use std::{ use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, net::{UnixListener, UnixStream}, - sync::{Mutex, Notify}, + sync::{Mutex, Notify, Semaphore}, task::JoinSet, }; use tokio_util::sync::CancellationToken; +pub const CONTROL_MAX_CLIENTS: usize = 32; +pub const CONTROL_MAX_REQUEST_BYTES: usize = 8192; +pub const CONTROL_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Clone)] pub struct ControlState { status: Arc>, - stats: Arc>, + stats: Arc>, transport_stats: Arc>>, pub reconnect: Arc, pub shutdown: CancellationToken, @@ -41,7 +46,7 @@ impl Default for ControlState { fn default() -> Self { Self { status: Arc::new(Mutex::new("starting".into())), - stats: Arc::new(Mutex::new("{}".into())), + stats: Arc::new(Mutex::new(json!({}))), transport_stats: Arc::new(Mutex::new(BTreeMap::new())), reconnect: Arc::new(Notify::new()), shutdown: CancellationToken::new(), @@ -54,7 +59,8 @@ impl ControlState { *self.status.lock().await = value.into(); } pub async fn set_stats_json(&self, value: impl Into) { - *self.stats.lock().await = value.into(); + let value = value.into(); + *self.stats.lock().await = serde_json::from_str(&value).unwrap_or(Value::String(value)); } /// Publish one listener's latest counters. The control-plane view retains @@ -103,39 +109,30 @@ impl ControlState { } let mut aggregate = StatsSnapshot::default(); let mut active_total = 0usize; - let mut by_transport = Vec::with_capacity(transports.len()); - let mut per_transport = Vec::with_capacity(transports.len()); + let mut by_transport = Map::new(); + let mut per_transport = Map::new(); let mut by_client_id = BTreeMap::::new(); - let mut by_transport_and_client = Vec::with_capacity(transports.len()); + let mut by_transport_and_client = Map::new(); for (name, stats) in transports.iter() { active_total = active_total.saturating_add(stats.active_sessions); aggregate = aggregate.saturating_add(stats.counters); - by_transport.push(format!("{}:{}", json_string(name), stats.active_sessions)); - per_transport.push(format!( - "{}:{{{}}}", - json_string(name), - snapshot_fields(stats.counters) - )); + by_transport.insert(name.clone(), json!(stats.active_sessions)); + per_transport.insert(name.clone(), snapshot_value(stats.counters)); let client_entries = stats .active_sessions_by_client_id .iter() .map(|(client_id, count)| { let total = by_client_id.entry(client_id.clone()).or_default(); *total = total.saturating_add(*count); - format!("{}:{count}", json_string(client_id)) + (client_id.clone(), json!(*count)) }) - .collect::>(); - by_transport_and_client.push(format!( - "{}:{{{}}}", - json_string(name), - client_entries.join(",") - )); + .collect::>(); + by_transport_and_client.insert(name.clone(), Value::Object(client_entries)); } let by_client_id = by_client_id .iter() - .map(|(client_id, count)| format!("{}:{count}", json_string(client_id))) - .collect::>() - .join(","); + .map(|(client_id, count)| (client_id.clone(), json!(*count))) + .collect::>(); let label = if transports.len() == 1 { transports .keys() @@ -145,14 +142,27 @@ impl ControlState { } else { "multi" }; - let json = format!( - "{{\"transport\":\"{label}\",\"active_sessions\":{active_total},\"active_sessions_by_transport\":{{{}}},\"active_sessions_by_client_id\":{{{by_client_id}}},\"active_sessions_by_transport_and_client_id\":{{{}}},\"transports\":{{{}}},{}}}", - by_transport.join(","), - by_transport_and_client.join(","), - per_transport.join(","), - snapshot_fields(aggregate), - ); - *self.stats.lock().await = json; + *self.stats.lock().await = json!({ + "transport": label, + "active_sessions": active_total, + "active_sessions_by_transport": by_transport, + "active_sessions_by_client_id": by_client_id, + "active_sessions_by_transport_and_client_id": by_transport_and_client, + "transports": per_transport, + "bytes_sent": aggregate.bytes_sent, + "bytes_received": aggregate.bytes_received, + "datagrams_sent": aggregate.datagrams_sent, + "datagrams_received": aggregate.datagrams_received, + "datagrams_dropped": aggregate.datagrams_dropped, + "oversized_datagrams": aggregate.oversized_datagrams, + "authentication_failures": aggregate.authentication_failures, + "reconnections": aggregate.reconnections, + "expired_sessions": aggregate.expired_sessions, + "handshake_attempts": aggregate.handshake_attempts, + "authentication_rejections": aggregate.authentication_rejections, + "rate_limited_attempts": aggregate.rate_limited_attempts, + "backend_errors": aggregate.backend_errors, + }); } async fn status_with_activity(&self) -> String { @@ -183,34 +193,22 @@ impl ControlState { } } -fn json_string(value: &str) -> String { - format!( - "\"{}\"", - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") - ) -} - -fn snapshot_fields(snapshot: StatsSnapshot) -> String { - format!( - "\"bytes_sent\":{},\"bytes_received\":{},\"datagrams_sent\":{},\"datagrams_received\":{},\"datagrams_dropped\":{},\"oversized_datagrams\":{},\"authentication_failures\":{},\"reconnections\":{},\"expired_sessions\":{},\"handshake_attempts\":{},\"authentication_rejections\":{},\"rate_limited_attempts\":{},\"backend_errors\":{}", - snapshot.bytes_sent, - snapshot.bytes_received, - snapshot.datagrams_sent, - snapshot.datagrams_received, - snapshot.datagrams_dropped, - snapshot.oversized_datagrams, - snapshot.authentication_failures, - snapshot.reconnections, - snapshot.expired_sessions, - snapshot.handshake_attempts, - snapshot.authentication_rejections, - snapshot.rate_limited_attempts, - snapshot.backend_errors, - ) +fn snapshot_value(snapshot: StatsSnapshot) -> Value { + json!({ + "bytes_sent": snapshot.bytes_sent, + "bytes_received": snapshot.bytes_received, + "datagrams_sent": snapshot.datagrams_sent, + "datagrams_received": snapshot.datagrams_received, + "datagrams_dropped": snapshot.datagrams_dropped, + "oversized_datagrams": snapshot.oversized_datagrams, + "authentication_failures": snapshot.authentication_failures, + "reconnections": snapshot.reconnections, + "expired_sessions": snapshot.expired_sessions, + "handshake_attempts": snapshot.handshake_attempts, + "authentication_rejections": snapshot.authentication_rejections, + "rate_limited_attempts": snapshot.rate_limited_attempts, + "backend_errors": snapshot.backend_errors, + }) } pub struct ControlServer { @@ -218,6 +216,7 @@ pub struct ControlServer { path: PathBuf, socket_identity: (u64, u64), state: ControlState, + client_limit: Arc, } impl ControlServer { pub async fn bind(path: impl Into, state: ControlState) -> io::Result { @@ -282,12 +281,36 @@ impl ControlServer { path, socket_identity: (metadata.dev(), metadata.ino()), state, + client_limit: Arc::new(Semaphore::new(CONTROL_MAX_CLIENTS)), }) } pub async fn run(self) -> io::Result<()> { let mut tasks = JoinSet::new(); loop { - tokio::select! { _ = self.state.shutdown.cancelled() => break, Some(result) = tasks.join_next() => { if let Err(error) = result { tracing::debug!(%error, "control connection ended"); } }, accepted = self.listener.accept() => { let (stream, _) = accepted?; let state = self.state.clone(); tasks.spawn(async move { let _ = handle_connection(stream, state).await; }); } } + tokio::select! { + _ = self.state.shutdown.cancelled() => break, + Some(result) = tasks.join_next() => { + if let Err(error) = result { + tracing::debug!(%error, "control connection ended"); + } + }, + accepted = self.listener.accept() => { + let (mut stream, _) = accepted?; + let permit = self.client_limit.clone().try_acquire_owned(); + match permit { + Ok(permit) => { + let state = self.state.clone(); + tasks.spawn(async move { + let _permit = permit; + let _ = handle_connection(stream, state).await; + }); + } + Err(_) => { + let _ = stream.shutdown().await; + } + } + } + } } // A client may connect and then never finish writing a command. Do not // let that local connection pin daemon shutdown indefinitely. @@ -369,76 +392,80 @@ fn current_uid() -> io::Result { async fn handle_connection(stream: UnixStream, state: ControlState) -> io::Result<()> { let (read, mut write) = stream.into_split(); - let mut reader = BufReader::new(read).take(8192); + let mut reader = BufReader::new(read).take(CONTROL_MAX_REQUEST_BYTES as u64); let mut line = String::new(); - reader.read_line(&mut line).await?; - let response = if line.len() >= 8192 { - "{\"ok\":false,\"error\":\"request too large\"}\n".to_owned() + let read_result = + tokio::time::timeout(CONTROL_REQUEST_TIMEOUT, reader.read_line(&mut line)).await; + let bytes_read = match read_result { + Ok(result) => result?, + Err(_) => return Ok(()), + }; + let response = if bytes_read >= CONTROL_MAX_REQUEST_BYTES { + json!({"ok": false, "error": "request too large"}) } else { match command_name(&line) { Some("status") => { let value = state.status_with_activity().await; - format!("{{\"ok\":true,\"status\":\"{}\"}}\n", json_escape(&value)) + json!({"ok": true, "status": value}) } - Some("stats") => format!("{{\"ok\":true,\"stats\":{}}}\n", state.stats.lock().await), + Some("stats") => json!({"ok": true, "stats": state.stats.lock().await.clone()}), Some("reconnect") => { if state.reconnect_supported.load(Ordering::Acquire) { state.reconnect.notify_one(); - "{\"ok\":true,\"command\":\"reconnect\"}\n".into() + json!({"ok": true, "command": "reconnect"}) } else { - "{\"ok\":false,\"error\":\"reconnect is not supported by this daemon\"}\n" - .into() + json!({"ok": false, "error": "reconnect is not supported by this daemon"}) } } // Reload is deliberately rejected until there is a file-backed // runtime configuration source and an atomic apply path. Some("reload") => { - "{\"ok\":false,\"error\":\"configuration reload is not supported\"}\n".into() + json!({"ok": false, "error": "configuration reload is not supported"}) } Some("shutdown") => { state.shutdown.cancel(); - "{\"ok\":true,\"command\":\"shutdown\"}\n".into() + json!({"ok": true, "command": "shutdown"}) } - _ => "{\"ok\":false,\"error\":\"unknown command\"}\n".into(), + _ => json!({"ok": false, "error": "unknown command"}), } }; - write.write_all(response.as_bytes()).await + let mut response = serde_json::to_vec(&response) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + response.push(b'\n'); + write.write_all(&response).await } -fn command_name(input: &str) -> Option<&str> { +#[derive(serde::Deserialize)] +struct ControlRequest { + command: String, +} + +fn command_name(input: &str) -> Option<&'static str> { let input = input.trim(); if matches!( input, "status" | "stats" | "reconnect" | "reload" | "shutdown" ) { - return Some(input); + return Some(match input { + "status" => "status", + "stats" => "stats", + "reconnect" => "reconnect", + "reload" => "reload", + "shutdown" => "shutdown", + _ => unreachable!(), + }); } - let rest = input - .split_once("\"command\"")? - .1 - .trim_start() - .strip_prefix(':')? - .trim_start() - .strip_prefix('"')?; - let command = &rest[..rest.find('"')?]; - if matches!( - command, - "status" | "stats" | "reconnect" | "reload" | "shutdown" - ) { - Some(command) - } else { - None + let request = serde_json::from_str::(input).ok()?; + match request.command.as_str() { + "status" => Some("status"), + "stats" => Some("stats"), + "reconnect" => Some("reconnect"), + "reload" => Some("reload"), + "shutdown" => Some("shutdown"), + _ => None, } } -fn json_escape(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") -} - pub fn default_socket_path() -> PathBuf { let uid = current_uid().unwrap_or(0); if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") @@ -480,9 +507,10 @@ fn is_private_runtime_dir(path: &Path, uid: u32) -> bool { } pub async fn send_command(path: impl AsRef, command: &str) -> io::Result { let mut stream = UnixStream::connect(path).await?; - stream - .write_all(format!("{{\"command\":\"{}\"}}\n", command).as_bytes()) - .await?; + let mut request = serde_json::to_vec(&json!({"command": command})) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + request.push(b'\n'); + stream.write_all(&request).await?; let mut response = Vec::new(); stream.read_to_end(&mut response).await?; Ok(String::from_utf8_lossy(&response).into_owned()) diff --git a/src/server/mod.rs b/src/server/mod.rs index c8ce095..db70e34 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -62,6 +62,7 @@ pub async fn run_with_control_path( async fn run_listener(config: ServerConfig, state: ControlState) -> Result<()> { validate_udp_listener_conflicts(&config)?; let backend = config.backend.target.parse::()?; + let idle_timeout = Duration::from_secs(config.session.idle_timeout_secs); let mut tasks = JoinSet::new(); let mut transports = Vec::new(); let direct_active = Arc::new(AtomicUsize::new(0)); @@ -82,7 +83,7 @@ async fn run_listener(config: ServerConfig, state: ControlState) -> Result<()> { transports.push("quic"); let state = state.clone(); let active = quic_active.clone(); - tasks.spawn(async move { run_quic(listener, backend, state, active).await }); + tasks.spawn(async move { run_quic(listener, backend, idle_timeout, state, active).await }); } if config.transports.http3.enabled { let listener = @@ -90,7 +91,7 @@ async fn run_listener(config: ServerConfig, state: ControlState) -> Result<()> { transports.push("http3"); let state = state.clone(); let active = http3_active.clone(); - tasks.spawn(async move { run_http3(listener, backend, state, active).await }); + tasks.spawn(async move { run_http3(listener, backend, idle_timeout, state, active).await }); } if config.transports.tls_tcp.enabled { let listener = @@ -98,7 +99,7 @@ async fn run_listener(config: ServerConfig, state: ControlState) -> Result<()> { transports.push("tls_tcp"); let state = state.clone(); let active = tls_active.clone(); - tasks.spawn(async move { run_tls(listener, backend, state, active).await }); + tasks.spawn(async move { run_tls(listener, backend, idle_timeout, state, active).await }); } if tasks.is_empty() { anyhow::bail!("no server transport enabled") @@ -215,6 +216,7 @@ async fn run_direct( async fn run_http3( listener: Arc, backend: SocketAddr, + idle_timeout: Duration, state: ControlState, active: Arc, ) -> Result<()> { @@ -249,7 +251,7 @@ async fn run_http3( tasks.spawn(async move { let _guard = ActiveSessionGuard(active); let _client_session = client_session; - if let Err(error) = relay_session(Box::new(session), backend, state, stats.clone()).await { + if let Err(error) = relay_session(Box::new(session), backend, idle_timeout, state, stats.clone()).await { stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); warn!(%error, "HTTP/3 session relay ended with an error"); } @@ -292,6 +294,7 @@ async fn run_http3( async fn run_quic( listener: Arc, backend: SocketAddr, + idle_timeout: Duration, state: ControlState, active: Arc, ) -> Result<()> { @@ -326,7 +329,7 @@ async fn run_quic( tasks.spawn(async move { let _guard = ActiveSessionGuard(active); let _client_session = client_session; - if let Err(error) = relay_session(Box::new(session), backend, state, stats.clone()).await { + if let Err(error) = relay_session(Box::new(session), backend, idle_timeout, state, stats.clone()).await { stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); warn!(%error, "QUIC session relay ended with an error"); } @@ -363,6 +366,7 @@ async fn run_quic( async fn run_tls( listener: Arc, backend: SocketAddr, + idle_timeout: Duration, state: ControlState, active: Arc, ) -> Result<()> { @@ -392,7 +396,7 @@ async fn run_tls( tasks.spawn(async move { let _guard = ActiveSessionGuard(active); let _client_session = client_session; - if let Err(error) = relay_session(session, backend, state, stats.clone()).await { + if let Err(error) = relay_session(session, backend, idle_timeout, state, stats.clone()).await { stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); warn!(%error, "TLS/TCP session relay ended with an error"); } @@ -464,22 +468,40 @@ async fn drain_sessions(tasks: &mut JoinSet<()>) { async fn relay_session( session: Box, backend_target: SocketAddr, + idle_timeout: Duration, state: ControlState, stats: Arc, ) -> Result<()> { - let result = relay_session_inner(session.as_ref(), backend_target, &state, &stats).await; + let result = relay_session_inner( + session.as_ref(), + backend_target, + idle_timeout, + &state, + &stats, + ) + .await; + if matches!(result, Ok(RelayOutcome::IdleExpired)) { + stats.expired_sessions.fetch_add(1, Ordering::Relaxed); + } // TLS/TCP has a bounded close handshake; cap all carriers uniformly so // session cleanup cannot hold the listener's shutdown path indefinitely. let _ = timeout(Duration::from_secs(2), session.close()).await; - result + result.map(|_| ()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RelayOutcome { + Shutdown, + IdleExpired, } async fn relay_session_inner( session: &dyn TransportSession, backend_target: SocketAddr, + idle_timeout: Duration, state: &ControlState, stats: &Stats, -) -> Result<()> { +) -> Result { let backend = match UdpSocket::bind("0.0.0.0:0").await { Ok(socket) => socket, Err(error) => { @@ -492,9 +514,11 @@ async fn relay_session_inner( return Err(error.into()); } let mut buf = vec![0u8; session.max_datagram_size().saturating_add(1)]; + let mut idle = Box::pin(tokio::time::sleep(idle_timeout)); loop { tokio::select! { - _ = state.shutdown.cancelled() => return Ok(()), + _ = state.shutdown.cancelled() => return Ok(RelayOutcome::Shutdown), + _ = &mut idle => return Ok(RelayOutcome::IdleExpired), received = session.recv_datagram() => { let payload = received?; if payload.len() > session.max_datagram_size() { @@ -503,7 +527,10 @@ async fn relay_session_inner( continue; } match timeout(Duration::from_secs(2), backend.send(&payload)).await { - Ok(Ok(_)) => stats.add_received(payload.len()), + Ok(Ok(_)) => { + stats.add_received(payload.len()); + idle.as_mut().reset(tokio::time::Instant::now() + idle_timeout); + } Ok(Err(error)) => { stats.backend_errors.fetch_add(1, Ordering::Relaxed); stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); @@ -547,6 +574,7 @@ async fn relay_session_inner( } } stats.add_sent(len); + idle.as_mut().reset(tokio::time::Instant::now() + idle_timeout); } } } @@ -600,3 +628,53 @@ async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + struct IdleSession { + closed: Arc, + } + + impl TransportSession for IdleSession { + fn send_datagram<'a>(&'a self, _data: Bytes) -> crate::transport::TransportFuture<'a, ()> { + Box::pin(async { Ok(()) }) + } + + fn recv_datagram<'a>(&'a self) -> crate::transport::TransportFuture<'a, Bytes> { + Box::pin(async { std::future::pending().await }) + } + + fn close<'a>(&'a self) -> crate::transport::TransportFuture<'a, ()> { + self.closed.store(true, Ordering::Relaxed); + Box::pin(async { Ok(()) }) + } + + fn max_datagram_size(&self) -> usize { + 1200 + } + } + + #[tokio::test] + async fn encrypted_idle_expiry_is_counted_and_closed() { + let closed = Arc::new(AtomicBool::new(false)); + let stats = Arc::new(Stats::default()); + let state = ControlState::default(); + let session = Box::new(IdleSession { + closed: closed.clone(), + }); + let result = relay_session( + session, + "127.0.0.1:9".parse().unwrap(), + Duration::from_millis(10), + state, + stats.clone(), + ) + .await; + assert!(result.is_ok()); + assert!(closed.load(Ordering::Relaxed)); + assert_eq!(stats.snapshot().expired_sessions, 1); + } +} diff --git a/src/transport/direct_udp.rs b/src/transport/direct_udp.rs index a685d1b..503febe 100644 --- a/src/transport/direct_udp.rs +++ b/src/transport/direct_udp.rs @@ -1,8 +1,8 @@ //! Authenticated, opaque-datagram direct UDP transport. use std::{ - collections::HashMap, - net::SocketAddr, + collections::{HashMap, VecDeque}, + net::{IpAddr, SocketAddr}, sync::{ atomic::{AtomicU32, Ordering}, Arc, @@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use crate::{ - config::{ClientConfig, ServerConfig}, + config::{format_host_port, ClientConfig, ServerConfig}, protocol::{ self, auth, direct::{ @@ -37,10 +37,10 @@ use crate::{ use super::{TransportConnector, TransportError, TransportFuture, TransportSession}; -const DEFAULT_MAX_DATAGRAM: usize = 1200; const MAX_UDP_DATAGRAM: usize = u16::MAX as usize; const HELLO_REPLAY_WINDOW: Duration = Duration::from_secs(120); const MAX_HELLO_REPLAY_ENTRIES: usize = 4096; +const MAX_AUTH_ATTEMPT_IPS: usize = 4096; fn unix_timestamp() -> u64 { SystemTime::now() @@ -53,7 +53,11 @@ async fn resolve_server(server: &str, port: u16) -> Result { if let Ok(addr) = server.parse::() { return Ok(addr); } - let endpoint = format!("{server}:{port}"); + let endpoint = if let Ok(address) = server.parse::() { + format!("[{address}]:{port}") + } else { + format!("{server}:{port}") + }; lookup_host(endpoint.clone()) .await? .next() @@ -61,11 +65,7 @@ async fn resolve_server(server: &str, port: u16) -> Result { } fn direct_bind_addr(listen: &str, port: u16) -> Result { - if listen.parse::().is_ok() { - Ok(listen.to_owned()) - } else { - Ok(format!("{listen}:{port}")) - } + Ok(format_host_port(listen, port)) } /// Established client-side direct UDP session. @@ -105,7 +105,12 @@ impl DirectUdpSession { e.to_string(), )) })?; - let socket = UdpSocket::bind("0.0.0.0:0").await?; + let bind = if peer.is_ipv6() { + "[::]:0" + } else { + "0.0.0.0:0" + }; + let socket = UdpSocket::bind(bind).await?; let socket = Arc::new(socket); let mut nonce = [0u8; 16]; OsRng.fill_bytes(&mut nonce); @@ -372,6 +377,12 @@ struct HelloReplayCache { entries: HashMap, } +#[derive(Default)] +struct AuthAttemptWindow { + attempts: VecDeque, + last_seen: Option, +} + impl HelloReplayCache { fn purge_expired(&mut self, now: Instant) { self.entries.retain(|_, entry| entry.expires_at > now); @@ -446,6 +457,7 @@ pub struct DirectUdpServer { pending: Arc>>, config: ServerConfig, stats: Arc, + auth_attempts: Mutex>, } /// Read-only observation handle that remains usable after `run(self)` takes @@ -507,6 +519,7 @@ impl DirectUdpServer { pending: Arc::new(Mutex::new(HashMap::new())), config, stats: Arc::new(Stats::default()), + auth_attempts: Mutex::new(HashMap::new()), }) } pub fn local_addr(&self) -> Result { @@ -548,6 +561,56 @@ impl DirectUdpServer { .handshake_attempts .fetch_add(1, Ordering::Relaxed); let now = Instant::now(); + + // An exact retry of an accepted HELLO must be able to recover a lost + // ACCEPT without consuming another authentication-attempt slot. The + // cache also rejects changed data or a changed source as replay before + // any expensive credential lookup or MAC verification. + match self.hello_cache.lock().await.lookup(&hello, peer, now) { + HelloCacheLookup::Retry { + session_id, + accept_bytes, + } => { + let active = self.sessions.lock().await.contains_key(&session_id); + if !active { + self.hello_cache + .lock() + .await + .deactivate(&HelloCacheKey::new(&hello), session_id); + self.send_reject(peer, RejectReason::ReplayDetected).await; + return Ok(None); + } + match self.socket.send_to(&accept_bytes, peer).await { + Ok(_) => self.stats.add_sent(accept_bytes.len()), + Err(error) => { + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + warn!(remote_addr=%peer, %error, "failed to retransmit cached direct UDP ACCEPT") + } + } + return Ok(None); + } + HelloCacheLookup::Replay => { + self.send_reject(peer, RejectReason::ReplayDetected).await; + return Ok(None); + } + HelloCacheLookup::Full => { + self.stats + .rate_limited_attempts + .fetch_add(1, Ordering::Relaxed); + self.send_reject(peer, RejectReason::RateLimited).await; + return Ok(None); + } + HelloCacheLookup::New => {} + } + + if !self.record_auth_attempt(peer.ip(), now).await { + self.stats + .rate_limited_attempts + .fetch_add(1, Ordering::Relaxed); + self.send_reject(peer, RejectReason::RateLimited).await; + return Ok(None); + } + { let mut pending = self.pending.lock().await; let expiry = Duration::from_millis(self.config.limits.handshake_timeout_ms.max(1)); @@ -583,50 +646,6 @@ impl DirectUdpServer { return Ok(None); } - let now = Instant::now(); - let cache_lookup = self.hello_cache.lock().await.lookup(&hello, peer, now); - match cache_lookup { - HelloCacheLookup::Retry { - session_id, - accept_bytes, - } => { - self.pending.lock().await.remove(&peer); - let active = self.sessions.lock().await.contains_key(&session_id); - if !active { - self.hello_cache - .lock() - .await - .deactivate(&HelloCacheKey::new(&hello), session_id); - self.send_reject(peer, RejectReason::ReplayDetected).await; - return Ok(None); - } - match self.socket.send_to(&accept_bytes, peer).await { - Ok(_) => self.stats.add_sent(accept_bytes.len()), - Err(error) => { - self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); - warn!(remote_addr=%peer, %error, "failed to retransmit cached direct UDP ACCEPT") - } - } - // The session task already exists; a retry only resends the - // original response and must not start another backend loop. - return Ok(None); - } - HelloCacheLookup::Replay => { - self.pending.lock().await.remove(&peer); - self.send_reject(peer, RejectReason::ReplayDetected).await; - return Ok(None); - } - HelloCacheLookup::Full => { - self.pending.lock().await.remove(&peer); - self.stats - .rate_limited_attempts - .fetch_add(1, Ordering::Relaxed); - self.send_reject(peer, RejectReason::RateLimited).await; - return Ok(None); - } - HelloCacheLookup::New => {} - } - // Exact authenticated retries reuse the original response for the // entire replay-cache window, even after the initial timestamp skew // window has elapsed. A new nonce still must be fresh before allocation. @@ -668,7 +687,7 @@ impl DirectUdpServer { let mut server_nonce = [0u8; 16]; OsRng.fill_bytes(&mut server_nonce); let idle = self.config.session.idle_timeout_secs.min(u32::MAX as u64) as u32; - let max_datagram = DEFAULT_MAX_DATAGRAM as u16; + let max_datagram = self.config.network.max_datagram_size; let key = auth::derive_session_key(secret, &hello.client_nonce, &server_nonce, session_id); let session = Arc::new(ServerSession { id: session_id, @@ -719,6 +738,35 @@ impl DirectUdpServer { Ok(Some(session)) } + async fn record_auth_attempt(&self, ip: IpAddr, now: Instant) -> bool { + let window_duration = + Duration::from_secs(self.config.limits.auth_attempt_window_secs.max(1)); + let mut attempts = self.auth_attempts.lock().await; + attempts.retain(|_, window| { + window + .last_seen + .is_some_and(|last_seen| now.duration_since(last_seen) < window_duration) + }); + if !attempts.contains_key(&ip) && attempts.len() >= MAX_AUTH_ATTEMPT_IPS { + return false; + } + let window = attempts.entry(ip).or_default(); + while window + .attempts + .front() + .is_some_and(|attempt| now.duration_since(*attempt) >= window_duration) + { + window.attempts.pop_front(); + } + if window.attempts.len() >= self.config.limits.max_auth_attempts_per_ip { + window.last_seen = Some(now); + return false; + } + window.attempts.push_back(now); + window.last_seen = Some(now); + true + } + async fn remove_session(&self, id: u64, expired: bool) { if let Some(session) = self.sessions.lock().await.remove(&id) { session.cancel.cancel(); @@ -762,13 +810,19 @@ impl DirectUdpServer { _ = cleanup.tick() => { let now = Instant::now(); self.hello_cache.lock().await.purge_expired(now); + let window_duration = Duration::from_secs(self.config.limits.auth_attempt_window_secs.max(1)); + self.auth_attempts.lock().await.retain(|_, window| { + window + .last_seen + .is_some_and(|last_seen| now.duration_since(last_seen) < window_duration) + }); let expired: Vec = self.sessions.lock().await.iter().filter_map(|(&id,s)| async_ready_expired(s, now, Duration::from_secs(self.config.session.idle_timeout_secs)).map(|_| id)).collect(); for id in expired { self.remove_session(id, true).await; } } received = self.socket.recv_from(&mut buf) => { let (len, peer) = match received { Ok(v) => v, Err(e) => { warn!(error=%e, "direct UDP receive failed"); continue; } }; self.stats.add_received(len); - let packet = match protocol::direct::parse_packet_bounded(&buf[..len], DEFAULT_MAX_DATAGRAM) { + let packet = match protocol::direct::parse_packet_bounded(&buf[..len], usize::from(self.config.network.max_datagram_size)) { Ok(p) => p, Err(protocol::direct::ProtocolError::OversizedDataPayload { actual, maximum }) => { self.stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed); diff --git a/src/transport/http3.rs b/src/transport/http3.rs index f52f787..8a26444 100644 --- a/src/transport/http3.rs +++ b/src/transport/http3.rs @@ -10,11 +10,11 @@ use std::{ collections::HashMap, fmt, future::Future, - net::{SocketAddr, ToSocketAddrs}, + net::SocketAddr, pin::Pin, str::FromStr, sync::{Arc, Mutex as StdMutex, Once}, - time::Duration, + time::{Duration, Instant}, }; use bytes::{Buf, Bytes, BytesMut}; @@ -23,7 +23,10 @@ use hmac::{Hmac, Mac}; use rand::{rngs::OsRng, RngCore}; use sha2::Sha256; use subtle::ConstantTimeEq; -use tokio::{sync::Mutex, task::JoinHandle}; +use tokio::{ + sync::Mutex, + task::{AbortHandle, JoinHandle}, +}; use tokio_util::sync::CancellationToken; use tracing::debug; @@ -52,6 +55,10 @@ const CHALLENGE_LEN: usize = 32; const CLIENT_NONCE_LEN: usize = 16; const MAC_LEN: usize = 32; const MAX_CLIENT_ID: usize = u8::MAX as usize; +// Application error code used after the rejection capsule has been attempted. +// A distinct code lets a client classify a close racing the final capsule +// without treating unrelated connection failures as authentication errors. +const AUTH_REJECT_CLOSE_CODE: u32 = 0x100; type HmacSha256 = Hmac; #[derive(Debug, Clone, PartialEq, Eq)] @@ -725,6 +732,7 @@ pub struct Http3ConnectUdpSession { quic_connection: quinn::Connection, client_id: String, driver: Mutex>>, + driver_abort: AbortHandle, client_sender: Mutex>>, } @@ -754,28 +762,73 @@ impl Http3ConnectUdpSession { } pub async fn close(&self, timeout: Duration) -> Result<(), Http3Error> { - self.datagrams.close(timeout).await?; - let finish_result = { + let deadline = Instant::now() + timeout; + let mut first_error = self.datagrams.close(remaining(deadline)).await.err(); + if let Some(error) = { let mut control = self.control.lock().await; match control.take() { - Some(mut control) => tokio::time::timeout(timeout, control.finish()) - .await - .map_err(|_| Http3Error::Io("CONNECT-UDP close timed out".into()))?, - None => Ok(()), + Some(mut control) => { + let timeout = remaining(deadline); + if timeout.is_zero() { + Some(Http3Error::Io("CONNECT-UDP close timed out".into())) + } else { + match tokio::time::timeout(timeout, control.finish()).await { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(Http3Error::Io(error)), + Err(_) => Some(Http3Error::Io("CONNECT-UDP close timed out".into())), + } + } + } + None => None, } - }; + } { + if first_error.is_none() { + first_error = Some(error); + } + } self.quic_connection .close(0u32.into(), b"CONNECT-UDP closed"); self.client_sender.lock().await.take(); if let Some(mut driver) = self.driver.lock().await.take() { - if tokio::time::timeout(timeout, &mut driver).await.is_err() { - // Closing the QUIC connection above is authoritative; abort - // the driver only if it did not observe that close in time. - driver.abort(); + let timeout = remaining(deadline); + if timeout.is_zero() { + self.driver_abort.abort(); let _ = driver.await; + } else { + match tokio::time::timeout(timeout, &mut driver).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + if first_error.is_none() { + first_error = Some(Http3Error::Io(format!( + "HTTP/3 driver failed during close: {error}" + ))); + } + } + Err(_) => { + // Closing the QUIC connection above is authoritative; + // abort and reap a driver which does not observe it. + self.driver_abort.abort(); + let _ = driver.await; + } + } } } - finish_result.map_err(Http3Error::Io) + first_error.map_or(Ok(()), Err) + } +} + +fn remaining(deadline: Instant) -> Duration { + deadline.saturating_duration_since(Instant::now()) +} + +impl Drop for Http3ConnectUdpSession { + fn drop(&mut self) { + // Drop cannot await a JoinHandle. Abort through the owned handle before + // dropping the join handle so an abandoned session never leaves a live + // HTTP/3 driver detached from its QUIC connection. + self.driver_abort.abort(); + self.quic_connection + .close(0u32.into(), b"CONNECT-UDP session dropped"); } } @@ -888,9 +941,19 @@ impl Http3ClientConnection { tokio::time::timeout(handshake_timeout, control.send_capsule(response)) .await .map_err(|_| Http3Error::IdleTimeout)??; - let result_capsule = tokio::time::timeout(handshake_timeout, control.recv_capsule()) - .await - .map_err(|_| Http3Error::IdleTimeout)??; + let result_capsule = + match tokio::time::timeout(handshake_timeout, control.recv_capsule()).await { + Ok(Ok(capsule)) => capsule, + Ok(Err(_error)) + if is_auth_rejection_close( + self.quic_connection.as_ref().ok_or(Http3Error::Closed)?, + ) => + { + return Err(Http3Error::AuthenticationRejected) + } + Ok(Err(error)) => return Err(error), + Err(_) => return Err(Http3Error::IdleTimeout), + }; let remote_maximum = auth_accept_limit(&result_capsule)?.ok_or(Http3Error::InvalidCapsule)?; if control.has_trailing_data() || remote_maximum == 0 { @@ -910,12 +973,18 @@ impl Http3ClientConnection { stream_id, )?, ); + let driver_abort = self + .driver + .as_ref() + .map(JoinHandle::abort_handle) + .ok_or(Http3Error::Closed)?; Ok(Http3ConnectUdpSession { datagrams: ConnectUdpSession::new(io, maximum_datagram), control: Mutex::new(Some(Box::new(control))), quic_connection: self.quic_connection.take().ok_or(Http3Error::Closed)?, client_id: client_id.to_owned(), driver: Mutex::new(self.driver.take()), + driver_abort, client_sender: Mutex::new(self.send_request.take()), }) } @@ -1034,9 +1103,15 @@ impl Http3ServerConnection { let reject = encode_auth_reject()?; let _ = tokio::time::timeout(handshake_timeout, control.send_capsule(reject)).await; let _ = tokio::time::timeout(handshake_timeout, control.stream.finish()).await; - if let Some(connection) = self.h3_connection.take() { - tokio::spawn(drive_server_connection(connection)); - } + // Authentication failure is terminal for this QUIC + // connection. Deliver the rejection where possible, then + // close the connection and drop the H3 driver state instead + // of detaching a task that could retain the peer forever. + self.quic_connection.close( + AUTH_REJECT_CLOSE_CODE.into(), + b"CONNECT-UDP authentication rejected", + ); + self.h3_connection.take(); return Err( if matches!( error, @@ -1072,17 +1147,27 @@ impl Http3ServerConnection { stream_id, http_datagram_payload_limit(wire_maximum, stream_id)?, ); + let driver_abort = driver.abort_handle(); Ok(Http3ConnectUdpSession { datagrams: ConnectUdpSession::new(io, maximum_datagram), control: Mutex::new(Some(Box::new(control))), quic_connection: self.quic_connection, client_id: authenticated_client_id, driver: Mutex::new(Some(driver)), + driver_abort, client_sender: Mutex::new(None), }) } } +fn is_auth_rejection_close(connection: &quinn::Connection) -> bool { + matches!( + connection.close_reason(), + Some(quinn::ConnectionError::ApplicationClosed(close)) + if close.error_code.into_inner() == AUTH_REJECT_CLOSE_CODE as u64 + ) +} + async fn drive_server_connection( mut connection: h3::server::Connection, ) { @@ -1276,17 +1361,9 @@ impl Http3ClientConnector { } async fn connect_session(&self) -> Result, TransportError> { - let host = self.config.client.server.trim(); - let address = (host, self.config.transport.http3.port) - .to_socket_addrs() - .map_err(TransportError::Io)? - .next() - .ok_or_else(|| { - TransportError::Io(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "server hostname resolved to no address", - )) - })?; + let host = super::normalize_host(&self.config.client.server); + let address = super::resolve_host_port(&host, self.config.transport.http3.port) + .map_err(TransportError::Io)?; let secret = self .config .client @@ -1313,7 +1390,7 @@ impl Http3ClientConnector { Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)); let connection = tokio::time::timeout( handshake_timeout, - endpoint.connect(address, host).map_err(|error| { + endpoint.connect(address, &host).map_err(|error| { TransportError::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, error.to_string(), @@ -1327,7 +1404,7 @@ impl Http3ClientConnector { .await .map_err(map_transport_error)? .connect_udp( - &format!("{host}:{}", self.config.transport.http3.port), + &super::format_transport_host_port(&host, self.config.transport.http3.port), &self.config.transport.http3.path, &self.config.client.id, &secret, @@ -1454,6 +1531,11 @@ impl Http3Server { let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); let mut transport = quinn::TransportConfig::default(); transport.datagram_send_buffer_size(1024 * 1024); + transport.max_idle_timeout(Some( + Duration::from_secs(config.session.idle_timeout_secs.max(1).saturating_add(1)) + .try_into() + .expect("validated HTTP/3 idle timeout fits quinn duration"), + )); server_config.transport_config(Arc::new(transport)); let endpoint = quinn::Endpoint::server(server_config, address).map_err(TransportError::Io)?; @@ -1474,7 +1556,7 @@ impl Http3Server { endpoint, clients: Arc::new(clients), path: config.transports.http3.path.clone(), - maximum_datagram: DEFAULT_MAX_DATAGRAM, + maximum_datagram: usize::from(config.network.max_datagram_size), handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)), admission, }) @@ -1578,12 +1660,7 @@ impl Http3Server { } fn resolve_http3_listen(host: &str, port: u16) -> Result { - let address = if host.contains(':') && !host.starts_with('[') { - format!("[{host}]:{port}") - } else { - format!("{host}:{port}") - }; - address.parse::().map_err(|error| { + super::resolve_host_port(host, port).map_err(|error| { TransportError::Io(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("invalid HTTP/3 listen address: {error}"), diff --git a/src/transport/mod.rs b/src/transport/mod.rs index e431c35..af1ff8e 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -4,7 +4,7 @@ use std::{ collections::{HashMap, VecDeque}, future::Future, io, - net::{IpAddr, SocketAddr}, + net::{IpAddr, SocketAddr, ToSocketAddrs}, pin::Pin, sync::{Arc, Mutex as StdMutex}, time::{Duration, Instant}, @@ -16,6 +16,7 @@ use tokio::{ io::{split, AsyncRead, AsyncWrite}, net::{TcpListener, TcpStream}, sync::{mpsc, oneshot, Mutex}, + task::JoinHandle, }; pub mod direct_udp; @@ -23,6 +24,31 @@ pub mod http3; pub mod quic; pub mod tls_tcp; +use crate::config::format_host_port; + +/// Normalize a configured host for DNS/IP lookup and TLS name validation. +/// Brackets are URI/socket syntax, not part of an IPv6 address or its +/// certificate identity. +pub(crate) fn normalize_host(host: &str) -> String { + let host = host.trim(); + host.strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host) + .to_owned() +} + +pub(crate) fn format_transport_host_port(host: &str, port: u16) -> String { + format_host_port(&normalize_host(host), port) +} + +pub(crate) fn resolve_host_port(host: &str, port: u16) -> io::Result { + let address = format_transport_host_port(host, port); + address + .to_socket_addrs()? + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "host resolved to no address")) +} + pub use direct_udp::{DirectUdpConnector, DirectUdpServer, DirectUdpSession}; pub use http3::{Http3ClientConnector, Http3Server, Http3TransportSession}; @@ -342,6 +368,8 @@ fn tls_auth_error(error: tls_tcp::AuthError) -> TransportError { struct TlsTcpSession { reader: Mutex>>, writer: mpsc::Sender, + writer_shutdown: tokio_util::sync::CancellationToken, + writer_task: StdMutex>>, max_datagram: usize, closed: tokio_util::sync::CancellationToken, keepalive_interval: Option, @@ -358,7 +386,7 @@ enum TlsWriterCommand { struct TlsSessionOptions { max_datagram: usize, - idle_timeout: Duration, + reader_idle_timeout: Option, keepalive_interval: Option, remote_addr: Option, client_id: Option, @@ -369,7 +397,7 @@ impl TlsTcpSession { fn from_halves(reader: TlsRead, writer: TlsWrite, options: TlsSessionOptions) -> Self { let TlsSessionOptions { max_datagram, - idle_timeout, + reader_idle_timeout, keepalive_interval, remote_addr, client_id, @@ -379,9 +407,18 @@ impl TlsTcpSession { // Dropping a caller while it awaits the reply cannot leave a partial // frame on the TCP stream. let (writer_tx, mut writer_rx) = mpsc::channel(16); - tokio::spawn(async move { + let writer_shutdown = tokio_util::sync::CancellationToken::new(); + let writer_shutdown_task = writer_shutdown.clone(); + let writer_task = tokio::spawn(async move { let mut writer = tls_tcp::FramedWriter::new(writer, max_datagram); - while let Some(command) = writer_rx.recv().await { + loop { + let Some(command) = (tokio::select! { + biased; + _ = writer_shutdown_task.cancelled() => None, + command = writer_rx.recv() => command, + }) else { + break; + }; match command { TlsWriterCommand::Datagram(data, reply) => { let result = writer.send_datagram(&data).await; @@ -414,9 +451,11 @@ impl TlsTcpSession { reader: Mutex::new(Some(tls_tcp::FramedReader::new( reader, max_datagram, - Some(idle_timeout), + reader_idle_timeout, ))), writer: writer_tx, + writer_shutdown, + writer_task: StdMutex::new(Some(writer_task)), max_datagram, closed: tokio_util::sync::CancellationToken::new(), keepalive_interval, @@ -491,6 +530,8 @@ impl TransportSession for TlsTcpSession { fn close<'a>(&'a self) -> TransportFuture<'a, ()> { Box::pin(async move { if self.closed.is_cancelled() { + self.writer_shutdown.cancel(); + self.reap_writer(false).await; return Ok(()); } self.closed.cancel(); @@ -499,7 +540,7 @@ impl TransportSession for TlsTcpSession { .unwrap_or_else(std::sync::PoisonError::into_inner) .take(); let (reply, result) = oneshot::channel(); - tokio::time::timeout(Duration::from_secs(2), async { + let close_result = tokio::time::timeout(Duration::from_secs(2), async { if self .writer .send(TlsWriterCommand::Close(reply)) @@ -517,7 +558,15 @@ impl TransportSession for TlsTcpSession { std::io::ErrorKind::TimedOut, "TLS/TCP close timed out", )) - })? + }); + // Signal the actor after the close frame had a chance to finish, + // then always reap it. If a peer stopped reading, the actor may + // still be blocked in a framed write; `reap_writer` aborts that + // boundedly instead of detaching the task when this session drops. + let close_succeeded = close_result.is_ok(); + self.writer_shutdown.cancel(); + self.reap_writer(!close_succeeded).await; + close_result? }) } fn max_datagram_size(&self) -> usize { @@ -554,6 +603,45 @@ impl TransportSession for TlsTcpSession { } } +impl TlsTcpSession { + async fn reap_writer(&self, force_abort: bool) { + let task = self + .writer_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let Some(mut task) = task else { + return; + }; + if force_abort { + task.abort(); + let _ = task.await; + return; + } + if tokio::time::timeout(Duration::from_secs(2), &mut task) + .await + .is_err() + { + task.abort(); + let _ = task.await; + } + } +} + +impl Drop for TlsTcpSession { + fn drop(&mut self) { + self.writer_shutdown.cancel(); + if let Some(task) = self + .writer_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + { + task.abort(); + } + } +} + pub struct TlsTcpConnector { config: crate::config::ClientConfig, client_config: Option>, @@ -575,8 +663,9 @@ impl TlsTcpConnector { } } async fn connect_session(&self) -> Result, TransportError> { - let host = self.config.client.server.trim(); - let stream = TcpStream::connect((host, self.config.transport.tls_tcp.port)).await?; + let host = normalize_host(&self.config.client.server); + let address = resolve_host_port(&host, self.config.transport.tls_tcp.port)?; + let stream = TcpStream::connect(address).await?; let remote_addr = stream.peer_addr().ok(); let connector_config = match &self.client_config { Some(config) => config.clone(), @@ -601,13 +690,12 @@ impl TlsTcpConnector { } }; let connector = tokio_rustls::TlsConnector::from(connector_config); - let server_name = - rustls::pki_types::ServerName::try_from(host.to_owned()).map_err(|_| { - TransportError::Io(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "invalid TLS server name", - )) - })?; + let server_name = rustls::pki_types::ServerName::try_from(host.clone()).map_err(|_| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid TLS server name", + )) + })?; let tls = connector.connect(server_name, stream).await.map_err(|e| { TransportError::Io(std::io::Error::new( std::io::ErrorKind::ConnectionAborted, @@ -644,7 +732,7 @@ impl TlsTcpConnector { writer, TlsSessionOptions { max_datagram: max, - idle_timeout: Duration::from_secs(120), + reader_idle_timeout: Some(Duration::from_secs(120)), keepalive_interval, remote_addr, client_id: Some(self.config.client.id.clone()), @@ -684,14 +772,8 @@ impl TlsTcpServer { config: &crate::config::ServerConfig, admission: Arc, ) -> Result { - let listen = if config.server.listen.parse::().is_ok() { - config.server.listen.clone() - } else { - format!( - "{}:{}", - config.server.listen, config.transports.tls_tcp.port - ) - }; + let listen = + format_transport_host_port(&config.server.listen, config.transports.tls_tcp.port); let listener = TcpListener::bind(listen).await?; let cert = config.tls.certificate.as_ref().ok_or_else(|| { TransportError::Unsupported("TLS/TCP requires tls.certificate/private_key".into()) @@ -725,7 +807,7 @@ impl TlsTcpServer { listener, tls, clients: Arc::new(clients), - max_datagram: 1200, + max_datagram: usize::from(config.network.max_datagram_size), handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)), admission, }) @@ -794,7 +876,7 @@ impl TlsTcpServer { writer, TlsSessionOptions { max_datagram: self.max_datagram, - idle_timeout: Duration::from_secs(120), + reader_idle_timeout: None, keepalive_interval: None, remote_addr: Some(remote_addr), client_id: Some(authenticated.id), @@ -809,6 +891,18 @@ mod tests { use super::*; use tokio::io::{duplex, AsyncReadExt}; + #[test] + fn host_normalization_handles_dns_and_ipv6_authorities() { + assert_eq!(normalize_host(" [::1] "), "::1"); + assert_eq!(normalize_host("::1"), "::1"); + assert_eq!(normalize_host("example.test"), "example.test"); + assert_eq!(format_transport_host_port("[::1]", 443), "[::1]:443"); + assert_eq!( + format_transport_host_port("example.test", 443), + "example.test:443" + ); + } + #[tokio::test] async fn cancelling_keepalive_wait_does_not_cancel_partial_frame_write() { let (client, peer) = duplex(1); @@ -819,7 +913,7 @@ mod tests { client_writer, TlsSessionOptions { max_datagram: 1200, - idle_timeout: Duration::from_secs(5), + reader_idle_timeout: Some(Duration::from_secs(5)), keepalive_interval: Some(Duration::from_secs(1)), remote_addr: None, client_id: None, @@ -865,4 +959,50 @@ mod tests { assert_eq!(next_frame, [0x01, b'o', b'k']); data_send.await.unwrap().unwrap(); } + + #[tokio::test] + async fn close_reaps_writer_blocked_on_peer_backpressure() { + let (client, peer) = duplex(1); + let (client_reader, client_writer) = split(Box::new(client) as BoxIo); + let (mut peer_reader, _peer_writer) = split(Box::new(peer) as BoxIo); + let session = Arc::new(TlsTcpSession::from_halves( + client_reader, + client_writer, + TlsSessionOptions { + max_datagram: 1200, + reader_idle_timeout: Some(Duration::from_secs(5)), + keepalive_interval: None, + remote_addr: None, + client_id: None, + admission: None, + }, + )); + + let send_session = session.clone(); + let send = tokio::spawn(async move { + send_session + .send_datagram(Bytes::from_static(b"blocked")) + .await + }); + let mut first_header_byte = [0; 1]; + tokio::time::timeout( + Duration::from_secs(1), + peer_reader.read_exact(&mut first_header_byte), + ) + .await + .unwrap() + .unwrap(); + + // The peer has consumed only one byte, so the dedicated writer is + // blocked in the middle of a frame. Closing must still reap it. + let _ = tokio::time::timeout(Duration::from_secs(5), session.close()) + .await + .expect("close must remain bounded"); + let _ = send.await; + assert!(session + .writer_task + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none()); + } } diff --git a/src/transport/quic.rs b/src/transport/quic.rs index 672ab94..f6d1755 100644 --- a/src/transport/quic.rs +++ b/src/transport/quic.rs @@ -5,14 +5,7 @@ //! client. Once that exchange has completed, application bytes are carried //! exclusively in QUIC DATAGRAM frames. -use std::{ - collections::HashMap, - fs, io, - net::{SocketAddr, ToSocketAddrs}, - path::Path, - sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, fs, io, net::SocketAddr, path::Path, sync::Arc, time::Duration}; use bytes::Bytes; use quinn::{ @@ -44,7 +37,6 @@ const MSG_RESULT: u8 = 3; const CHALLENGE_LEN: usize = 4 + 1 + 1 + 32; const RESULT_LEN: usize = 4 + 1 + 1 + 1 + 2; const MAX_CLIENT_ID: usize = 255; -const DEFAULT_MAX_DATAGRAM: usize = 1200; fn io_other(message: impl Into) -> io::Error { io::Error::other(message.into()) @@ -58,11 +50,22 @@ fn quinn_io(error: E) -> TransportError { TransportError::Io(io_other(error.to_string())) } -fn transport_config(keepalive_secs: u64) -> Arc { +fn transport_config( + keepalive_secs: u64, + idle_timeout_secs: Option, +) -> Arc { let mut cfg = quinn::TransportConfig::default(); if keepalive_secs > 0 { cfg.keep_alive_interval(Some(Duration::from_secs(keepalive_secs))); } + if let Some(idle_timeout_secs) = idle_timeout_secs { + let timeout = Duration::from_secs(idle_timeout_secs.max(1).saturating_add(1)); + cfg.max_idle_timeout(Some( + timeout + .try_into() + .expect("validated QUIC idle timeout fits quinn duration"), + )); + } // A bounded send queue prevents an application producer from retaining // unbounded memory while the path is congested. cfg.datagram_send_buffer_size(1024 * 1024); @@ -70,11 +73,15 @@ fn transport_config(keepalive_secs: u64) -> Arc { } fn apply_transport_config(config: &mut QuinnClientConfig, keepalive_secs: u64) { - config.transport_config(transport_config(keepalive_secs)); + config.transport_config(transport_config(keepalive_secs, None)); } -fn apply_server_transport_config(config: &mut QuinnServerConfig, keepalive_secs: u64) { - config.transport_config(transport_config(keepalive_secs)); +fn apply_server_transport_config( + config: &mut QuinnServerConfig, + keepalive_secs: u64, + idle_timeout_secs: u64, +) { + config.transport_config(transport_config(keepalive_secs, Some(idle_timeout_secs))); } /// Read an X.509 chain and private key from normal PEM or DER files. @@ -119,6 +126,7 @@ pub fn load_tls_identity( fn server_config( config: &ServerConfig, keepalive_secs: u64, + idle_timeout_secs: u64, ) -> Result { let certificate = config.tls.certificate.as_ref().ok_or_else(|| { TransportError::Io(io_invalid( @@ -133,7 +141,7 @@ fn server_config( let (certs, key) = load_tls_identity(certificate, private_key)?; let mut result = QuinnServerConfig::with_single_cert(certs, key) .map_err(|e| io_invalid(format!("invalid TLS identity: {e}")))?; - apply_server_transport_config(&mut result, keepalive_secs); + apply_server_transport_config(&mut result, keepalive_secs, idle_timeout_secs); Ok(result) } @@ -146,20 +154,15 @@ fn client_config() -> Result { } fn resolve_server(host: &str, port: u16) -> Result { - let mut addrs = (host, port).to_socket_addrs().map_err(TransportError::Io)?; - addrs - .next() - .ok_or_else(|| TransportError::Io(io_invalid("server hostname resolved to no address"))) + super::resolve_host_port(host, port).map_err(|error| { + TransportError::Io(io_invalid(format!( + "server hostname resolved to no address: {error}" + ))) + }) } fn resolve_listen(host: &str, port: u16) -> Result { - let address = if host.contains(':') && !host.starts_with('[') { - format!("[{host}]:{port}") - } else { - format!("{host}:{port}") - }; - address - .parse::() + super::resolve_host_port(host, port) .map_err(|e| TransportError::Io(io_invalid(format!("invalid QUIC listen address: {e}")))) } @@ -393,8 +396,8 @@ impl QuicClientConnector { } pub async fn connect_session(&self) -> Result { - let host = self.config.client.server.trim(); - let addr = resolve_server(host, self.config.transport.quic.port)?; + let host = super::normalize_host(&self.config.client.server); + let addr = resolve_server(&host, self.config.transport.quic.port)?; let mut endpoint = Endpoint::client("[::]:0".parse().expect("valid wildcard address")) .map_err(TransportError::Io)?; let mut cfg = match self.client_config.clone() { @@ -403,7 +406,7 @@ impl QuicClientConnector { }; apply_transport_config(&mut cfg, self.config.transport.quic.keepalive_secs); endpoint.set_default_client_config(cfg); - let connecting = endpoint.connect(addr, host).map_err(quinn_io)?; + let connecting = endpoint.connect(addr, &host).map_err(quinn_io)?; let connection = timeout( Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)), connecting, @@ -487,7 +490,7 @@ impl QuicServer { let addr = resolve_listen(&config.server.listen, config.transports.quic.port)?; // ServerQuicConfig currently has no keepalive setting; keep the // listener's idle-path probe at the documented 15-second default. - let tls = server_config(config, 15); + let tls = server_config(config, 15, config.session.idle_timeout_secs); let endpoint = Endpoint::server(tls?, addr).map_err(TransportError::Io)?; let mut clients = HashMap::new(); for client in &config.auth.clients { @@ -500,7 +503,7 @@ impl QuicServer { Ok(Self { endpoint, clients: Arc::new(clients), - max_datagram: DEFAULT_MAX_DATAGRAM, + max_datagram: usize::from(config.network.max_datagram_size), handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)), admission, }) diff --git a/src/transport/tls_tcp.rs b/src/transport/tls_tcp.rs index b4f6472..8bf403e 100644 --- a/src/transport/tls_tcp.rs +++ b/src/transport/tls_tcp.rs @@ -35,9 +35,14 @@ const AUTH_CHALLENGE_KIND: u8 = 0x10; const AUTH_RESPONSE_KIND: u8 = 0x11; const AUTH_ACCEPT_KIND: u8 = 0x12; const AUTH_REJECT_KIND: u8 = 0x13; +const AUTH_FRAME_MINIMUM: usize = 2 + MAX_CLIENT_ID + CLIENT_NONCE_LEN + MAC_LEN; type HmacSha256 = Hmac; +fn auth_frame_maximum(maximum: usize) -> usize { + maximum.clamp(AUTH_FRAME_MINIMUM, MAX_WIRE_FRAME) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum FrameError { MaxTooLarge, @@ -340,6 +345,7 @@ where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { + let maximum = auth_frame_maximum(maximum); let challenge = AuthChallenge::random(); let mut message = Vec::with_capacity(1 + CHALLENGE_LEN); message.push(AUTH_CHALLENGE_KIND); @@ -377,6 +383,7 @@ where R: AsyncRead + Unpin, W: AsyncWrite + Unpin, { + let maximum = auth_frame_maximum(maximum); let challenge = tokio::time::timeout(timeout, read_frame(reader, maximum)) .await .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; diff --git a/tests/control.rs b/tests/control.rs index 9234388..09a19cd 100644 --- a/tests/control.rs +++ b/tests/control.rs @@ -2,6 +2,9 @@ use wgcloak::control; use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; +use tokio::io::AsyncReadExt; +use tokio::net::UnixStream; +use tokio::time::{sleep, timeout, Duration}; #[tokio::test] async fn control_commands_and_socket_permissions() { @@ -98,3 +101,91 @@ fn default_socket_path_never_targets_the_shared_tmp_socket() { assert_eq!(path.file_name().unwrap(), "wgcloak.sock"); assert_ne!(path.parent().unwrap(), std::path::Path::new("/tmp")); } + +#[tokio::test] +async fn control_serializes_unusual_status_and_stats_keys_as_json() { + let dir = tempdir().unwrap(); + let path = dir.path().join("runtime/control.sock"); + let state = control::ControlState::default(); + state.set_status("ready\"\n\t\0").await; + let mut clients = std::collections::BTreeMap::new(); + clients.insert("client\"\n\t\0".to_owned(), 1); + state + .set_transport_stats_by_client_id("transport\"\n\t\0", 1, Default::default(), clients) + .await; + let server = control::ControlServer::bind(&path, state.clone()) + .await + .unwrap(); + let task = tokio::spawn(server.run()); + + let status: serde_json::Value = + serde_json::from_str(&control::send_command(&path, "status").await.unwrap()).unwrap(); + assert_eq!(status["status"], "ready\"\n\t\0"); + let stats: serde_json::Value = + serde_json::from_str(&control::send_command(&path, "stats").await.unwrap()).unwrap(); + assert_eq!( + stats["stats"]["active_sessions_by_client_id"]["client\"\n\t\0"], + 1 + ); + assert_eq!( + stats["stats"]["active_sessions_by_transport"]["transport\"\n\t\0"], + 1 + ); + + state.shutdown.cancel(); + task.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn idle_control_clients_are_closed_and_do_not_pin_shutdown() { + let dir = tempdir().unwrap(); + let path = dir.path().join("runtime/control.sock"); + let state = control::ControlState::default(); + let server = control::ControlServer::bind(&path, state.clone()) + .await + .unwrap(); + let task = tokio::spawn(server.run()); + let mut idle = UnixStream::connect(&path).await.unwrap(); + let mut response = Vec::new(); + timeout( + control::CONTROL_REQUEST_TIMEOUT + Duration::from_secs(1), + idle.read_to_end(&mut response), + ) + .await + .expect("idle control client must be closed") + .unwrap(); + assert!(response.is_empty()); + + state.shutdown.cancel(); + timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn control_rejects_clients_beyond_the_concurrency_bound() { + let dir = tempdir().unwrap(); + let path = dir.path().join("runtime/control.sock"); + let state = control::ControlState::default(); + let server = control::ControlServer::bind(&path, state.clone()) + .await + .unwrap(); + let task = tokio::spawn(server.run()); + let mut held = Vec::new(); + for _ in 0..control::CONTROL_MAX_CLIENTS { + held.push(UnixStream::connect(&path).await.unwrap()); + } + sleep(Duration::from_millis(25)).await; + let mut extra = UnixStream::connect(&path).await.unwrap(); + let mut response = Vec::new(); + timeout(Duration::from_secs(1), extra.read_to_end(&mut response)) + .await + .expect("connection over the bound must be closed") + .unwrap(); + assert!(response.is_empty()); + drop(held); + state.shutdown.cancel(); + task.await.unwrap().unwrap(); +} diff --git a/tests/direct_udp.rs b/tests/direct_udp.rs index d673d8b..bd541b6 100644 --- a/tests/direct_udp.rs +++ b/tests/direct_udp.rs @@ -248,6 +248,84 @@ async fn malformed_and_unauthenticated_packets_do_not_kill_server() { server_task.abort(); } +#[tokio::test] +async fn direct_auth_attempt_limit_expires_and_recovers() { + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (mut server_config, _) = configs(45790, backend_addr.port()); + server_config.limits.max_auth_attempts_per_ip = 2; + server_config.limits.auth_attempt_window_secs = 1; + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let stats = server.stats(); + let server_task = tokio::spawn(server.run()); + let raw = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let wrong_secret = [0xabu8; 32]; + let mut response = [0u8; 2048]; + + for nonce_value in [1u8, 2] { + let mut nonce = [0u8; 16]; + nonce[0] = nonce_value; + let hello = protocol::HelloPacket::authenticated( + &wrong_secret, + Bytes::from_static(b"test"), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + nonce, + ) + .unwrap(); + raw.send_to(&hello.encode().unwrap(), "127.0.0.1:45790") + .await + .unwrap(); + let (len, _) = timeout(Duration::from_secs(2), raw.recv_from(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(matches!( + protocol::parse_packet(&response[..len]), + Ok(protocol::Packet::Reject(protocol::RejectPacket { + reason: protocol::RejectReason::AuthenticationFailed, + .. + })) + )); + } + + let mut limited_nonce = [0u8; 16]; + limited_nonce[0] = 3; + let limited = protocol::HelloPacket::authenticated( + &wrong_secret, + Bytes::from_static(b"test"), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + limited_nonce, + ) + .unwrap(); + raw.send_to(&limited.encode().unwrap(), "127.0.0.1:45790") + .await + .unwrap(); + let (len, _) = timeout(Duration::from_secs(2), raw.recv_from(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(matches!( + protocol::parse_packet(&response[..len]), + Ok(protocol::Packet::Reject(protocol::RejectPacket { + reason: protocol::RejectReason::RateLimited, + .. + })) + )); + + tokio::time::sleep(Duration::from_millis(1_100)).await; + let client_config = configs(45790, backend_addr.port()).1; + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + assert!(stats.snapshot().rate_limited_attempts >= 1); + session.close().await.unwrap(); + server_task.abort(); +} + #[tokio::test] async fn authenticated_source_port_change_updates_return_peer() { let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); @@ -320,7 +398,9 @@ async fn authenticated_source_port_change_updates_return_peer() { async fn identical_hello_retransmission_recovers_original_accept_and_session() { let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); let backend_addr = backend.local_addr().unwrap(); - let (server_config, _) = configs(45788, backend_addr.port()); + let (mut server_config, _) = configs(45788, backend_addr.port()); + // The cached exact retry must not consume a second auth-attempt slot. + server_config.limits.max_auth_attempts_per_ip = 1; let server = DirectUdpServer::bind(server_config).await.unwrap(); let observer = server.handle(); let server_task = tokio::spawn(server.run()); @@ -481,6 +561,43 @@ async fn identical_hello_retransmission_recovers_original_accept_and_session() { server_task.abort(); } +#[tokio::test] +async fn direct_udp_supports_ipv6_loopback_listen() { + let probe = match UdpSocket::bind("[::1]:0").await { + Ok(socket) => socket, + Err(_) => return, + }; + let port = probe.local_addr().unwrap().port(); + drop(probe); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (mut server_config, mut client_config) = configs(port, backend_addr.port()); + server_config.server.listen = "::1".to_owned(); + client_config.client.server = "::1".to_owned(); + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let server_task = tokio::spawn(server.run()); + let backend_task = tokio::spawn(async move { + let mut buffer = [0u8; 2048]; + let (len, peer) = backend.recv_from(&mut buffer).await.unwrap(); + backend.send_to(&buffer[..len], peer).await.unwrap(); + }); + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + session + .send_datagram(Bytes::from_static(b"ipv6")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), session.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"ipv6") + ); + session.close().await.unwrap(); + backend_task.await.unwrap(); + server_task.abort(); +} + #[tokio::test] async fn flags_are_authenticated_and_invalid_close_cannot_remove_session() { let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); diff --git a/tests/encrypted_runtime.rs b/tests/encrypted_runtime.rs new file mode 100644 index 0000000..0c2b9f4 --- /dev/null +++ b/tests/encrypted_runtime.rs @@ -0,0 +1,568 @@ +//! Daemon-level encrypted transport proofs. +//! +//! These tests deliberately use the public client/server runtime entrypoints: +//! an application UDP socket talks to a client runtime, which talks through an +//! authenticated encrypted listener to a per-session UDP backend socket. + +use std::{ + fs, + net::SocketAddr, + os::unix::fs::PermissionsExt, + path::Path, + process::Command, + sync::{ + atomic::{AtomicU16, Ordering}, + Arc, + }, + time::Duration, +}; + +use tempfile::TempDir; +use tokio::{ + net::UdpSocket, + sync::mpsc, + time::{sleep, timeout}, +}; +use wgcloak::{ + client, + config::{self, Config, TransportMode}, + control, server, + transport::{ + quic::QuicClientConnector, tls_tcp::client_tls_config, TlsTcpConnector, TransportConnector, + TransportFuture, TransportSession, + }, +}; + +const SECRET_ONE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const SECRET_TWO: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const WRONG_SECRET: &str = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; + +fn free_udp_port() -> u16 { + std::net::UdpSocket::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn free_tcp_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn private_runtime_dir(root: &Path) -> std::path::PathBuf { + let path = root.join("runtime"); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +fn make_identity(dir: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) { + let cert = dir.path().join("cert.der"); + let key = dir.path().join("key.der"); + let pem_cert = dir.path().join("cert.pem"); + let pem_key = dir.path().join("key.pem"); + let result = Command::new("openssl") + .args(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout"]) + .arg(&pem_key) + .args(["-out"]) + .arg(&pem_cert) + .args([ + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost,IP:127.0.0.1", + "-addext", + "basicConstraints=critical,CA:FALSE", + "-addext", + "extendedKeyUsage=serverAuth", + "-days", + "1", + ]) + .output() + .unwrap(); + assert!(result.status.success(), "openssl failed: {:?}", result); + let result = Command::new("openssl") + .args(["x509", "-in"]) + .arg(&pem_cert) + .args(["-outform", "DER", "-out"]) + .arg(&cert) + .output() + .unwrap(); + assert!(result.status.success()); + let result = Command::new("openssl") + .args(["pkcs8", "-topk8", "-nocrypt", "-in"]) + .arg(&pem_key) + .args(["-outform", "DER", "-out"]) + .arg(&key) + .output() + .unwrap(); + assert!(result.status.success()); + (cert, key) +} + +fn server_config( + cert: &Path, + key: &Path, + backend: SocketAddr, + quic_port: u16, + tls_port: u16, + quic: bool, + tls_tcp: bool, +) -> config::ServerConfig { + Config::from_str(&format!( + "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='{backend}'\n[transports.direct_udp]\nenabled=false\n[transports.quic]\nenabled={quic}\nport={quic_port}\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled={tls_tcp}\nport={tls_port}\n[tls]\ncertificate='{}'\nprivate_key='{}'\n[[auth.clients]]\nid='one'\nsecret='{SECRET_ONE}'\n[[auth.clients]]\nid='two'\nsecret='{SECRET_TWO}'\n[limits]\nhandshake_timeout_ms=2000\n", + cert.display(), + key.display(), + )) + .unwrap() + .into_server() + .unwrap() +} + +fn client_config( + id: &str, + secret: &str, + mode: &str, + port: u16, + local_port: u16, +) -> config::ClientConfig { + Config::from_str(&format!( + "[client]\nserver='localhost'\nid='{id}'\nsecret='{secret}'\n[local]\nlisten='127.0.0.1:{local_port}'\n[transport]\nmode='{mode}'\n[transport.auto]\nprobe_timeout_ms=500\nretries=1\n[transport.quic]\nport={port}\n[transport.tls_tcp]\nport={port}\n[network]\nmax_datagram_size=1200\n" + )) + .unwrap() + .into_client() + .unwrap() +} + +fn quic_connector(config: config::ClientConfig, cert: &Path, key: &Path) -> QuicClientConnector { + let certs = wgcloak::transport::quic::load_tls_identity(cert, key) + .map(|(certs, _)| certs) + .unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(certs[0].clone()).unwrap(); + let tls = quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap(); + QuicClientConnector::with_client_config(config, tls) +} + +fn tls_connector(config: config::ClientConfig, cert: &Path) -> TlsTcpConnector { + let certs = wgcloak::transport::tls_tcp::load_certificates(cert).unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(certs[0].clone()).unwrap(); + TlsTcpConnector::with_client_config(config, (*client_tls_config(roots)).clone()) +} + +struct RotatingQuicConnector { + config: config::ClientConfig, + cert: std::path::PathBuf, + key: std::path::PathBuf, + port: Arc, +} + +impl TransportConnector for RotatingQuicConnector { + fn connect<'a>(&'a self) -> TransportFuture<'a, Box> { + Box::pin(async move { + let mut config = self.config.clone(); + config.transport.quic.port = self.port.load(Ordering::Relaxed); + quic_connector(config, &self.cert, &self.key) + .connect() + .await + }) + } +} + +async fn wait_for(path: &Path, command: &str, predicate: impl Fn(&str) -> bool) -> String { + let mut last = None; + timeout(Duration::from_secs(10), async { + loop { + if let Ok(response) = control::send_command(path, command).await { + if predicate(&response) { + return response; + } + last = Some(response); + } + sleep(Duration::from_millis(25)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "control {} {command} did not reach expected state: {last:?}", + path.display() + ) + }) +} + +async fn echo_backend(socket: UdpSocket) { + echo_backend_observed(socket, None).await; +} + +async fn echo_backend_observed( + socket: UdpSocket, + observed: Option>, +) { + let mut buf = [0u8; 2048]; + while let Ok((len, peer)) = socket.recv_from(&mut buf).await { + if let Some(observed) = &observed { + let _ = observed.send(peer); + } + let _ = socket.send_to(&buf[..len], peer).await; + } +} + +async fn stop(path: &Path, task: tokio::task::JoinHandle>) { + let response = control::send_command(path, "shutdown").await.unwrap(); + assert!(response.contains("\"ok\":true")); + timeout(Duration::from_secs(5), task) + .await + .expect("runtime did not stop") + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn explicit_quic_runtime_forwards_two_clients_and_reports_identity() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = make_identity(&dir); + let runtime = private_runtime_dir(dir.path()); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (peer_tx, mut peer_rx) = mpsc::unbounded_channel(); + let backend_task = tokio::spawn(echo_backend_observed(backend, Some(peer_tx))); + let quic_port = free_udp_port(); + let server_path = runtime.join("server.sock"); + let server_task = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + quic_port, + free_udp_port(), + true, + false, + ), + server_path.clone(), + )); + wait_for(&server_path, "status", |s| s.contains("transports=quic")).await; + + let mut clients = Vec::new(); + for (id, secret) in [("one", SECRET_ONE), ("two", SECRET_TWO)] { + let local = free_udp_port(); + let app = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let cfg = client_config(id, secret, "quic", quic_port, local); + let control_path = runtime.join(format!("client-{id}.sock")); + let connector = + Arc::new(quic_connector(cfg.clone(), &cert, &key)) as Arc; + let task = tokio::spawn(client::run_with_transport_connector_and_control_path( + cfg, + control_path.clone(), + TransportMode::Quic, + connector, + )); + wait_for(&control_path, "stats", |s| { + s.contains("\"state\":\"connected\"") + }) + .await; + clients.push((local, app, control_path, task)); + } + wait_for(&server_path, "stats", |s| { + s.contains("\"active_sessions_by_transport\":{\"quic\":2") + && s.contains("\"one\":1") + && s.contains("\"two\":1") + }) + .await; + + for (local, app, _, _) in &clients { + app.send_to(b"encrypted-echo", ("127.0.0.1", *local)) + .await + .unwrap(); + let mut reply = [0u8; 64]; + let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply[..len], b"encrypted-echo"); + } + let peer_one = timeout(Duration::from_secs(2), peer_rx.recv()) + .await + .unwrap() + .unwrap(); + let peer_two = timeout(Duration::from_secs(2), peer_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_ne!( + peer_one, peer_two, + "each encrypted session needs its own backend socket" + ); + for (_, _, control_path, task) in clients { + stop(&control_path, task).await; + } + stop(&server_path, server_task).await; + backend_task.abort(); +} + +#[tokio::test] +async fn explicit_tls_runtime_forwards_and_rejects_hostname_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = make_identity(&dir); + let runtime = private_runtime_dir(dir.path()); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let backend_task = tokio::spawn(echo_backend(backend)); + let tls_port = free_tcp_port(); + let server_path = runtime.join("server.sock"); + let server_task = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + free_udp_port(), + tls_port, + false, + true, + ), + server_path.clone(), + )); + wait_for(&server_path, "status", |s| s.contains("transports=tls_tcp")).await; + let local = free_udp_port(); + let app = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let cfg = client_config("one", SECRET_ONE, "tls_tcp", tls_port, local); + let client_path = runtime.join("client.sock"); + let connector = Arc::new(tls_connector(cfg.clone(), &cert)) as Arc; + let client_task = tokio::spawn(client::run_with_transport_connector_and_control_path( + cfg.clone(), + client_path.clone(), + TransportMode::TlsTcp, + connector, + )); + wait_for(&client_path, "stats", |s| { + s.contains("\"state\":\"connected\"") + }) + .await; + app.send_to(b"tls-echo", ("127.0.0.1", local)) + .await + .unwrap(); + let mut reply = [0u8; 64]; + let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply[..len], b"tls-echo"); + wait_for(&server_path, "stats", |s| { + s.contains("\"active_sessions_by_transport\":{\"tls_tcp\":1") + && s.contains("\"active_sessions_by_client_id\":{\"one\":1") + }) + .await; + // A trusted certificate with an unrelated hostname must still fail normal + // hostname verification; trusting the issuing certificate is not enough. + let mut mismatch = cfg.clone(); + mismatch.client.server = "not-localhost".to_owned(); + let result = tls_connector(mismatch, &cert).connect().await; + assert!(matches!( + result, + Err(wgcloak::transport::TransportError::Io(_)) + )); + stop(&client_path, client_task).await; + stop(&server_path, server_task).await; + backend_task.abort(); +} + +#[tokio::test] +async fn encrypted_runtime_cancellation_releases_server_sessions() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = make_identity(&dir); + let runtime = private_runtime_dir(dir.path()); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let backend_task = tokio::spawn(echo_backend(backend)); + let quic_port = free_udp_port(); + let server_path = runtime.join("server.sock"); + let server_task = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + quic_port, + free_udp_port(), + true, + false, + ), + server_path.clone(), + )); + wait_for(&server_path, "status", |s| s.contains("transports=quic")).await; + let local = free_udp_port(); + let cfg = client_config("one", SECRET_ONE, "quic", quic_port, local); + let client_path = runtime.join("client.sock"); + let connector = + Arc::new(quic_connector(cfg.clone(), &cert, &key)) as Arc; + let client_task = tokio::spawn(client::run_with_transport_connector_and_control_path( + cfg, + client_path.clone(), + TransportMode::Quic, + connector, + )); + wait_for(&client_path, "stats", |s| { + s.contains("\"state\":\"connected\"") + }) + .await; + wait_for(&server_path, "stats", |s| { + s.contains("\"active_sessions\":1") + }) + .await; + stop(&client_path, client_task).await; + wait_for(&server_path, "stats", |s| { + s.contains("\"active_sessions\":0") + }) + .await; + stop(&server_path, server_task).await; + backend_task.abort(); +} + +#[tokio::test] +async fn wrong_secret_is_authentication_rejection_without_fallback() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = make_identity(&dir); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let backend_task = tokio::spawn(echo_backend(backend)); + let quic_port = free_udp_port(); + let server_path = private_runtime_dir(dir.path()).join("server.sock"); + let server_task = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + quic_port, + free_udp_port(), + true, + false, + ), + server_path.clone(), + )); + wait_for(&server_path, "status", |s| s.contains("transports=quic")).await; + let cfg = client_config("one", WRONG_SECRET, "quic", quic_port, free_udp_port()); + let result = timeout( + Duration::from_secs(5), + QuicClientConnector::with_client_config(cfg.clone(), { + let certs = wgcloak::transport::quic::load_tls_identity(&cert, &key) + .unwrap() + .0; + let mut roots = rustls::RootCertStore::empty(); + roots.add(certs[0].clone()).unwrap(); + quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap() + }) + .connect(), + ) + .await + .unwrap(); + assert!(matches!( + result, + Err(wgcloak::transport::TransportError::AuthenticationRejected( + _ + )) + )); + stop(&server_path, server_task).await; + backend_task.abort(); +} + +#[tokio::test] +async fn quic_runtime_reconnects_after_server_restart_and_cleans_on_cancel() { + let dir = tempfile::tempdir().unwrap(); + let (cert, key) = make_identity(&dir); + let runtime = private_runtime_dir(dir.path()); + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let backend_task = tokio::spawn(echo_backend(backend)); + let quic_port = free_udp_port(); + let server_path = runtime.join("server.sock"); + let first_server = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + quic_port, + free_udp_port(), + true, + false, + ), + server_path.clone(), + )); + wait_for(&server_path, "status", |s| s.contains("transports=quic")).await; + let local = free_udp_port(); + let app = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let cfg = client_config("one", SECRET_ONE, "quic", quic_port, local); + let client_path = runtime.join("client.sock"); + let rotating_port = Arc::new(AtomicU16::new(quic_port)); + let connector = Arc::new(RotatingQuicConnector { + config: cfg.clone(), + cert: cert.clone(), + key: key.clone(), + port: rotating_port.clone(), + }) as Arc; + let client_task = tokio::spawn(client::run_with_transport_connector_and_control_path( + cfg, + client_path.clone(), + TransportMode::Quic, + connector, + )); + wait_for(&client_path, "stats", |s| { + s.contains("\"state\":\"connected\"") + }) + .await; + app.send_to(b"before-restart", ("127.0.0.1", local)) + .await + .unwrap(); + let mut reply = [0u8; 64]; + let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply[..len], b"before-restart"); + + control::send_command(&server_path, "shutdown") + .await + .unwrap(); + timeout(Duration::from_secs(5), first_server) + .await + .unwrap() + .unwrap() + .unwrap(); + let replacement_port = free_udp_port(); + rotating_port.store(replacement_port, Ordering::Relaxed); + let replacement_path = runtime.join("server-replacement.sock"); + let replacement = tokio::spawn(server::run_with_control_path( + server_config( + &cert, + &key, + backend_addr, + replacement_port, + free_udp_port(), + true, + false, + ), + replacement_path.clone(), + )); + wait_for(&replacement_path, "status", |s| { + s.contains("transports=quic") + }) + .await; + wait_for(&client_path, "stats", |s| { + s.contains("\"state\":\"connected\"") && s.contains("\"reconnections\":1") + }) + .await; + app.send_to(b"after-restart", ("127.0.0.1", local)) + .await + .unwrap(); + let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply[..len], b"after-restart"); + stop(&client_path, client_task).await; + stop(&replacement_path, replacement).await; + backend_task.abort(); +} diff --git a/tests/http3_integration.rs b/tests/http3_integration.rs index c7beca1..865d9cf 100644 --- a/tests/http3_integration.rs +++ b/tests/http3_integration.rs @@ -180,11 +180,13 @@ async fn connect_udp_rejects_bad_secret_before_returning_session() { let server_task = tokio::spawn(async move { let incoming = server_endpoint.accept().await.unwrap(); let quic_connection = incoming.await.unwrap(); - Http3ServerConnection::new(quic_connection) + let observer = quic_connection.clone(); + let result = Http3ServerConnection::new(quic_connection) .await .unwrap() .accept_connect_udp("/tunnel", &clients, 1200, Duration::from_secs(3)) - .await + .await; + (result, observer) }); let quic_connection = connect_client(address, client_tls).await; @@ -206,7 +208,7 @@ async fn connect_udp_rejects_bad_secret_before_returning_session() { "unexpected client error: {:?}", client_result.as_ref().err() ); - let server_result = timeout(Duration::from_secs(3), server_task) + let (server_result, server_connection) = timeout(Duration::from_secs(3), server_task) .await .unwrap() .unwrap(); @@ -215,6 +217,16 @@ async fn connect_udp_rejects_bad_secret_before_returning_session() { "unexpected server error: {:?}", server_result.as_ref().err() ); + timeout(Duration::from_secs(2), async { + loop { + if server_connection.close_reason().is_some() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("authentication rejection must close the QUIC connection"); } #[tokio::test] @@ -268,5 +280,11 @@ async fn http3_server_admission_permit_tracks_authenticated_session_lifetime() { assert_eq!(admission.pending_handshakes(), 0); server_session.close().await.unwrap(); assert_eq!(admission.active_sessions(), 0); - client_session.close(Duration::from_secs(2)).await.unwrap(); + timeout( + Duration::from_secs(2), + client_session.close(Duration::from_millis(250)), + ) + .await + .expect("client cleanup after peer close must be bounded") + .unwrap_or_else(|error| panic!("client cleanup returned an unexpected error: {error}")); } diff --git a/tests/integration_transports.rs b/tests/integration_transports.rs index 9e3a256..3c3a048 100644 --- a/tests/integration_transports.rs +++ b/tests/integration_transports.rs @@ -177,3 +177,53 @@ async fn tls_tcp_authenticates_round_trips_and_classifies_failures() { .unwrap() .is_err()); } + +#[tokio::test] +async fn tls_tcp_ipv6_loopback_listener_accepts_bracketed_configuration() { + if Command::new("openssl").arg("version").output().is_err() { + return; + } + let probe = match tokio::net::TcpListener::bind("[::1]:0").await { + Ok(listener) => listener, + Err(_) => return, + }; + let port = probe.local_addr().unwrap().port(); + drop(probe); + let dir = tempdir().unwrap(); + let cert = dir.path().join("cert.pem"); + let key = dir.path().join("key.pem"); + let generated = Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=IP:::1", + "-days", + "1", + "-keyout", + ]) + .arg(&key) + .args(["-out"]) + .arg(&cert) + .output() + .unwrap(); + assert!(generated.status.success(), "openssl failed: {generated:?}"); + let config = Config::from_str(&format!( + "[server]\nlisten = \"::1\"\n[backend]\ntarget = \"127.0.0.1:51820\"\n[transports.direct_udp]\nenabled = false\n[transports.quic]\nenabled = false\n[transports.http3]\nenabled = false\n[transports.tls_tcp]\nenabled = true\nport = {port}\n[tls]\ncertificate = \"{}\"\nprivate_key = \"{}\"\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n", + cert.display(), + key.display() + )) + .unwrap() + .into_server() + .unwrap(); + let server = + TlsTcpServer::bind_with_admission(&config, AdmissionController::new(&config.limits)) + .await + .unwrap(); + assert!(server.local_addr().unwrap().ip().is_ipv6()); +} diff --git a/tests/server_capabilities.rs b/tests/server_capabilities.rs new file mode 100644 index 0000000..012171a --- /dev/null +++ b/tests/server_capabilities.rs @@ -0,0 +1,50 @@ +use wgcloak::config::{Config, ServerConfig, MAX_APPLICATION_DATAGRAM_SIZE}; + +const SECRET: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + +fn server_text(max_datagram_size: Option) -> String { + let network = max_datagram_size + .map(|value| format!("[network]\nmax_datagram_size = {value}\n")) + .unwrap_or_default(); + format!( + "[server]\nlisten = \"::1\"\n[backend]\ntarget = \"127.0.0.1:51820\"\n{network}[transports.direct_udp]\nport = 51830\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n" + ) +} + +#[test] +fn server_datagram_capability_defaults_and_accepts_boundary() { + let Config::Server(defaults) = Config::from_str(&server_text(None)).unwrap() else { + panic!("expected server configuration"); + }; + assert_eq!(defaults.network.max_datagram_size, 1200); + + let Config::Server(config) = + Config::from_str(&server_text(Some(MAX_APPLICATION_DATAGRAM_SIZE))).unwrap() + else { + panic!("expected server configuration"); + }; + config.validate().unwrap(); + assert_eq!( + config.network.max_datagram_size, + MAX_APPLICATION_DATAGRAM_SIZE + ); +} + +#[test] +fn server_datagram_capability_rejects_unsupported_size() { + let Config::Server(config) = + Config::from_str(&server_text(Some(MAX_APPLICATION_DATAGRAM_SIZE + 1))).unwrap() + else { + panic!("expected server configuration"); + }; + let error = config.validate().unwrap_err().to_string(); + assert!(error.contains("network.max_datagram_size")); +} + +#[test] +fn server_config_type_remains_constructible_from_toml() { + let Config::Server(config): Config = Config::from_str(&server_text(Some(1400))).unwrap() else { + panic!("expected server configuration"); + }; + let _: ServerConfig = config; +} diff --git a/tests/supervisor.rs b/tests/supervisor.rs index ae30720..d8c13bb 100644 --- a/tests/supervisor.rs +++ b/tests/supervisor.rs @@ -29,6 +29,37 @@ impl TransportSession for Session { 1200 } } + +struct EchoSession { + payload: Mutex>, +} + +impl TransportSession for EchoSession { + fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> { + Box::pin(async move { + *self.payload.lock().await = Some(data); + Ok(()) + }) + } + + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> { + Box::pin(async move { + self.payload + .lock() + .await + .take() + .ok_or(TransportError::Closed) + }) + } + + fn close<'a>(&'a self) -> TransportFuture<'a, ()> { + Box::pin(async { Ok(()) }) + } + + fn max_datagram_size(&self) -> usize { + 1200 + } +} struct Probe { calls: Arc>>, auth: bool, @@ -48,9 +79,113 @@ impl TransportProbe for Probe { } }) } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> supervisor::SessionWatchFuture<'a> { + Box::pin(async { + Err(TransportError::Unsupported( + "test probe has no session lifecycle".into(), + )) + }) + } } fn config(mode: &str) -> config::ClientConfig { - Config::from_str(&format!("[client]\nserver=\"127.0.0.1\"\nid=\"c\"\nsecret=\"{}\"\n[transport]\nmode=\"{}\"\n[transport.auto]\norder=[\"direct_udp\",\"quic\"]\nretries=2\nprobe_timeout_ms=20\n", "A".repeat(64), mode)).unwrap().into_client().unwrap() + config_with_order(mode, "\"direct_udp\",\"quic\"") +} + +fn config_with_order(mode: &str, order: &str) -> config::ClientConfig { + Config::from_str(&format!("[client]\nserver=\"127.0.0.1\"\nid=\"c\"\nsecret=\"{}\"\n[transport]\nmode=\"{}\"\n[transport.auto]\norder=[{}]\nretries=2\nprobe_timeout_ms=20\n", "A".repeat(64), mode, order)).unwrap().into_client().unwrap() +} + +struct SelectingProbe { + calls: Arc>>, + selected: TransportMode, +} + +impl TransportProbe for SelectingProbe { + fn connect(&self, mode: TransportMode) -> ProbeFuture { + let calls = self.calls.clone(); + let selected = self.selected; + Box::pin(async move { + calls.lock().await.push(mode); + if mode == selected { + Ok(Box::new(EchoSession { + payload: Mutex::new(None), + }) as Box) + } else { + Err(TransportError::HandshakeTimeout) + } + }) + } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> supervisor::SessionWatchFuture<'a> { + Box::pin(async { Err(TransportError::Unsupported("test probe".into())) }) + } +} + +async fn assert_selected_with_roundtrip( + config: config::ClientConfig, + selected: TransportMode, + expected_calls: Vec, +) { + let calls = Arc::new(Mutex::new(Vec::new())); + let supervisor = TransportSupervisor::new( + config, + SelectingProbe { + calls: calls.clone(), + selected, + }, + ); + let session = supervisor.connect_once().await.unwrap(); + assert_eq!(*calls.lock().await, expected_calls); + let status = supervisor.status().await; + assert_eq!(status.state, supervisor::SupervisorState::Connected); + assert_eq!(status.selected_transport, Some(selected)); + session + .send_datagram(Bytes::from_static(b"auto-payload")) + .await + .unwrap(); + assert_eq!( + session.recv_datagram().await.unwrap(), + Bytes::from_static(b"auto-payload") + ); +} + +#[tokio::test] +async fn auto_selects_quic_after_direct_udp_is_unavailable() { + assert_selected_with_roundtrip( + config_with_order("auto", "\"direct_udp\",\"quic\""), + TransportMode::Quic, + vec![ + TransportMode::DirectUdp, + TransportMode::DirectUdp, + TransportMode::Quic, + ], + ) + .await; +} + +#[tokio::test] +async fn auto_selects_tls_tcp_after_udp_carriers_are_unavailable() { + assert_selected_with_roundtrip( + config_with_order("auto", "\"direct_udp\",\"quic\",\"http3\",\"tls_tcp\""), + TransportMode::TlsTcp, + vec![ + TransportMode::DirectUdp, + TransportMode::DirectUdp, + TransportMode::Quic, + TransportMode::Quic, + TransportMode::Http3, + TransportMode::Http3, + TransportMode::TlsTcp, + ], + ) + .await; } #[tokio::test] @@ -89,6 +224,10 @@ async fn selection_order_retries_and_auth_stop() { Err(TransportError::AuthenticationRejected(_)) )); assert_eq!(*calls.lock().await, vec![TransportMode::DirectUdp]); + assert_eq!( + supervisor.status().await.state, + supervisor::SupervisorState::AuthenticationRejected + ); } #[test] @@ -154,3 +293,28 @@ async fn established_failure_reprobes_without_reading_datagrams() { assert!(result.is_ok()); assert!(calls.load(Ordering::SeqCst) >= 2); } + +struct UnsupportedLifecycleProbe; + +impl TransportProbe for UnsupportedLifecycleProbe { + fn connect(&self, _mode: TransportMode) -> ProbeFuture { + Box::pin(async { Ok(Box::new(Session) as Box) }) + } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> supervisor::SessionWatchFuture<'a> { + Box::pin(async { Err(TransportError::Unsupported("lifecycle unavailable".into())) }) + } +} + +#[tokio::test] +async fn unsupported_session_lifecycle_is_explicitly_terminal() { + let supervisor = TransportSupervisor::new(config("direct_udp"), UnsupportedLifecycleProbe); + let result = timeout(Duration::from_secs(1), supervisor.run()) + .await + .expect("unsupported lifecycle should not hang") + .unwrap_err(); + assert!(matches!(result, TransportError::Unsupported(_))); +} diff --git a/tests/tls_tcp.rs b/tests/tls_tcp.rs index aeb1533..f523eac 100644 --- a/tests/tls_tcp.rs +++ b/tests/tls_tcp.rs @@ -36,6 +36,14 @@ fn codec_rejects_oversized_before_payload() { assert!(decoder.finish().is_ok()); } +#[test] +fn codec_rejects_maximum_above_u16_wire_limit() { + assert_eq!( + FrameCodec::new(65_536).unwrap_err(), + FrameError::MaxTooLarge + ); +} + #[test] fn codec_reports_truncated_header_or_payload() { let mut decoder = FrameCodec::new(32).unwrap(); @@ -95,6 +103,40 @@ async fn tls_stream_authentication_exchange_uses_challenge_response() { assert_eq!(authenticated.id, "laptop"); } +#[tokio::test] +async fn tls_authentication_accepts_validated_maximum_datagram_size() { + let (left, right) = duplex(4096); + let (mut server_reader, mut server_writer) = split(left); + let (mut client_reader, mut client_writer) = split(right); + let mut clients = HashMap::new(); + clients.insert("laptop".to_owned(), vec![7u8; 32]); + let server_clients = clients.clone(); + + let server = tokio::spawn(async move { + server_authenticate( + &mut server_reader, + &mut server_writer, + &server_clients, + 65_503 + 512, + Duration::from_secs(1), + ) + .await + }); + let client = tokio::spawn(async move { + client_authenticate( + &mut client_reader, + &mut client_writer, + "laptop", + &[7u8; 32], + 65_503 + 512, + Duration::from_secs(1), + ) + .await + }); + assert!(client.await.unwrap().is_ok()); + assert_eq!(server.await.unwrap().unwrap().id, "laptop"); +} + #[tokio::test] async fn bad_secret_is_rejected_without_authenticating() { let (left, right) = duplex(4096);