feat: complete wgcloak v1 transports
This commit is contained in:
1 parent
49ec9d8c33
commit
cedab4910e
47 files changed
+2842
-393
No files matched your search
Generated
+272
@@ -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"
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/Cargo.lock
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
[[client
|
||||
@@ -0,0 +1,4 @@
|
||||
[client]
|
||||
server = "127.0.0.1:51820"
|
||||
id = "fuzz"
|
||||
secret = "ZmFrZQ=="
|
||||
@@ -0,0 +1 @@
|
||||
\x01\x10\x00\xff
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
\x2a\x20
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
\xff\xff
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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())))??;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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(_)));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user