feat: complete wgcloak v1 transports

This commit is contained in:
h3cx committed 2026-09-19 19:47:16 +02:00
1 parent 49ec9d8c33
commit cedab4910e
47 files changed
+2842 -393

No files matched your search

Generated
+272
View File
@@ -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"
+7
View File
@@ -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
+13 -1
View File
@@ -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):
+151
View File
@@ -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<u32> = (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);
+91
View File
@@ -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.
+84 -52
View File
@@ -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.
+49
View File
@@ -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.
+96 -50
View File
@@ -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.
+23
View File
@@ -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
+4 -2
View File
@@ -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
+1
View File
@@ -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]
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/target/
/Cargo.lock
+40
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
[[client
+4
View File
@@ -0,0 +1,4 @@
[client]
server = "127.0.0.1:51820"
id = "fuzz"
secret = "ZmFrZQ=="
@@ -0,0 +1 @@
\x01\x10\x00\xff
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
\x2a\x20
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
\xff\xff
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -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);
}
});
+15
View File
@@ -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);
});
+26
View File
@@ -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);
});
+24
View File
@@ -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();
});
+1
View File
@@ -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
+1
View File
@@ -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
+31 -8
View File
@@ -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<P: TransportProbe> TransportSupervisor<P> {
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<C: crate::transport::TransportConnector + 'static> 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(),
))
})
}
}
+116 -10
View File
@@ -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::<SocketAddr>().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<Vec<u8>, 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<Vec<u8>, ConfigError> {
});
}
}
fs::read(path).map_err(|source| ConfigError::SecretRead {
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<Vec<u8>, 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::<SocketAddr>().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 { .. })
));
}
}
+129 -101
View File
@@ -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<Mutex<String>>,
stats: Arc<Mutex<String>>,
stats: Arc<Mutex<Value>>,
transport_stats: Arc<Mutex<BTreeMap<String, TransportStats>>>,
pub reconnect: Arc<Notify>,
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<String>) {
*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::<String, usize>::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::<Vec<_>>();
by_transport_and_client.push(format!(
"{}:{{{}}}",
json_string(name),
client_entries.join(",")
));
.collect::<Map<_, _>>();
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::<Vec<_>>()
.join(",");
.map(|(client_id, count)| (client_id.clone(), json!(*count)))
.collect::<Map<_, _>>();
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<Semaphore>,
}
impl ControlServer {
pub async fn bind(path: impl Into<PathBuf>, state: ControlState) -> io::Result<Self> {
@@ -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<u32> {
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::<ControlRequest>(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<Path>, command: &str) -> io::Result<String> {
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())
+89 -11
View File
@@ -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::<SocketAddr>()?;
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<Http3Server>,
backend: SocketAddr,
idle_timeout: Duration,
state: ControlState,
active: Arc<AtomicUsize>,
) -> 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<QuicServer>,
backend: SocketAddr,
idle_timeout: Duration,
state: ControlState,
active: Arc<AtomicUsize>,
) -> 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<TlsTcpServer>,
backend: SocketAddr,
idle_timeout: Duration,
state: ControlState,
active: Arc<AtomicUsize>,
) -> 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<dyn TransportSession>,
backend_target: SocketAddr,
idle_timeout: Duration,
state: ControlState,
stats: Arc<Stats>,
) -> 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<RelayOutcome> {
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<AtomicBool>,
}
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);
}
}
+111 -57
View File
@@ -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<SocketAddr> {
if let Ok(addr) = server.parse::<SocketAddr>() {
return Ok(addr);
}
let endpoint = format!("{server}:{port}");
let endpoint = if let Ok(address) = server.parse::<std::net::Ipv6Addr>() {
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<SocketAddr> {
}
fn direct_bind_addr(listen: &str, port: u16) -> Result<String> {
if listen.parse::<SocketAddr>().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<HelloCacheKey, HelloCacheEntry>,
}
#[derive(Default)]
struct AuthAttemptWindow {
attempts: VecDeque<Instant>,
last_seen: Option<Instant>,
}
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<Mutex<HashMap<SocketAddr, Instant>>>,
config: ServerConfig,
stats: Arc<Stats>,
auth_attempts: Mutex<HashMap<IpAddr, AuthAttemptWindow>>,
}
/// 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<SocketAddr> {
@@ -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<u64> = 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);
+118 -41
View File
@@ -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<Sha256>;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -725,6 +732,7 @@ pub struct Http3ConnectUdpSession {
quic_connection: quinn::Connection,
client_id: String,
driver: Mutex<Option<JoinHandle<()>>>,
driver_abort: AbortHandle,
client_sender: Mutex<Option<h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>>>,
}
@@ -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<h3_quinn::Connection, Bytes>,
) {
@@ -1276,17 +1361,9 @@ impl Http3ClientConnector {
}
async fn connect_session(&self) -> Result<Box<dyn TransportSession>, 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<SocketAddr, TransportError> {
let address = if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
};
address.parse::<SocketAddr>().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}"),
+164 -24
View File
@@ -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<SocketAddr> {
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<Option<tls_tcp::FramedReader<TlsRead>>>,
writer: mpsc::Sender<TlsWriterCommand>,
writer_shutdown: tokio_util::sync::CancellationToken,
writer_task: StdMutex<Option<JoinHandle<()>>>,
max_datagram: usize,
closed: tokio_util::sync::CancellationToken,
keepalive_interval: Option<Duration>,
@@ -358,7 +386,7 @@ enum TlsWriterCommand {
struct TlsSessionOptions {
max_datagram: usize,
idle_timeout: Duration,
reader_idle_timeout: Option<Duration>,
keepalive_interval: Option<Duration>,
remote_addr: Option<SocketAddr>,
client_id: Option<String>,
@@ -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<Arc<rustls::ClientConfig>>,
@@ -575,8 +663,9 @@ impl TlsTcpConnector {
}
}
async fn connect_session(&self) -> Result<Box<dyn TransportSession>, 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,8 +690,7 @@ impl TlsTcpConnector {
}
};
let connector = tokio_rustls::TlsConnector::from(connector_config);
let server_name =
rustls::pki_types::ServerName::try_from(host.to_owned()).map_err(|_| {
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",
@@ -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<AdmissionController>,
) -> Result<Self, TransportError> {
let listen = if config.server.listen.parse::<SocketAddr>().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());
}
}
+33 -30
View File
@@ -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<String>) -> io::Error {
io::Error::other(message.into())
@@ -58,11 +50,22 @@ fn quinn_io<E: std::fmt::Display>(error: E) -> TransportError {
TransportError::Io(io_other(error.to_string()))
}
fn transport_config(keepalive_secs: u64) -> Arc<quinn::TransportConfig> {
fn transport_config(
keepalive_secs: u64,
idle_timeout_secs: Option<u64>,
) -> Arc<quinn::TransportConfig> {
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<quinn::TransportConfig> {
}
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<QuinnServerConfig, TransportError> {
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<QuinnClientConfig, TransportError> {
}
fn resolve_server(host: &str, port: u16) -> Result<SocketAddr, TransportError> {
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<SocketAddr, TransportError> {
let address = if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
};
address
.parse::<SocketAddr>()
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<QuicDatagramSession, TransportError> {
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,
})
+7
View File
@@ -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<Sha256>;
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())))??;
+91
View File
@@ -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();
}
+118 -1
View File
@@ -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();
+568
View File
@@ -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<AtomicU16>,
}
impl TransportConnector for RotatingQuicConnector {
fn connect<'a>(&'a self) -> TransportFuture<'a, Box<dyn TransportSession>> {
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<mpsc::UnboundedSender<SocketAddr>>,
) {
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<anyhow::Result<()>>) {
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<dyn TransportConnector>;
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<dyn TransportConnector>;
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<dyn TransportConnector>;
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<dyn TransportConnector>;
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();
}
+22 -4
View File
@@ -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}"));
}
+50
View File
@@ -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());
}
+50
View File
@@ -0,0 +1,50 @@
use wgcloak::config::{Config, ServerConfig, MAX_APPLICATION_DATAGRAM_SIZE};
const SECRET: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
fn server_text(max_datagram_size: Option<u16>) -> 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;
}
+165 -1
View File
@@ -29,6 +29,37 @@ impl TransportSession for Session {
1200
}
}
struct EchoSession {
payload: Mutex<Option<Bytes>>,
}
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<Mutex<Vec<TransportMode>>>,
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<Mutex<Vec<TransportMode>>>,
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<dyn TransportSession>)
} 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<TransportMode>,
) {
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<dyn TransportSession>) })
}
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(_)));
}
+42
View File
@@ -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);