From 313ab0076592092d8119a6358a8af5b03b838b8c Mon Sep 17 00:00:00 2001 From: Hector van der Aa Date: Mon, 14 Sep 2026 22:55:07 +0200 Subject: [PATCH] checkpoint: implement wgcloak v1 foundations --- .gitignore | 23 + Cargo.lock | 1808 ++++++++++++++++ Cargo.toml | 37 + LICENSE | 21 + README.md | 98 + docs/ARCHITECTURE.md | 73 + docs/DEVELOPMENT_STATE.md | 109 + docs/IMPLEMENTATION_CHECKLIST.md | 60 + docs/OPERATIONS.md | 93 + docs/PROTOCOL.md | 72 + docs/SECURITY.md | 49 + docs/TROUBLESHOOTING.md | 65 + examples/client.advanced.toml | 39 + examples/client.minimal.toml | 13 + examples/server.advanced.toml | 49 + examples/server.minimal.toml | 22 + packaging/systemd/wgcloak-client.service | 39 + packaging/systemd/wgcloak-server.service | 44 + spec.md | 2390 ++++++++++++++++++++++ src/cli.rs | 92 + src/client/mod.rs | 143 ++ src/client/supervisor.rs | 257 +++ src/config.rs | 826 ++++++++ src/control.rs | 170 ++ src/datagram.rs | 23 + src/main.rs | 63 + src/protocol/auth.rs | 309 +++ src/protocol/direct.rs | 950 +++++++++ src/protocol/mod.rs | 21 + src/protocol/replay.rs | 191 ++ src/server/mod.rs | 93 + src/session.rs | 52 + src/stats.rs | 67 + src/transport/direct_udp.rs | 662 ++++++ src/transport/http3.rs | 428 ++++ src/transport/mod.rs | 353 ++++ src/transport/quic.rs | 600 ++++++ src/transport/tls_tcp.rs | 643 ++++++ tests/control.rs | 45 + tests/direct_udp.rs | 295 +++ tests/http3.rs | 96 + tests/integration_transports.rs | 102 + tests/quic.rs | 148 ++ tests/supervisor.rs | 166 ++ tests/tls_tcp.rs | 159 ++ 45 files changed, 12058 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DEVELOPMENT_STATE.md create mode 100644 docs/IMPLEMENTATION_CHECKLIST.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 examples/client.advanced.toml create mode 100644 examples/client.minimal.toml create mode 100644 examples/server.advanced.toml create mode 100644 examples/server.minimal.toml create mode 100644 packaging/systemd/wgcloak-client.service create mode 100644 packaging/systemd/wgcloak-server.service create mode 100644 spec.md create mode 100644 src/cli.rs create mode 100644 src/client/mod.rs create mode 100644 src/client/supervisor.rs create mode 100644 src/config.rs create mode 100644 src/control.rs create mode 100644 src/datagram.rs create mode 100644 src/main.rs create mode 100644 src/protocol/auth.rs create mode 100644 src/protocol/direct.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/replay.rs create mode 100644 src/server/mod.rs create mode 100644 src/session.rs create mode 100644 src/stats.rs create mode 100644 src/transport/direct_udp.rs create mode 100644 src/transport/http3.rs create mode 100644 src/transport/mod.rs create mode 100644 src/transport/quic.rs create mode 100644 src/transport/tls_tcp.rs create mode 100644 tests/control.rs create mode 100644 tests/direct_udp.rs create mode 100644 tests/http3.rs create mode 100644 tests/integration_transports.rs create mode 100644 tests/quic.rs create mode 100644 tests/supervisor.rs create mode 100644 tests/tls_tcp.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f83c9d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +/target/ +**/*.secret +*.secret +*.key +*.pem +.env +.env.* + +# Local runtime state and sockets +*.sock +run/ + +# Editor and OS files +.DS_Store +*~ +.idea/ +.vscode/ + +# Coverage and local benchmark output +*.profraw +*.profdata +coverage/ +bench-results/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c167a57 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1808 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "h3-datagram" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" +dependencies = [ + "bytes", + "h3", + "pin-project-lite", +] + +[[package]] +name = "h3-quinn" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" +dependencies = [ + "bytes", + "futures", + "h3", + "h3-datagram", + "quinn", + "tokio", + "tokio-util", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wgcloak" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "bytes", + "clap", + "h3", + "h3-quinn", + "hex", + "hkdf", + "hmac", + "quinn", + "rand 0.8.8", + "rustls", + "rustls-native-certs", + "secrecy", + "serde", + "sha2", + "socket2 0.5.10", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "zeroize", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c097f50 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "wgcloak" +version = "0.1.0" +edition = "2021" +description = "Protocol-agnostic authenticated UDP transport layer" +license = "MIT OR Apache-2.0" + +[dependencies] +anyhow = "1.0" +bytes = "1.7" +base64 = "0.22" +clap = { version = "4.5", features = ["derive"] } +h3 = "0.0.8" +h3-quinn = { version = "0.0.10", features = ["datagram"] } +hkdf = "0.12" +hmac = "0.12" +hex = "0.4" +quinn = "0.11" +rand = "0.8" +rustls = "0.23" +secrecy = "0.8" +serde = { version = "1.0", features = ["derive"] } +sha2 = "0.10" +socket2 = "0.5" +subtle = "2.6" +thiserror = "1.0" +tokio = { version = "1.40", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec", "rt"] } +tokio-rustls = "0.26" +rustls-native-certs = "0.8" +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +zeroize = "1.8" + +[dev-dependencies] +tempfile = "3.13" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d02e3d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wgcloak contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f4bec4 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# wgcloak + +wgcloak is a Linux userspace relay for opaque UDP datagrams. It is intended to +carry WireGuard traffic, but does not configure, inspect, or depend on +WireGuard: the same relay can carry any UDP application. + +The design keeps four concerns separate: + +```text +local UDP application -> tunnel core -> selected transport -> tunnel core + -> backend UDP application +``` + +The server gives each authenticated client its own backend UDP socket. The +application payload is not decoded or rewritten. + +## Status + +This checkout is specification-first and under active implementation. The +milestone and v1 acceptance checklist is in +[`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md). A +transport is available only when its code, tests, and integration criteria are +verified; the documentation intentionally does not imply that all four +transports are already present. + +Target v1 transport order: + +1. direct UDP (Milestone 1) +2. QUIC DATAGRAM (Milestone 2) +3. HTTP/3 CONNECT-UDP (Milestone 4) +4. TLS/TCP framed fallback (Milestone 5) + +## Build and install + +Rust and Cargo are required. From the repository root: + +```bash +cargo test +cargo build --release +``` + +For a host installation (choose a reviewed release artifact): + +```bash +sudo install -Dm755 target/release/wgcloak /usr/local/bin/wgcloak +sudo install -d -m0750 /etc/wgcloak +``` + +The binary name and subcommands are the intended interface from `spec.md`: + +```bash +wgcloak client --config /etc/wgcloak/client.toml +wgcloak server --config /etc/wgcloak/server.toml +wgcloak check-config --config /etc/wgcloak/client.toml +wgcloak generate-secret +``` + +Run `wgcloak --help` against the built binary to confirm the commands in the +current build. QUIC, HTTP/3, control commands, and certificate reload are +milestone-gated; see the checklist before deploying them. + +## Configuration + +Copy and edit one of the safe, placeholder-only examples: + +- [`examples/client.minimal.toml`](examples/client.minimal.toml) +- [`examples/client.advanced.toml`](examples/client.advanced.toml) +- [`examples/server.minimal.toml`](examples/server.minimal.toml) +- [`examples/server.advanced.toml`](examples/server.advanced.toml) + +Put real secrets in separate files, owned by the service account, mode `0600`. +Do not commit them. See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) and +[`docs/SECURITY.md`](docs/SECURITY.md). + +## Run with systemd + +The templates in [`packaging/systemd/`](packaging/systemd/) assume a dedicated +`wgcloak` user, `/usr/bin/wgcloak`, and configuration under `/etc/wgcloak/`. +Review paths and transport support before installing them. The server template +includes hardening and a commented optional `CAP_NET_BIND_SERVICE` setting for +ports below 1024; normal operation does not require `CAP_NET_ADMIN` or +`CAP_SYS_ADMIN`. + +## Documentation + +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): process, data path, and ownership boundaries. +- [`docs/PROTOCOL.md`](docs/PROTOCOL.md): direct packet model and transport contracts. +- [`docs/OPERATIONS.md`](docs/OPERATIONS.md): files, control socket, lifecycle, and deployment. +- [`docs/SECURITY.md`](docs/SECURITY.md): threat model, secrets, privileges, and non-goals. +- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md): checks for common startup and connectivity failures. +- [`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md): milestones 1–6 and all 17 v1 criteria. + +## Explicit non-goals + +wgcloak does not configure WireGuard, create TUN/TAP devices, change routes or +firewalls, fragment application datagrams, spoof browser/TLS fingerprints, or +provide clustering, roaming, a GUI, Windows/macOS support, or multiple client +tunnels in v1. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c639efc --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,73 @@ +# Architecture + +wgcloak is a single Linux userspace process with a `client` or `server` role. +The core moves opaque datagrams between a local UDP socket and a transport +session. It does not know whether the payload is WireGuard, DNS, or another UDP +protocol. + +```text +CLIENT SERVER +local UDP peer transport listener + │ │ + ▼ ▼ +local listener ── tunnel core ── transport ── authenticated session + │ + ▼ + dedicated connected UDP backend socket + │ + ▼ + configured UDP backend +``` + +## Ownership boundaries + +- The local listener receives datagrams and remembers the most recently active + local peer for return traffic. One active peer per v1 tunnel is sufficient. +- The core owns session lifecycle, limits, datagram-size checks, reconnect + policy, and statistics. Payload bytes remain opaque. +- A transport owns connection establishment and wire representation. Every + transport exposes the same logical send/receive/close behavior and a maximum + datagram size. +- Authentication is a separate concern. Direct UDP uses the documented + challenge/HMAC packet protocol; TLS-capable transports authenticate the + client after transport establishment. +- The server creates a backend UDP socket only after client authentication and + maps that socket to one session. A backend response therefore has natural + session ownership without inspecting the backend protocol. + +## Transport lifecycle + +The client state machine is: + +```text +STARTING → RESOLVING → PROBING → AUTHENTICATING → CONNECTED + │ │ + └───────────┘ failure + ▼ + RECONNECTING + │ + └→ PROBING +``` + +In `auto` mode the target order is direct UDP, QUIC DATAGRAM, HTTP/3 +CONNECT-UDP, then TLS/TCP. Authentication rejection is a configuration error, +not permission to probe indefinitely. Network failures use bounded exponential +backoff with jitter. + +## Server isolation + +One malformed or unauthenticated packet must be rejected without terminating +the listener or affecting another client. Server limits cover active sessions, +per-source sessions, pending handshakes, handshake duration, authentication +attempts, and maximum datagram size. Expiry closes the backend socket and +releases replay/session state. + +## Privileges and filesystem + +Normal operation uses ordinary UDP/TCP/Unix sockets and needs no root, +`CAP_NET_ADMIN`, or `CAP_SYS_ADMIN`. `CAP_NET_BIND_SERVICE` is optional when a +reviewed deployment must bind a port below 1024. The control socket is local +and protected by filesystem ownership/mode; it is not a network listener. + +This document describes the intended v1 architecture. Check the milestone +matrix before relying on a transport or control command in a particular build. diff --git a/docs/DEVELOPMENT_STATE.md b/docs/DEVELOPMENT_STATE.md new file mode 100644 index 0000000..027cbe0 --- /dev/null +++ b/docs/DEVELOPMENT_STATE.md @@ -0,0 +1,109 @@ +# Development state + +Checkpoint: 2026-09-14, end of day. Development is paused at a compilable +checkpoint; no commit was made. + +## Branch and worktree + +- Branch: `main`. +- `git status --short` reports the project files as untracked, including + `Cargo.toml`, `Cargo.lock`, `src/`, `tests/`, documentation, examples, and + packaging files. There is no useful committed baseline to compare against in + this checkout. +- Multiple agents have been working in the shared directory. Review all diffs + and ownership boundaries before committing; in particular, do not assume an + untracked file was created by this task or that a concurrent edit is final. + +## Implemented and wired behavior + +The current tree contains the following work, subject to integration review: + +- Operational assets: MIT `LICENSE`, Linux-focused `README.md`, `.gitignore`, + client/server TOML examples, architecture/protocol/security/operations/ + troubleshooting documents, an implementation checklist, and systemd client + and server templates. +- TLS/TCP transport primitives in `src/transport/tls_tcp.rs`: bounded + incremental/coalesced u16-big-endian framing, truncated/oversized-frame + handling, PEM/DER certificate and private-key loading, challenge-response + authentication, authenticated data/heartbeat/close framing, idle timeout, + and bounded shutdown. +- HTTP/3 support primitives in `src/transport/http3.rs`: CONNECT-UDP request + validation, QUIC varints, RFC 9297 HTTP Datagram context encoding, capsule + encoding, and project authentication messages. The module does not fake + CONNECT-UDP with POST or ordinary streams. +- Runtime orchestration in `src/transport/mod.rs`, `src/client/mod.rs`, and + `src/server/mod.rs` instantiates direct UDP, QUIC, and TLS/TCP connectors or + listeners. TLS backend creation occurs after the authenticated session is + established. HTTP/3 currently returns an explicit `Unsupported` result from + runtime selection because the actual h3 datagram adapter is not complete. +- The client and server start the Unix control server. Client reconnect and + reload commands currently cause a reconnect attempt; server shutdown is + handled by the encrypted listener paths, while direct-UDP/control lifecycle + still needs review. +- `h3-quinn` was enabled with its `datagram` feature; `tokio-rustls` and + `rustls-native-certs` were added for TLS runtime integration. + +## Files changed in this work + +Primary files touched by the operations/integration work are: + +- `Cargo.toml`, `Cargo.lock` +- `src/transport/mod.rs` +- `src/transport/tls_tcp.rs` +- `src/transport/http3.rs` +- `src/client/mod.rs` +- `src/server/mod.rs` +- `tests/tls_tcp.rs`, `tests/http3.rs`, `tests/integration_transports.rs` +- `.gitignore`, `LICENSE`, `README.md` +- `examples/*`, `packaging/systemd/*`, and the other files under `docs/` + +`src/transport/quic.rs` was edited by another concurrent agent and is outside +the TLS/integration ownership listed above. Preserve that ownership boundary +when resolving conflicts. + +## Verification and current failures + +- TOML examples were parsed successfully with Python `tomllib`. +- TLS/TCP focused tests passed: `cargo test --test tls_tcp --quiet` (7 tests). +- HTTP/3 focused tests passed: `cargo test --test http3 --quiet` (4 tests). +- `cargo check` passed after the transport integration edits. +- The TLS loopback integration test compiles but currently panics while + constructing a rustls configuration because no process-level crypto provider + is installed: + + `Could not automatically determine process-level CryptoProvider from Rustls crate features.` + + The runtime/test setup must explicitly install the `ring` provider (or enable + exactly one provider feature) before building rustls configurations. +- The supervisor's latest failure reprobe reports that the QUIC focused tests + now pass. Run the complete suite again after fixing the rustls provider; the + earlier complete-suite run had QUIC handshake-timeout failures before the + concurrent QUIC-agent fixes landed. +- Clippy has not reached a clean result. Existing included test/source modules + produce many dead-code and lint diagnostics, and integration code also needs + a final lint pass after the provider fix. + +## Remaining gaps, prioritized + +1. Fix and verify explicit rustls crypto-provider initialization, then run the + TLS loopback test and the complete test suite. +2. Finish a real h3 0.0.8/h3-quinn 0.0.10 HTTP/3 CONNECT-UDP datagram adapter, + or retain the explicit `Unsupported` path with a precise documented + dependency/API blocker. Do not substitute POST, capsules-only streams, or + ordinary HTTP request bodies for HTTP Datagrams. +3. Review runtime lifecycle semantics: direct-UDP control shutdown wiring, + actual file-backed config reload, client TLS heartbeat scheduling, and + authentication-error mapping so auto fallback stops on rejected credentials. +4. Run repository-wide formatting, tests, and clippy; inspect the shared + untracked tree and reconcile concurrent `quic.rs` changes before any commit. + +## First three tasks tomorrow + +1. Install/select the rustls `ring` provider once at runtime and in the + integration test setup; rerun TLS loopback plus `cargo test`. +2. Inspect the enabled h3-quinn datagram API and implement/test the smallest + standards-correct CONNECT-UDP client/server adapter; otherwise record the + exact unsupported API/dependency condition in the public docs. +3. Perform an end-to-end lifecycle review and repository-wide `fmt`, check, + test, and clippy pass, resolving only changes within each agent's ownership. + diff --git a/docs/IMPLEMENTATION_CHECKLIST.md b/docs/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..ec34202 --- /dev/null +++ b/docs/IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,60 @@ +# Implementation and acceptance checklist + +This is a coordinator checklist, not a claim that the items are complete. +Mark an item only with code evidence, tests, and an integration result. Record +the commit/test command or issue beside each checked item. A feature marked +planned in the other documents must remain visibly planned here until proven. + +## 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"`; direct UDP then QUIC; + 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; certificate + and configuration reload; benchmarks; fuzzing; installation docs. + +## 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. + +## 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, + 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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..b8018fc --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,93 @@ +# Operations + +This guide assumes a dedicated `wgcloak` system user and `/etc/wgcloak/` for +configuration. Adjust paths for a user service or a different packaging +layout. + +## First installation + +```bash +sudo install -Dm755 target/release/wgcloak /usr/bin/wgcloak +sudo install -d -o wgcloak -g wgcloak -m0750 /etc/wgcloak/clients +sudo install -o wgcloak -g wgcloak -m0600 client.secret /etc/wgcloak/client.secret +``` + +Create server client secrets separately and transfer them through a protected +channel. Do not put real secret contents in the repository. Validate a config +before starting a service when `check-config` is available in the current +build: + +```bash +sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/client.toml +``` + +The command is a target v1 CLI and is delivered in the operational-polish +milestone; confirm with `wgcloak --help`. + +## Starting and stopping + +Foreground operation is useful for first-run diagnostics: + +```bash +wgcloak client --config /etc/wgcloak/client.toml +wgcloak server --config /etc/wgcloak/server.toml +``` + +SIGTERM and SIGINT should stop accepting new sessions, stop local ingestion, +close backend sockets, flush useful metrics, and exit within a bounded timeout. +A transport failure should reconnect with jitter; it should not terminate the +daemon. The systemd templates set restart-on-failure for this reason. + +## Control socket (intended interface) + +The daemon should expose a Unix-domain control socket at +`$XDG_RUNTIME_DIR/wgcloak.sock` (for a system service, the template maps this +to its private runtime directory). Filesystem permissions, not a TCP port, +authorize access. The initial control protocol may be newline-delimited JSON. + +Target commands are: + +```text +status current role/state, selected transport, endpoint, MTU and uptime +stats counters for traffic, drops, auth failures, reconnects and sessions +reconnect ask the client to leave the current transport and probe again +reload re-read supported configuration (not full hot reload in early builds) +shutdown request graceful process shutdown +``` + +The exact request/response field names are implementation-owned until the +control protocol lands. A client should never send credentials or payloads +through this socket. `status` and `stats` are read-only; `reconnect`, `reload`, +and `shutdown` are state-changing and must remain local-admin operations. + +## systemd deployment + +Review and copy the units under `packaging/systemd/`. They assume: + +- binary `/usr/bin/wgcloak`; +- user/group `wgcloak`; +- configuration `/etc/wgcloak/client.toml` or `server.toml`; +- runtime socket under a private `/run` directory; +- secret and certificate paths readable by the service user. + +After review: + +```bash +sudo install -Dm0644 packaging/systemd/wgcloak-client.service /etc/systemd/system/wgcloak-client.service +sudo systemctl daemon-reload +sudo systemctl enable --now wgcloak-client.service +systemctl status wgcloak-client.service +journalctl -u wgcloak-client.service -f +``` + +For a low port such as 443, uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` +and its bounding-set companion in the server unit only after confirming the +unit still starts. Direct UDP examples use an unprivileged port. + +## Safe rollout + +Start with direct UDP and one test client. Check local listener binding, +server authentication, backend reachability, return-peer behavior, counters, +and clean shutdown. Add one transport milestone at a time. Keep an independent +path to the host while changing a public listener and retain the previous +configuration until a restart test succeeds. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..e040822 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,72 @@ +# Protocol overview + +The application protocol, tunnel session, transport, and authentication remain +independent. The application payload is opaque and is never parsed as +WireGuard (or any other protocol). + +## Direct UDP wire protocol (target v1) + +Direct messages use protocol version `1`, network byte order, and a 12-byte +common header: + +```text ++---------+---------+---------+---------+ +| version | type | flags | hdr_len | +| u8 | u8 | u8 | u8 | ++---------------------------------------+ +| session_id (u64) | ++---------------------------------------+ +``` + +Packet types are `HELLO (0x01)`, `ACCEPT (0x02)`, `REJECT (0x03)`, +`DATA (0x10)`, `KEEPALIVE (0x11)`, and `CLOSE (0x12)`. Unknown or malformed +types are rejected or ignored according to context and never crash the daemon. + +`HELLO` has session ID zero and carries a bounded client ID, timestamp, +16-byte client nonce, and a 32-byte HMAC-SHA256 authenticator over the +canonical version/client/timestamp/nonce encoding. The server checks a default +60-second clock window and remembers accepted nonces for replay protection. + +`ACCEPT` assigns a random, non-zero 64-bit session ID and returns a server +nonce, idle timeout, maximum datagram size, and an authenticator. `REJECT` +returns only a small reason class; it must not disclose secret material. + +Authenticated `DATA` and `KEEPALIVE` packets contain the common header, a +32-bit direction-specific sequence number, a 128-bit truncated HMAC tag, and +the optional payload. Session keys are derived with HKDF-SHA256 from the +configured secret, both nonces, and the session ID. Implementations must use +maintained cryptographic libraries and constant-time tag comparison. + +Sequence windows provide duplicate/replay detection and diagnostics but do not +reorder UDP. After MAC and replay checks, a valid packet may update the peer +address, allowing NAT rebinding. A session expires after the configured idle +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. + +## Encapsulated transports + +- **Direct UDP:** lowest overhead and native datagrams; uses the authenticated + protocol above and provides integrity but not outer confidentiality. +- **QUIC DATAGRAM:** TLS 1.3 protects the transport; client shared-secret + challenge/response still authenticates the configured client. +- **HTTP/3 CONNECT-UDP:** uses legitimate MASQUE CONNECT-UDP and HTTP Datagram + semantics over QUIC. It is for compatibility, not fingerprint spoofing. +- **TLS/TCP:** uses the same client authentication model. Each datagram is + framed as a big-endian `u16` payload length followed by up to 65535 bytes; + the configured lower limit should normally apply. TCP head-of-line blocking + is expected. + +QUIC, HTTP/3, and TLS/TCP are planned by milestones 2, 4, and 5 respectively. +The direct packet details are a protocol target; confirm the implementation and +integration tests before treating them as interoperable guarantees. + +## Authentication and resource order + +The server performs cheap bounds/version checks, authentication and rate-limit +checks before allocating a backend UDP socket. Public listeners are hostile: +limits, handshake timeouts, per-source caps, and malformed-packet handling are +part of the protocol boundary. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..545de6d --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,49 @@ +# Security model + +## What is protected + +The server treats every public listener as hostile. v1 requires a configured +client ID and at least 256 bits of entropy in its shared secret. Direct UDP +authenticates every session and every data packet, rejects replay, tolerates +authenticated NAT rebinding, and does not trust a session ID by itself. +TLS-capable transports authenticate the server through normal X.509 hostname +verification and authenticate the client with a post-connection HMAC +challenge/response. + +Secrets and application payloads must not appear in logs. Cryptographic +operations belong to maintained Rust libraries; wgcloak does not implement +encryption, HMAC, HKDF, SHA, or TLS itself. + +## Local permissions + +Use separate secret files rather than inline values. Secret files should be +owned by the service account and mode `0600`; the parent configuration +directory should not be writable by untrusted users. The Unix control socket +must be created in a private runtime directory with restrictive ownership and +mode (normally `0600`). Do not put it in a world-writable directory. + +The systemd templates apply `NoNewPrivileges`, a read-only system view, private +temporary storage, restricted address families, and other process hardening. +Review `ReadWritePaths`, certificate access, and local policy before enabling +them. Grant only `CAP_NET_BIND_SERVICE` when a low port is essential. Never +grant `CAP_NET_ADMIN` or `CAP_SYS_ADMIN` for normal operation. + +## Threat boundaries + +The relay protects session admission and transport integrity; it does not make +the encapsulated application secure. Direct UDP is not encrypted by wgcloak, +so use QUIC, HTTP/3, or TLS/TCP when outer confidentiality is needed. A shared +secret is a credential: rotate it out of band and avoid putting it in command +lines, shell history, or issue reports. + +Resource limits and bounded parsing reduce denial-of-service exposure, but a +public relay still needs host firewalling, monitoring, and capacity planning. +Authentication failure must stop probing rather than causing endless fallback +traffic. + +## Explicit non-goals + +v1 does not configure WireGuard, create TUN/TAP interfaces, manage routes or +firewalls, provide traffic-shaping or browser/TLS-fingerprint evasion, hide +traffic metadata from a compromised host, cluster servers, or support multiple +simultaneous client tunnels. These boundaries are intentional. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..699ffc3 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,65 @@ +# Troubleshooting + +Check the simplest layer that can explain the symptom, then move outward. + +## Process exits immediately + +- Run the same command in the foreground and read the fatal configuration + error. +- Check that every secret/certificate path exists and is readable by the + service account, but not by unrelated users. +- Run `wgcloak --help` and, where implemented, `wgcloak check-config` to spot a + command or schema mismatch. +- Confirm listener addresses are valid and no other process owns the port. + +## Client cannot connect + +- Verify DNS resolution and the configured server name/port. +- Confirm the selected transport is implemented in this build; an `auto` list + may mention future transports. +- For direct UDP, check both directions in the host firewall and allow the + server's UDP listener. +- For TLS transports, verify the hostname matches the certificate and do not + disable certificate verification. +- Authentication rejection means a bad/unknown client or secret and must not + be diagnosed as a firewall failure. Fix credentials before retrying. + +## Authentication failures or repeated probing + +Check client ID spelling, secret-file contents, ownership/mode, and system +clock skew. Direct HELLO timestamps have a target default 60-second window; +replayed client nonces are rejected. Repeated invalid credentials should stop +fallback probing rather than generate traffic across every transport. + +## Connected but no application traffic + +- Confirm the local application sends to the configured local UDP address. +- Confirm the server backend is listening at `[backend].target`. +- Check `status`/`stats` or structured logs for selected transport, maximum + datagram size, dropped/oversized datagrams, and backend errors. +- v1 remembers one most-recent local source peer per client tunnel. Concurrent + source peers can replace that return destination and produce a warning. +- No application-layer fragmentation exists: reduce payload size when the + configured transport limit is exceeded (starting target is 1200 bytes). + +## Session disappears + +The target idle timeout is 120 seconds, refreshed by authenticated data or +keepalive. Check keepalive settings, NAT expiry, server resource limits, and +whether the client is reconnecting after a transport failure. A valid direct +UDP packet can update the peer address after NAT rebinding; a spoofed packet +without a valid MAC cannot. + +## Control socket unavailable + +Check the daemon's `XDG_RUNTIME_DIR`, socket ownership/mode, and whether a +stale socket path remains after an unclean exit. The control interface is local +only and may be absent until the operational-polish milestone is implemented. + +## Host hardening prevents startup + +Read the unit's journal output. Common causes are a secret/certificate path +outside the unit's read paths, a changed binary location, or an address family +not allowed by `RestrictAddressFamilies`. Correct the reviewed unit rather +than weakening all hardening directives. `CAP_NET_BIND_SERVICE` is only needed +for ports below 1024. diff --git a/examples/client.advanced.toml b/examples/client.advanced.toml new file mode 100644 index 0000000..a75a9bd --- /dev/null +++ b/examples/client.advanced.toml @@ -0,0 +1,39 @@ +# Advanced client example. Values are placeholders, not credentials. + +[client] +server = "vpn.example.invalid" +id = "replace-me" +secret_file = "/etc/wgcloak/client.secret" + +[local] +listen = "127.0.0.1:51821" + +[transport] +mode = "auto" + +# Auto probing is target behavior; use only transports supported by the build. +[transport.auto] +order = ["direct_udp", "quic", "http3", "tls_tcp"] +probe_timeout_ms = 1500 +retries = 2 + +[transport.direct_udp] +port = 51830 +keepalive_secs = 15 + +[transport.quic] +port = 443 +keepalive_secs = 15 + +[transport.http3] +port = 443 +path = "/tunnel" + +[transport.tls_tcp] +port = 443 + +[network] +max_datagram_size = 1200 + +[logging] +level = "info" diff --git a/examples/client.minimal.toml b/examples/client.minimal.toml new file mode 100644 index 0000000..9b9ac27 --- /dev/null +++ b/examples/client.minimal.toml @@ -0,0 +1,13 @@ +# Minimal client. Replace the hostname, id, and secret path before use. +# The secret file must contain a generated secret and be mode 0600. + +[client] +server = "vpn.example.invalid" +id = "replace-me" +secret_file = "/etc/wgcloak/client.secret" + +[local] +listen = "127.0.0.1:51821" + +[transport] +mode = "auto" diff --git a/examples/server.advanced.toml b/examples/server.advanced.toml new file mode 100644 index 0000000..ca9b874 --- /dev/null +++ b/examples/server.advanced.toml @@ -0,0 +1,49 @@ +# Advanced server example. Values are placeholders, not credentials. + +[server] +listen = "0.0.0.0:51830" + +[backend] +target = "127.0.0.1:51820" + +[transports.direct_udp] +enabled = true +port = 51830 + +[transports.quic] +enabled = true +port = 443 + +[transports.http3] +enabled = true +port = 443 +path = "/tunnel" + +[transports.tls_tcp] +enabled = true +port = 443 + +# Required when TLS-capable transports are enabled. +[tls] +certificate = "/etc/letsencrypt/live/vpn.example.invalid/fullchain.pem" +private_key = "/etc/letsencrypt/live/vpn.example.invalid/privkey.pem" + +[session] +idle_timeout_secs = 120 + +[limits] +max_sessions = 1024 +max_sessions_per_ip = 32 +max_pending_handshakes = 256 +handshake_timeout_ms = 3000 + +[[auth.clients]] +id = "laptop" +secret_file = "/etc/wgcloak/clients/laptop.secret" + +[[auth.clients]] +id = "desktop" +secret_file = "/etc/wgcloak/clients/desktop.secret" + +[logging] +level = "info" diff --git a/examples/server.minimal.toml b/examples/server.minimal.toml new file mode 100644 index 0000000..f7665bf --- /dev/null +++ b/examples/server.minimal.toml @@ -0,0 +1,22 @@ +# Minimal server. Replace the backend and client secret path before use. +# The listener address/port is an example; open only the transports you need. + +[server] +listen = "0.0.0.0:51830" + +[backend] +target = "127.0.0.1:51820" + +# Milestone 1 direct-UDP deployment. Other transports are planned or optional. +[transports.quic] +enabled = false + +[transports.http3] +enabled = false + +[transports.tls_tcp] +enabled = false + +[[auth.clients]] +id = "replace-me" +secret_file = "/etc/wgcloak/clients/replace-me.secret" diff --git a/packaging/systemd/wgcloak-client.service b/packaging/systemd/wgcloak-client.service new file mode 100644 index 0000000..ccfd4a7 --- /dev/null +++ b/packaging/systemd/wgcloak-client.service @@ -0,0 +1,39 @@ +[Unit] +Description=wgcloak UDP transport client +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=wgcloak +Group=wgcloak +ExecStart=/usr/bin/wgcloak client --config /etc/wgcloak/client.toml +Restart=on-failure +RestartSec=2s +TimeoutStopSec=15s +UMask=0077 + +# Keep the control socket in a private runtime directory. +RuntimeDirectory=wgcloak-client +RuntimeDirectoryMode=0750 +Environment=XDG_RUNTIME_DIR=/run/wgcloak-client + +# Process and filesystem hardening. Review paths if the deployment changes. +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +LockPersonality=true +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/packaging/systemd/wgcloak-server.service b/packaging/systemd/wgcloak-server.service new file mode 100644 index 0000000..128f9fd --- /dev/null +++ b/packaging/systemd/wgcloak-server.service @@ -0,0 +1,44 @@ +[Unit] +Description=wgcloak UDP transport server +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=wgcloak +Group=wgcloak +ExecStart=/usr/bin/wgcloak server --config /etc/wgcloak/server.toml +Restart=on-failure +RestartSec=2s +TimeoutStopSec=15s +UMask=0077 + +# Keep the control socket in a private runtime directory. +RuntimeDirectory=wgcloak-server +RuntimeDirectoryMode=0750 +Environment=XDG_RUNTIME_DIR=/run/wgcloak-server + +# Process and filesystem hardening. Review paths if the deployment changes. +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +LockPersonality=true +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +# Ports below 1024 require this optional capability. Leave commented for the +# normal unprivileged configuration. Keep the bounding set this narrow. +# AmbientCapabilities=CAP_NET_BIND_SERVICE +# CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..32245e7 --- /dev/null +++ b/spec.md @@ -0,0 +1,2390 @@ +# Multiprotocol UDP Transport Layer +## Functional & Technical Specification v1.0 + +## 1. Purpose + +This project is a Linux userspace client/server system for transporting arbitrary UDP datagrams through networks where direct UDP connectivity may be restricted. + +Its primary initial use case is carrying WireGuard traffic, but the software MUST remain completely protocol-agnostic. + +The system MUST NOT: + +- configure WireGuard; +- inspect WireGuard packets; +- depend on WireGuard libraries; +- manipulate WireGuard interfaces; +- assume anything about the encapsulated UDP protocol. + +The fundamental abstraction is: + +> Transport opaque UDP datagrams between two endpoints using the least complex viable carrier. + +Typical WireGuard deployment: + +```text +WireGuard client + │ + │ UDP + ▼ +127.0.0.1:51821 + │ + ▼ +Transport client + │ + │ selected carrier + ▼ +Internet + │ + ▼ +Transport server + │ + │ UDP + ▼ +127.0.0.1:51820 + │ + ▼ +WireGuard server +``` + +The exact same setup MUST be usable with any other UDP application. + +--- + +# 2. Design Principles + +The project SHALL follow these principles: + +1. Protocol independence. +2. Minimal packet transformation. +3. Datagram semantics preserved wherever possible. +4. No custom cryptography. +5. Pluggable transports. +6. Automatic fallback from simpler transports to more compatible ones. +7. Low-overhead direct mode. +8. Multi-client server from the first release. +9. Unprivileged execution wherever possible. +10. Human-readable TOML configuration. +11. Sensible defaults with optional advanced tuning. +12. Explicit observability and diagnostics. +13. Architecture prepared for multiple client tunnels in a future release. +14. Security-sensitive parsing kept small and deterministic. +15. Transport-specific behavior MUST NOT leak unnecessarily into the core. + +--- + +# 3. Initial Platform Scope + +Initial supported operating system: + +```text +Linux +``` + +The project SHALL run entirely in userspace. + +Root privileges SHALL NOT be required during normal operation. + +Binding privileged ports such as 443 MAY be accomplished using: + +```text +CAP_NET_BIND_SERVICE +``` + +or an external supervisor/socket mechanism. + +The daemon SHALL NOT require: + +```text +CAP_NET_ADMIN +CAP_SYS_ADMIN +``` + +for normal operation. + +--- + +# 4. Operating Roles + +The project has two runtime roles: + +```text +client +server +``` + +They SHOULD initially be exposed through one executable: + +```bash +project client --config client.toml +project server --config server.toml +``` + +The final project name may replace `project`. + +--- + +# 5. Client Data Path + +The client listens on a configured local UDP address. + +Default example: + +```text +127.0.0.1:51821 +``` + +An arbitrary local application sends UDP datagrams to this socket. + +The client: + +1. receives the datagram; +2. associates it with the configured tunnel; +3. passes it unchanged to the active transport; +4. transports it to the server; +5. receives return datagrams; +6. sends them to the originating local UDP peer. + +The application payload MUST remain opaque. + +--- + +# 6. Local Client Peer Handling + +Although v1 represents one configured tunnel, the local UDP listener MUST track the local source peer. + +For example: + +```text +127.0.0.1:49218 -> 127.0.0.1:51821 +``` + +The most recently active local peer becomes the return destination. + +For v1, one active local peer per tunnel is sufficient. + +If packets arrive concurrently from a different local peer, the implementation SHOULD: + +1. update the active peer; +2. emit a warning/debug event; +3. continue operation. + +This avoids coupling the software to a fixed local source port. + +Future multi-flow support MAY replace this behavior. + +--- + +# 7. Server Data Path + +The server MUST support multiple clients concurrently. + +Every authenticated client transport session maps to its own backend UDP socket. + +Example: + +```text +Client A + │ + ▼ +Session A + │ +UDP socket :41001 + │ + ├─────────────► backend :51820 + +Client B + │ + ▼ +Session B + │ +UDP socket :41002 + │ + ├─────────────► backend :51820 + +Client C + │ + ▼ +Session C + │ +UDP socket :41003 + │ + └─────────────► backend :51820 +``` + +Each backend socket provides natural response demultiplexing. + +The server MUST NOT inspect the backend protocol in order to determine response ownership. + +--- + +# 8. Backend Target + +The server configuration defines one UDP backend destination for v1. + +Example: + +```toml +[backend] +target = "127.0.0.1:51820" +``` + +Every authenticated client receives an independent UDP socket connected to this backend. + +Future versions MAY support client-specific backend targets. + +That feature is explicitly out of scope for v1. + +--- + +# 9. Transport Architecture + +The core operates on logical datagrams. + +A transport session SHALL conceptually expose: + +```rust +pub trait TransportSession { + async fn send_datagram(&self, data: Bytes) -> Result<()>; + async fn recv_datagram(&self) -> Result; + async fn close(&self) -> Result<()>; +} +``` + +Connection establishment SHALL be separated from established-session behavior. + +Conceptually: + +```rust +pub trait TransportConnector { + async fn connect( + &self, + endpoint: &ServerEndpoint, + ) -> Result>; +} +``` + +The final implementation MAY use associated types, enums, channels, or trait objects depending on ergonomics and performance. + +The architectural boundary is more important than the exact Rust syntax. + +--- + +# 10. Supported Transports + +The planned v1 transport order is: + +```text +1. direct UDP +2. QUIC DATAGRAM +3. HTTP/3 CONNECT-UDP +4. TLS/TCP framed fallback +``` + +All four SHALL share the same logical session API. + +--- + +# 11. Direct UDP Transport + +Direct UDP is the lowest-overhead transport. + +It provides: + +- native datagram behavior; +- minimal encapsulation; +- easiest debugging; +- lowest latency; +- baseline performance measurements. + +It also provides the least firewall traversal capability. + +Direct UDP MUST therefore have its own lightweight session and authentication protocol. + +--- + +# 12. QUIC Transport + +QUIC SHALL be the primary encapsulated datagram transport. + +Application packets SHALL use QUIC DATAGRAM support rather than QUIC streams. + +Desired semantics: + +```text +application datagram + │ + ▼ +QUIC DATAGRAM + │ + ▼ +QUIC + │ + ▼ +UDP +``` + +QUIC provides: + +- TLS 1.3; +- server authentication; +- encrypted transport metadata; +- connection identity; +- connection state; +- congestion control; +- transport-level integrity; +- migration primitives; +- keepalive support. + +The QUIC connection itself constitutes the transport session. + +No redundant project session identifier SHALL be placed in every QUIC data packet. + +--- + +# 13. HTTP/3 Transport + +HTTP/3 SHALL use legitimate HTTP/3 semantics. + +Preferred architecture: + +```text +UDP payload + │ + ▼ +CONNECT-UDP + │ + ▼ +HTTP Datagram + │ + ▼ +HTTP/3 + │ + ▼ +QUIC +``` + +The implementation SHOULD follow the standardized MASQUE CONNECT-UDP architecture rather than inventing an HTTP-shaped custom transport. + +The purpose of this transport is firewall compatibility, not sophisticated fingerprint impersonation. + +--- + +# 14. TLS/TCP Fallback + +Some restrictive networks may block UDP completely. + +The final fallback SHALL therefore operate over TLS/TCP. + +Datagrams MUST be explicitly framed because TCP has no message boundaries. + +Frame format: + +```text ++----------------+----------------------+ +| payload length | UDP payload | +| u16 BE | variable | ++----------------+----------------------+ +``` + +Maximum payload: + +```text +65535 bytes +``` + +In practice the project SHALL enforce a much lower configurable transport maximum. + +The fallback MUST preserve original datagram boundaries after reassembly. + +TCP mode SHALL be considered a compatibility mechanism rather than a performance-optimal transport because head-of-line blocking is unavoidable. + +--- + +# 15. Automatic Transport Selection + +Default client configuration: + +```toml +[transport] +mode = "auto" +``` + +Automatic mode SHALL attempt transports in the following order: + +```text +direct_udp + ↓ failure +quic + ↓ failure +http3 + ↓ failure +tls_tcp +``` + +The first working transport is selected. + +The selected transport remains active until: + +- the connection fails; +- the daemon is restarted; +- the user explicitly requests reconnection; +- configuration is reloaded requiring reconnection. + +After failure, the client SHALL begin transport probing again. + +--- + +# 16. Probe Behavior + +Default values: + +```toml +[transport.auto] +probe_timeout_ms = 1500 +retries = 2 +``` + +A transport is considered unavailable if connection establishment or authentication fails within the configured limit. + +Authentication rejection MUST be distinguished from connectivity failure. + +A rejected credential MUST NOT cause automatic fallback through every transport indefinitely. + +Instead: + +```text +authentication rejected + ↓ +stop probing + ↓ +report configuration/authentication error +``` + +This prevents invalid credentials from producing unnecessary connection traffic. + +--- + +# 17. Transport Preference Override + +Users MAY explicitly select a transport: + +```toml +[transport] +mode = "quic" +``` + +Valid initial values: + +```text +auto +direct_udp +quic +http3 +tls_tcp +``` + +When a specific transport is configured, automatic fallback SHALL be disabled unless separately enabled in a future version. + +--- + +# 18. Direct UDP Protocol + +Direct UDP requires a small project-specific wire protocol. + +Protocol version: + +```text +1 +``` + +All integer fields SHALL use network byte order. + +All packet parsers MUST reject malformed or truncated messages before allocating large buffers. + +--- + +# 19. Direct UDP Packet Types + +Initial packet types: + +```text +0x01 HELLO +0x02 ACCEPT +0x03 REJECT + +0x10 DATA +0x11 KEEPALIVE +0x12 CLOSE +``` + +Unknown packet types MUST be ignored or rejected according to protocol context. + +They MUST NOT cause daemon termination. + +--- + +# 20. Direct UDP Common Header + +All direct transport messages use: + +```text ++---------+---------+---------+---------+ +| version | type | flags | hdr_len | +| u8 | u8 | u8 | u8 | ++---------+---------+---------+---------+ +| | +| session_id | +| u64 | +| | ++-----------------------------------+ +``` + +Total common header: + +```text +12 bytes +``` + +Fields: + +```text +version + protocol version + +type + packet type + +flags + type-specific flags + +hdr_len + complete header length in bytes + +session_id + zero during initial HELLO + assigned by server after authentication +``` + +A 64-bit session identifier is selected for v1. + +It is sufficiently large when generated randomly and checked for collision. + +--- + +# 21. Session Identifier Generation + +Session IDs MUST: + +- be generated by a cryptographically secure random number generator; +- never use sequential counters; +- never be predictable; +- never use zero; +- be collision-checked against active sessions. + +If a collision occurs, the server generates another identifier. + +Session IDs are identifiers, not secrets. + +Authentication MUST NOT rely solely on knowledge of the session ID. + +--- + +# 22. Client Identity + +Each configured client has: + +```text +client_id +shared_secret +``` + +Example server configuration: + +```toml +[[auth.clients]] +id = "laptop" +secret = "base64-or-hex-secret" + +[[auth.clients]] +id = "server-a" +secret = "base64-or-hex-secret" +``` + +Client IDs are not confidential. + +Secrets MUST have at least 256 bits of entropy. + +A project CLI SHOULD eventually provide: + +```bash +project generate-secret +``` + +--- + +# 23. Direct UDP HELLO + +The HELLO packet contains: + +```text +common header +client_id_length u8 +client_id bytes +timestamp u64 +client_nonce 16 bytes +authenticator 32 bytes +``` + +`session_id` SHALL be zero. + +The authenticator is: + +```text +HMAC-SHA256( + client_secret, + protocol_version + || client_id + || timestamp + || client_nonce +) +``` + +The exact serialization used for the HMAC input MUST be canonical and documented in the protocol implementation. + +--- + +# 24. Replay Protection + +The server MUST protect HELLO authentication against replay. + +A HELLO is accepted only if: + +```text +abs(server_time - client_timestamp) <= allowed_clock_skew +``` + +Default: + +```text +60 seconds +``` + +The server SHALL also retain recently accepted client nonces for the replay window. + +A repeated: + +```text +client_id + client_nonce +``` + +combination MUST be rejected. + +Nonce replay state MAY expire after: + +```text +2 × allowed_clock_skew +``` + +--- + +# 25. Direct UDP ACCEPT + +After successful authentication, the server creates a session and responds: + +```text +type ACCEPT +session_id generated u64 + +server_nonce 16 bytes +idle_timeout u32 seconds +max_datagram u16 +authenticator 32 bytes +``` + +The server authenticator SHALL be: + +```text +HMAC-SHA256( + client_secret, + session_id + || client_nonce + || server_nonce + || idle_timeout + || max_datagram +) +``` + +This proves that the ACCEPT originated from a party possessing the configured secret. + +--- + +# 26. Direct UDP REJECT + +REJECT SHALL contain a small reason code. + +Example codes: + +```text +0x01 unsupported_version +0x02 unknown_client +0x03 authentication_failed +0x04 replay_detected +0x05 server_busy +0x06 rate_limited +``` + +Detailed authentication information MUST NOT be returned. + +For example, an unknown client and an invalid HMAC MAY both be externally represented as: + +```text +authentication_failed +``` + +if reducing information leakage is preferred. + +--- + +# 27. Direct DATA Packets + +DATA packets SHALL use: + +```text +common header +sequence u32 +auth_tag 16 bytes +payload remaining bytes +``` + +Sequence numbers are included. + +They provide: + +- replay protection; +- diagnostics; +- loss measurement; +- duplicate detection; +- future protocol extensibility. + +They MUST NOT be used to reorder packets. + +The transport preserves the natural UDP arrival order. + +--- + +# 28. Direct Session Authentication + +Direct UDP DATA packets require integrity/authentication. + +A per-session key SHALL be derived after the handshake. + +Conceptually: + +```text +session_key = HKDF-SHA256( + client_secret, + client_nonce || server_nonce || session_id +) +``` + +The implementation SHALL use a well-established Rust cryptographic library. + +The project SHALL NOT implement HMAC, HKDF, or MAC primitives manually. + +--- + +# 29. DATA Authentication Tag + +Each DATA packet SHALL carry a truncated authentication tag. + +Recommended: + +```text +HMAC-SHA256 truncated to 128 bits +``` + +The MAC covers: + +```text +version +type +flags +session_id +sequence +payload +``` + +A 128-bit tag provides ample integrity protection while keeping packet overhead manageable. + +Unauthenticated DATA packets MUST be discarded before forwarding. + +--- + +# 30. Direct DATA Overhead + +Approximate direct data overhead: + +```text +common header 12 bytes +sequence 4 bytes +auth tag 16 bytes +-------------------------- +total 32 bytes +``` + +The design favors simple, robust parsing over saving a handful of bytes. + +Future protocol versions MAY reduce overhead if profiling shows a meaningful benefit. + +--- + +# 31. Sequence Numbers + +Each direction SHALL maintain an independent 32-bit sequence counter. + +Example: + +```text +client -> server counter +server -> client counter +``` + +Initial value SHALL be random. + +Sequence numbers increment modulo `2^32`. + +Replay protection SHALL use a sliding receive window. + +Recommended window: + +```text +1024 packets +``` + +Packets may arrive out of order within this window. + +Already-seen sequence numbers MUST be dropped. + +Packets older than the replay window MUST be dropped. + +--- + +# 32. NAT Rebinding + +Direct UDP sessions MUST tolerate client NAT rebinding. + +The server SHALL NOT permanently bind session identity to: + +```text +source IP + source port +``` + +Instead: + +1. receive packet; +2. parse session ID; +3. verify packet authentication; +4. verify sequence/replay protection; +5. if valid, update the session's current peer address. + +Therefore: + +```text +192.0.2.20:43871 +``` + +may become: + +```text +192.0.2.20:51203 +``` + +without renegotiating the session. + +An attacker cannot redirect the session merely by spoofing its session ID because a valid packet MAC is also required. + +--- + +# 33. KEEPALIVE + +Direct transport KEEPALIVE messages SHALL use the same session authentication mechanism as DATA messages. + +They carry: + +```text +common header +sequence +auth_tag +``` + +with no application payload. + +Default interval: + +```text +15 seconds +``` + +Keepalive MAY be disabled. + +Keepalives count as session activity. + +--- + +# 34. CLOSE + +CLOSE SHALL be authenticated. + +The receiver SHOULD immediately release the associated session. + +Loss of a CLOSE packet is harmless because idle timeout remains authoritative. + +--- + +# 35. Session Timeout + +Default idle timeout: + +```text +120 seconds +``` + +A session is active when an authenticated: + +```text +DATA +KEEPALIVE +``` + +packet is received. + +Expired sessions SHALL: + +1. close their backend socket; +2. release memory; +3. remove replay state; +4. increment expiration metrics. + +--- + +# 36. QUIC and HTTP/3 Authentication + +TLS authenticates the server but does not necessarily authenticate the client. + +QUIC and HTTP/3 transports SHALL therefore use the same logical: + +```text +client_id +shared_secret +``` + +credentials as direct mode. + +Authentication occurs once after transport establishment. + +It SHALL use a challenge-response protocol based on HMAC rather than directly sending the shared secret. + +Conceptually: + +```text +server -> random challenge + +client -> client_id + client_nonce + HMAC(secret, challenge || client_nonce || client_id) + +server -> authenticated +``` + +The exact representation MAY use: + +- a QUIC control stream; +- an HTTP request; +- a dedicated initial protocol message. + +Application DATA MUST NOT be accepted before authentication completes. + +--- + +# 37. TLS/TCP Authentication + +TLS/TCP SHALL use the same challenge-response client authentication mechanism. + +This gives all transports one conceptual authentication model: + +```text +server authenticated by TLS where applicable +client authenticated by project shared secret +``` + +--- + +# 38. Future Authentication + +The architecture SHOULD permit later introduction of: + +```text +mTLS +public-key client identity +short-lived credentials +external authentication +``` + +without altering the core datagram API. + +These are not required for v1. + +--- + +# 39. DoS and Resource Protection + +The server MUST assume that its public listeners receive hostile traffic. + +It SHALL implement: + +```text +maximum active sessions +maximum sessions per source IP +handshake timeout +authentication rate limiting +maximum authentication attempts per source +maximum datagram size +idle session timeout +maximum pending handshakes +``` + +Defaults SHOULD be conservative. + +Example: + +```toml +[limits] +max_sessions = 1024 +max_sessions_per_ip = 32 +max_pending_handshakes = 256 +handshake_timeout_ms = 3000 +``` + +Limits MUST be configurable. + +--- + +# 40. Allocation Policy + +Expensive resources MUST NOT be allocated before authentication where avoidable. + +In particular, the server SHOULD NOT open a backend UDP socket until client authentication succeeds. + +Desired order: + +```text +receive handshake + ↓ +cheap validation + ↓ +authentication + ↓ +rate-limit check + ↓ +session allocation + ↓ +backend socket creation +``` + +--- + +# 41. MTU and Datagram Size + +The project SHALL treat transport datagram size as a first-class capability. + +Every active transport session exposes: + +```text +max_datagram_size +``` + +The core SHALL reject application datagrams larger than this value. + +Initial behavior: + +```text +oversized datagram + ↓ +drop + ↓ +increment counter + ↓ +debug/warn log +``` + +The project SHALL NOT implement application-layer fragmentation in v1. + +--- + +# 42. Recommended Initial Datagram Limit + +A conservative default application payload limit SHOULD begin around: + +```text +1200 bytes +``` + +and later be refined using transport/path information. + +Users MAY override this value. + +The default MUST favor reliability over maximum packet size. + +--- + +# 43. Configuration Format + +Configuration SHALL use TOML. + +Parsing stack: + +```text +serde +toml +``` + +Configuration MUST be validated before network listeners start. + +Invalid configuration SHALL result in a clear fatal error. + +--- + +# 44. Minimal Client Configuration + +```toml +[client] +server = "vpn.example.com" +id = "laptop" +secret_file = "/etc/project/client.secret" + +[local] +listen = "127.0.0.1:51821" + +[transport] +mode = "auto" +``` + +The server hostname is static in v1. + +DNS resolution SHALL use the system resolver. + +--- + +# 45. Minimal Server Configuration + +```toml +[server] +listen = "0.0.0.0" + +[backend] +target = "127.0.0.1:51820" + +[tls] +certificate = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem" +private_key = "/etc/letsencrypt/live/vpn.example.com/privkey.pem" + +[[auth.clients]] +id = "laptop" +secret_file = "/etc/project/clients/laptop.secret" +``` + +--- + +# 46. Advanced Client Configuration + +```toml +[client] +server = "vpn.example.com" +id = "laptop" +secret_file = "/etc/project/client.secret" + +[local] +listen = "127.0.0.1:51821" + +[transport] +mode = "auto" + +[transport.auto] +order = [ + "direct_udp", + "quic", + "http3", + "tls_tcp", +] + +probe_timeout_ms = 1500 +retries = 2 + +[transport.direct_udp] +port = 51830 +keepalive_secs = 15 + +[transport.quic] +port = 443 +keepalive_secs = 15 + +[transport.http3] +port = 443 +path = "/tunnel" + +[transport.tls_tcp] +port = 443 + +[network] +max_datagram_size = 1200 + +[logging] +level = "info" +``` + +--- + +# 47. Advanced Server Configuration + +```toml +[server] +listen = "0.0.0.0" + +[backend] +target = "127.0.0.1:51820" + +[transports.direct_udp] +enabled = true +port = 51830 + +[transports.quic] +enabled = true +port = 443 + +[transports.http3] +enabled = true +port = 443 +path = "/tunnel" + +[transports.tls_tcp] +enabled = true +port = 443 + +[tls] +certificate = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem" +private_key = "/etc/letsencrypt/live/vpn.example.com/privkey.pem" + +[session] +idle_timeout_secs = 120 + +[limits] +max_sessions = 1024 +max_sessions_per_ip = 32 +max_pending_handshakes = 256 +handshake_timeout_ms = 3000 + +[[auth.clients]] +id = "laptop" +secret_file = "/etc/project/clients/laptop.secret" + +[[auth.clients]] +id = "desktop" +secret_file = "/etc/project/clients/desktop.secret" + +[logging] +level = "info" +``` + +--- + +# 48. Secret Storage + +Inline secrets MAY be accepted for testing but SHOULD generate a warning. + +Preferred configuration: + +```toml +secret_file = "/path/to/secret" +``` + +Secret files SHOULD require restrictive permissions. + +Recommended: + +```text +0600 +``` + +Secrets MUST NOT appear in logs. + +--- + +# 49. TLS Certificates + +TLS-capable transports SHALL use normal X.509 server certificates. + +Expected provisioning includes: + +```text +Certbot +acme.sh +Caddy +other ACME tooling +``` + +The project does not implement ACME itself in v1. + +Clients SHALL perform normal hostname verification against the configured static hostname. + +Certificate verification MUST NOT be disabled by default. + +--- + +# 50. Certificate Reload + +Automatic certificate reload is desirable but optional for the first prototype. + +The architecture SHOULD allow certificates to be reloaded without rebuilding transport logic. + +--- + +# 51. Session Model + +Core session representation: + +```rust +struct Session { + id: SessionId, + client_id: ClientId, + transport: TransportKind, + backend: UdpSocket, + created_at: Instant, + last_activity: Instant, + stats: SessionStats, +} +``` + +Transport-specific information SHOULD remain outside the generic session where possible. + +--- + +# 52. Datagram Representation + +Initial representation: + +```rust +struct Datagram { + payload: Bytes, +} +``` + +Do not add unnecessary metadata until required. + +Potential future form: + +```rust +struct Datagram { + payload: Bytes, + received_at: Instant, + flow_id: Option, +} +``` + +--- + +# 53. Client State Machine + +```text +STARTING + │ + ▼ +RESOLVING + │ + ▼ +PROBING + │ + ├─ direct_udp + ├─ quic + ├─ http3 + └─ tls_tcp + │ + ▼ +AUTHENTICATING + │ + ▼ +CONNECTED + │ + ├──────── traffic + │ + └──────── failure + │ + ▼ + RECONNECTING + │ + ▼ + PROBING +``` + +A transport failure MUST NOT terminate the daemon. + +--- + +# 54. Backoff + +Repeated connection failures SHALL use exponential backoff with jitter. + +Example bounds: + +```text +minimum 250 ms +maximum 30 s +``` + +Successful connection resets the backoff. + +This avoids aggressive reconnect loops during outages. + +--- + +# 55. Server Session Lifecycle + +```text +HANDSHAKE + │ + ▼ +AUTHENTICATING + │ + ├── reject + │ + ▼ +ACTIVE + │ + ├── client close + ├── transport loss + ├── timeout + └── server shutdown + │ + ▼ + CLOSED +``` + +Resources SHALL be released deterministically. + +--- + +# 56. Keepalive Strategy + +Keepalive behavior is transport-specific. + +```text +direct_udp + authenticated protocol KEEPALIVE + +quic + QUIC keepalive + +http3 + underlying QUIC keepalive + +tls_tcp + transport heartbeat or TCP state +``` + +Keepalive defaults SHOULD preserve common NAT mappings without producing unnecessary traffic. + +--- + +# 57. Observability + +At minimum the client SHALL expose: + +```text +state +server hostname +resolved remote address +selected transport +connection duration +RTT where available +bytes sent +bytes received +datagrams sent +datagrams received +datagrams dropped +oversized datagrams +authentication failures +reconnections +max datagram size +``` + +The server SHALL additionally expose: + +```text +active sessions +sessions by transport +sessions by client ID +handshake attempts +authentication rejections +rate-limited attempts +expired sessions +backend errors +``` + +--- + +# 58. Logging + +Use: + +```text +tracing +tracing-subscriber +``` + +Logs SHOULD contain structured fields such as: + +```text +client_id +session_id +transport +remote_addr +event +``` + +Secrets and application payloads MUST NEVER be logged by default. + +Packet payload logging SHOULD not exist in normal release builds. + +--- + +# 59. Logging Levels + +Suggested semantics: + +```text +ERROR + unrecoverable subsystem failure + +WARN + transport failure + malformed traffic + rejected config behavior + +INFO + startup + successful connection + selected transport + authenticated session + reconnect + shutdown + +DEBUG + probing details + MTU decisions + session lifecycle + +TRACE + low-level protocol state +``` + +--- + +# 60. Local Control Interface + +The daemon SHOULD expose a Unix domain control socket. + +Example: + +```text +$XDG_RUNTIME_DIR/project.sock +``` + +The control protocol MAY initially use newline-delimited JSON. + +Expected commands: + +```text +status +stats +reconnect +reload +shutdown +``` + +The control socket MUST use filesystem permissions to prevent unauthorized local control. + +--- + +# 61. CLI + +Initial commands: + +```bash +project client --config FILE +project server --config FILE + +project check-config --config FILE +project generate-secret +``` + +Later: + +```bash +project status +project stats +project reconnect +project reload +``` + +--- + +# 62. Graceful Shutdown + +On SIGTERM or SIGINT: + +1. stop accepting new sessions; +2. notify transports where appropriate; +3. stop local ingestion; +4. close backend sockets; +5. flush useful final metrics/logs; +6. terminate cleanly. + +Shutdown MUST have a bounded timeout. + +--- + +# 63. Configuration Reload + +Full hot reload is not mandatory for the first milestone. + +Architecture SHOULD nonetheless separate: + +```text +parsed configuration +runtime state +``` + +A future reload MUST be capable of updating: + +- clients; +- limits; +- log level; +- certificate paths; + +without requiring architectural redesign. + +--- + +# 64. Future Multiple Tunnel Support + +V1 exposes one client tunnel. + +Internally, configuration SHOULD already use: + +```rust +struct TunnelConfig +``` + +rather than embedding tunnel fields directly everywhere. + +Initial: + +```rust +struct ClientConfig { + tunnel: TunnelConfig, +} +``` + +Future: + +```rust +struct ClientConfig { + tunnels: Vec, +} +``` + +Future TOML: + +```toml +[[tunnels]] +name = "home" + +[[tunnels]] +name = "work" +``` + +No v1 UI or runtime behavior is required for multiple tunnels. + +--- + +# 65. Rust Technical Stack + +Recommended language: + +```text +Rust +``` + +Recommended runtime: + +```text +Tokio +``` + +Recommended major libraries: + +```text +tokio +quinn +rustls +h3 +h3-quinn +bytes +serde +toml +clap +tracing +tracing-subscriber +thiserror +anyhow +socket2 +hmac +sha2 +hkdf +rand +subtle +``` + +Potential optional libraries: + +```text +secrecy +zeroize +``` + +Exact versions SHALL be selected at implementation time. + +--- + +# 66. Error Handling + +Library/core modules SHOULD use typed errors with: + +```text +thiserror +``` + +Binary/CLI boundaries MAY use: + +```text +anyhow +``` + +Recoverable network failure MUST NOT be represented as an unrecoverable process error. + +--- + +# 67. Constant-Time Authentication + +Authentication tag comparison MUST use constant-time equality. + +Do not compare authentication data with ordinary: + +```rust +a == b +``` + +unless the cryptographic library explicitly guarantees appropriate behavior. + +Use primitives supplied by the selected crypto implementation or a constant-time helper library. + +--- + +# 68. Crypto Policy + +The project SHALL NOT: + +- invent encryption algorithms; +- implement SHA manually; +- implement HMAC manually; +- implement HKDF manually; +- implement TLS manually. + +All cryptographic operations SHALL use maintained, reviewed Rust libraries. + +The direct transport payload itself does not require encryption because the encapsulated application may already be encrypted. + +Direct transport DOES require integrity and authentication. + +Users requiring outer confidentiality SHALL use QUIC, HTTP/3, or TLS/TCP. + +--- + +# 69. Core Module Structure + +Initial source tree SHOULD remain simple: + +```text +src/ +├── main.rs +├── cli.rs +├── config.rs +├── datagram.rs +├── session.rs +├── stats.rs +├── client/ +│ ├── mod.rs +│ ├── listener.rs +│ └── supervisor.rs +├── server/ +│ ├── mod.rs +│ ├── session.rs +│ ├── backend.rs +│ └── limits.rs +├── protocol/ +│ ├── mod.rs +│ ├── direct.rs +│ ├── auth.rs +│ └── replay.rs +└── transport/ + ├── mod.rs + ├── direct_udp.rs + ├── quic.rs + ├── http3.rs + └── tls_tcp.rs +``` + +A Cargo workspace SHOULD NOT be introduced until module boundaries have stabilized. + +--- + +# 70. Core Dependency Direction + +Desired dependency structure: + +```text + configuration + │ + ▼ + core + ┌────────┴────────┐ + │ │ + client server + │ │ + └──── transport ──┘ + │ + ┌─────────┼──────────┐ + ▼ ▼ ▼ + UDP QUIC HTTP/3 + │ + etc. +``` + +The core MUST NOT contain scattered transport type checks. + +Avoid: + +```rust +if transport == Transport::Quic { + ... +} +``` + +unless behavior genuinely belongs at the orchestration layer. + +--- + +# 71. Performance Model + +The fast path SHOULD minimize allocations. + +Preferred packet representation: + +```rust +Bytes +BytesMut +``` + +Buffers SHOULD be reused where practical. + +The implementation SHOULD avoid: + +- copying payloads unnecessarily; +- global mutexes on every packet; +- per-packet heap allocation where easily avoidable; +- synchronous filesystem operations in packet paths. + +Correctness takes priority over premature micro-optimization. + +--- + +# 72. Concurrency Model + +Tokio tasks SHALL be used around logical session operations. + +A server session will approximately contain: + +```text +transport receive ─────► backend UDP send +transport send ◄─────── backend UDP receive +``` + +These may be implemented using: + +- paired tasks; +- `tokio::select!`; +- channels; +- a session supervisor. + +The simplest implementation that preserves cancellation and ownership clarity SHOULD be preferred. + +--- + +# 73. Cancellation + +All long-lived tasks MUST support deterministic cancellation. + +Recommended primitives include: + +```text +CancellationToken +broadcast/watch channels +task ownership through supervisors +``` + +Detached tasks that cannot be shut down SHOULD be avoided. + +--- + +# 74. Server Multi-Client Requirements + +The server MUST: + +- accept concurrent clients; +- isolate session errors; +- prevent one client from crashing another session; +- enforce per-client limits; +- maintain independent backend sockets; +- clean up dead sessions; +- maintain aggregate and per-session stats. + +One malformed client packet MUST NOT terminate a listener. + +--- + +# 75. Direct UDP Listener Model + +The direct listener may use one public UDP socket. + +Incoming packets are dispatched by: + +```text +packet type +session ID +``` + +Initial HELLO packets use: + +```text +session_id = 0 +``` + +Authenticated DATA packets identify their session through the assigned random ID. + +--- + +# 76. QUIC Listener Model + +One QUIC endpoint accepts many connections. + +Each authenticated QUIC connection becomes one server session. + +QUIC connection state acts as transport identity. + +Application packets arrive through QUIC DATAGRAM frames. + +--- + +# 77. HTTP/3 Listener Model + +HTTP/3 operates over the QUIC listener architecture. + +Each successful authenticated CONNECT-UDP exchange maps to one backend session. + +Where feasible, QUIC and HTTP/3 MAY share certificate/configuration infrastructure. + +--- + +# 78. TCP Listener Model + +Each accepted TLS/TCP connection becomes one potential session. + +No backend socket is created until project authentication succeeds. + +After authentication, framed datagrams are transported bidirectionally. + +--- + +# 79. Protocol Versioning + +Direct wire protocol packets contain: + +```text +version = 1 +``` + +Breaking protocol changes SHALL increment the version. + +Unknown versions MUST result in clean rejection. + +Configuration schema and project binary version are independent from transport wire protocol version. + +--- + +# 80. Metrics and Benchmarking + +The project SHOULD eventually include benchmarks for: + +```text +datagrams/sec +throughput +latency overhead +memory/session +CPU usage +direct UDP overhead +QUIC overhead +HTTP/3 overhead +TCP fallback overhead +``` + +Performance tests SHOULD compare the tunnel against direct UDP baseline. + +--- + +# 81. Testing Strategy + +Unit tests SHALL cover: + +```text +packet serialization +packet parsing +malformed packets +HMAC verification +session key derivation +sequence windows +replay detection +configuration validation +timeout logic +``` + +Integration tests SHALL cover: + +```text +client ↔ server direct UDP +multiple simultaneous clients +NAT-like source port change +authentication rejection +server restart +client reconnect +backend unavailable +oversized datagram +session expiration +``` + +Later tests SHALL cover each transport. + +--- + +# 82. Fuzzing + +The direct UDP protocol parser is a good fuzzing target. + +The parser SHOULD be written so arbitrary byte strings can be fed into it independently of network state. + +Fuzz targets SHOULD include: + +```text +direct packet parser +TCP frame parser +authentication message parser +configuration parser where appropriate +``` + +--- + +# 83. Development Milestone 1 + +Goal: + +```text +generic authenticated UDP relay +``` + +Implement only: + +```text +client local UDP listener +direct UDP transport +server listener +authentication handshake +session creation +backend UDP sockets +DATA packet authentication +replay protection +keepalive +session expiry +multiple simultaneous clients +TOML configuration +structured logs +basic statistics +``` + +No QUIC yet. + +This validates the entire core architecture. + +--- + +# 84. Development Milestone 2 + +Add QUIC. + +Requirements: + +```text +TLS certificate loading +hostname validation +QUIC connection establishment +project client authentication +QUIC DATAGRAM send/receive +keepalive +MTU reporting +reconnect +multi-client server handling +``` + +No core datagram API redesign should be necessary. + +If substantial core redesign is required, the transport abstraction should be reconsidered before continuing. + +--- + +# 85. Development Milestone 3 + +Implement: + +```text +transport = "auto" +``` + +Automatic order: + +```text +direct_udp +quic +``` + +Include: + +- probing; +- timeouts; +- retries; +- exponential reconnect backoff; +- status reporting. + +--- + +# 86. Development Milestone 4 + +Implement: + +```text +HTTP/3 CONNECT-UDP +``` + +using the existing QUIC infrastructure where practical. + +Add: + +```text +HTTP/3 listener +CONNECT request handling +authentication +HTTP datagrams +backend session mapping +``` + +--- + +# 87. Development Milestone 5 + +Implement TLS/TCP fallback. + +Add: + +```text +TLS listener +framed packet parser +project authentication +bidirectional datagram reconstruction +automatic fallback integration +``` + +--- + +# 88. Development Milestone 6 + +Operational polish: + +```text +Unix control socket +status command +stats command +reconnect command +config validation command +systemd units +CAP_NET_BIND_SERVICE documentation +certificate reload +configuration reload +benchmarks +fuzzing +installation documentation +``` + +--- + +# 89. Definition of v1 Completion + +Version 1 is considered complete when: + +1. multiple clients can 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 includes replay and integrity protection; +10. NAT rebinding works for direct UDP; +11. sessions expire and clean up correctly; +12. the server survives malformed and unauthenticated traffic; +13. the daemon runs unprivileged except for optional privileged-port capability; +14. TOML configuration is validated clearly; +15. meaningful status and statistics are available; +16. reconnects occur automatically; +17. automated integration tests cover multi-client operation. + +--- + +# 90. Explicit Non-Goals for v1 + +The following SHALL remain out of scope: + +```text +WireGuard configuration +TUN/TAP interfaces +routing management +firewall management +multi-hop routing +browser fingerprint imitation +traffic shaping for censorship evasion +packet-size mimicry +TLS fingerprint spoofing +dynamic server discovery +server clustering +automatic roaming between servers +GUI +Windows +macOS +multiple simultaneous client tunnels +application-layer fragmentation +custom encryption +``` + +These MAY be explored after the core transport system is mature. + +--- + +# 91. Architectural Summary + +The system can be summarized as: + +```text + CLIENT + +Application + │ + │ UDP + ▼ +Local listener + │ + ▼ +Tunnel/session core + │ + ▼ +Transport abstraction + │ + ├── Direct UDP + ├── QUIC DATAGRAM + ├── HTTP/3 CONNECT-UDP + └── TLS/TCP + │ +════════════════ Internet ════════════════ + │ + ▼ +Transport listener + │ + ▼ +Authenticated server session + │ + ▼ +Dedicated backend UDP socket + │ + ▼ +Configured UDP backend +``` + +The core sees only: + +```text +send datagram +receive datagram +session opened +session closed +transport capabilities +``` + +It does not care whether the external representation is: + +```text +custom direct UDP packet +QUIC connection +HTTP/3 CONNECT-UDP flow +TLS/TCP stream +``` + +--- + +# 92. Final Design Rule + +The project SHALL always preserve this separation: + +```text +application protocol + ≠ +tunnel session + ≠ +transport + ≠ +authentication +``` + +The encapsulated application remains opaque. + +Sessions provide logical client isolation. + +Transports determine how datagrams cross the network. + +Authentication determines who may create sessions. + +Keeping those four responsibilities independent is the central architectural rule of the project. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..22f37a8 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "wgcloak", + version, + about = "Authenticated protocol-agnostic UDP transport" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Run the client role. + Client { + /// Path to the client TOML configuration. + #[arg(short, long, value_name = "FILE")] + config: PathBuf, + }, + /// Run the server role. + Server { + /// Path to the server TOML configuration. + #[arg(short, long, value_name = "FILE")] + config: PathBuf, + }, + /// Parse and validate a client or server TOML configuration. + CheckConfig { + /// Path to the TOML configuration. + #[arg(short, long, value_name = "FILE")] + config: PathBuf, + }, + /// Generate a cryptographically random 256-bit secret as base64. + GenerateSecret, + /// Query a running daemon's control socket. + Status { + #[arg(short, long)] + socket: Option, + }, + /// Query counters from a running daemon. + Stats { + #[arg(short, long)] + socket: Option, + }, + /// Request transport reconnection. + Reconnect { + #[arg(short, long)] + socket: Option, + }, + /// Request configuration reload. + Reload { + #[arg(short, long)] + socket: Option, + }, + /// Request graceful shutdown. + Shutdown { + #[arg(short, long)] + socket: Option, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn parses_runtime_commands_and_config_paths() { + let cli = Cli::try_parse_from(["wgcloak", "client", "--config", "client.toml"]).unwrap(); + assert!( + matches!(cli.command, Command::Client { config } if config == PathBuf::from("client.toml")) + ); + + let cli = Cli::try_parse_from(["wgcloak", "check-config", "-c", "server.toml"]).unwrap(); + assert!( + matches!(cli.command, Command::CheckConfig { config } if config == PathBuf::from("server.toml")) + ); + } + + #[test] + fn parses_generate_secret_without_arguments() { + assert!(matches!( + Cli::try_parse_from(["wgcloak", "generate-secret"]) + .unwrap() + .command, + Command::GenerateSecret + )); + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..bf9e4e8 --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,143 @@ +use std::time::Duration; + +use anyhow::Result; +use bytes::Bytes; +use tokio::{net::UdpSocket, time}; +use tracing::{debug, info, warn}; + +use crate::{ + config::{ClientConfig, TransportMode}, + control::{ControlServer, ControlState}, + transport::{ + quic::QuicClientConnector, DirectUdpConnector, TlsTcpConnector, TransportConnector, + TransportError, TransportSession, + }, +}; + +pub mod supervisor; + +pub async fn run(config: ClientConfig) -> Result<()> { + let local = UdpSocket::bind(config.local.listen).await?; + info!(local_addr=%local.local_addr()?, "client local UDP listener started"); + let control_state = ControlState::default(); + let control = + ControlServer::bind(crate::control::default_socket_path(), control_state.clone()).await?; + tokio::spawn(async move { + let _ = control.run().await; + }); + let mut latest_peer = None; + let mut backoff = Duration::from_millis(250); + let signal = shutdown_signal(); + tokio::pin!(signal); + loop { + let connected = tokio::select! { + _ = &mut signal => return Ok(()), + _ = control_state.shutdown.cancelled() => return Ok(()), + result = connect_for_mode(&config) => result, + }; + let (session, mode) = match connected { + Ok(value) => { + backoff = Duration::from_millis(250); + value + } + Err(TransportError::AuthenticationRejected(reason)) => { + warn!(?reason, "server rejected client authentication"); + return Err(anyhow::anyhow!(TransportError::AuthenticationRejected( + reason + ))); + } + Err(error @ TransportError::Unsupported(_)) => return Err(error.into()), + Err(error) => { + warn!(error=%error, retry_in_ms=backoff.as_millis(), "configured transports unavailable"); + tokio::select! { _ = &mut signal => return Ok(()), _ = control_state.shutdown.cancelled() => return Ok(()), _ = time::sleep(backoff) => {} } + backoff = (backoff * 2).min(Duration::from_secs(30)); + continue; + } + }; + info!(?mode, "client transport connected"); + let outcome = + run_connected(&local, session.as_ref(), &mut latest_peer, &control_state).await; + let _ = session.close().await; + if outcome { + return Ok(()); + } + warn!(?mode, "transport session lost; reconnecting"); + } +} + +async fn connect_for_mode( + config: &ClientConfig, +) -> Result<(Box, TransportMode), TransportError> { + let modes = match config.transport.mode { + TransportMode::Auto => config.transport.auto.order.clone(), + mode => vec![mode], + }; + let mut last = TransportError::Unsupported("no transport configured".into()); + for mode in modes { + let result = match mode { + TransportMode::DirectUdp => DirectUdpConnector::new(config.clone()).connect().await, + TransportMode::Quic => QuicClientConnector::new(config.clone()).connect().await, + TransportMode::TlsTcp => TlsTcpConnector::new(config.clone()).connect().await, + TransportMode::Http3 => Err(TransportError::Unsupported( + "HTTP/3 CONNECT-UDP adapter requires h3-quinn datagram integration".into(), + )), + TransportMode::Auto => Err(TransportError::Unsupported( + "nested auto transport mode".into(), + )), + }; + match result { + Ok(session) => return Ok((session, mode)), + Err(TransportError::AuthenticationRejected(reason)) => { + return Err(TransportError::AuthenticationRejected(reason)) + } + Err(error) => { + debug!(?mode, error=%error, "transport probe failed"); + last = error; + } + } + } + Err(last) +} + +async fn run_connected( + local: &UdpSocket, + session: &dyn TransportSession, + latest_peer: &mut Option, + control: &ControlState, +) -> bool { + let mut local_buf = vec![0u8; session.max_datagram_size().saturating_add(1)]; + let signal = shutdown_signal(); + tokio::pin!(signal); + loop { + tokio::select! { + _ = &mut signal => return true, + _ = control.shutdown.cancelled() => return true, + _ = control.reconnect.notified() => return false, + _ = control.reload.notified() => return false, + received = local.recv_from(&mut local_buf) => { + let (len, peer) = match received { Ok(v) => v, Err(_) => return false }; + if latest_peer.map(|old| old != peer).unwrap_or(false) { debug!(old=?latest_peer, new_addr=%peer, "local UDP peer changed; using newest peer"); } + *latest_peer = Some(peer); + if len > session.max_datagram_size() { warn!(len, maximum=session.max_datagram_size(), "dropping oversized local datagram"); continue; } + if session.send_datagram(Bytes::copy_from_slice(&local_buf[..len])).await.is_err() { return false; } + } + received = session.recv_datagram() => { + let payload = match received { Ok(v) => v, Err(_) => return false }; + if let Some(peer) = *latest_peer { if local.send_to(&payload, peer).await.is_err() { return false; } } + } + } + } +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/src/client/supervisor.rs b/src/client/supervisor.rs new file mode 100644 index 0000000..ef035df --- /dev/null +++ b/src/client/supervisor.rs @@ -0,0 +1,257 @@ +//! Generic client transport selection and reconnection policy. + +use crate::{ + config::{ClientConfig, TransportMode}, + transport::{TransportConnector, TransportError, TransportSession}, +}; +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; +use tokio::{ + sync::{Mutex, Notify}, + time::{self, timeout}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +pub type ProbeFuture = + Pin, TransportError>> + Send>>; +/// A probe-owned lifecycle watcher. It must report only session lifecycle +/// events; it must not read application datagrams from the session. +pub type SessionWatchFuture<'a> = + Pin> + Send + '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`. + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> SessionWatchFuture<'a> { + Box::pin(std::future::pending()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SupervisorState { + Starting, + Probing, + Connected, + Reconnecting, + AuthenticationRejected, + Stopped, +} +#[derive(Clone, Debug)] +pub struct SupervisorStatus { + pub state: SupervisorState, + pub selected_transport: Option, + pub attempts: u64, + pub reconnections: u64, + pub backoff: Duration, + pub last_error: Option, +} +impl Default for SupervisorStatus { + fn default() -> Self { + Self { + state: SupervisorState::Starting, + selected_transport: None, + attempts: 0, + reconnections: 0, + backoff: Duration::from_millis(250), + last_error: None, + } + } +} + +pub struct TransportSupervisor

{ + config: ClientConfig, + probe: Arc

, + status: Arc>, + reconnect: Arc, + shutdown: CancellationToken, + backoff_step: u32, +} +impl TransportSupervisor

{ + pub fn new(config: ClientConfig, probe: P) -> Self { + Self { + config, + probe: Arc::new(probe), + status: Arc::new(Mutex::new(SupervisorStatus::default())), + reconnect: Arc::new(Notify::new()), + shutdown: CancellationToken::new(), + backoff_step: 0, + } + } + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown.clone() + } + pub fn reconnect_handle(&self) -> Arc { + self.reconnect.clone() + } + pub async fn status(&self) -> SupervisorStatus { + self.status.lock().await.clone() + } + pub fn request_reconnect(&self) { + self.reconnect.notify_one(); + } + pub fn request_shutdown(&self) { + self.shutdown.cancel(); + } + fn modes(&self) -> Vec { + match self.config.transport.mode { + TransportMode::Auto => self.config.transport.auto.order.clone(), + mode => vec![mode], + } + } + + pub async fn connect_once(&self) -> Result, TransportError> { + self.status.lock().await.state = SupervisorState::Probing; + let deadline = Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)); + let attempts = self.config.transport.auto.retries.max(1); + let mut last = None; + for mode in self.modes() { + for attempt in 0..attempts { + self.status.lock().await.attempts += 1; + debug!(?mode, attempt, "probing transport"); + match timeout(deadline, self.probe.connect(mode)).await { + Ok(Ok(session)) => { + let mut s = self.status.lock().await; + s.state = SupervisorState::Connected; + s.selected_transport = Some(mode); + s.last_error = None; + return Ok(session); + } + Ok(Err(error @ TransportError::AuthenticationRejected(_))) => { + let mut s = self.status.lock().await; + s.state = SupervisorState::AuthenticationRejected; + s.last_error = Some(error.to_string()); + return Err(error); + } + Ok(Err(error)) => last = Some(error), + Err(_) => last = Some(TransportError::HandshakeTimeout), + } + } + } + let error = last.unwrap_or(TransportError::HandshakeTimeout); + self.status.lock().await.last_error = Some(error.to_string()); + Err(error) + } + pub fn backoff_delay(&mut self) -> Duration { + let n = self.backoff_step.min(17); + let base = 250u64.saturating_mul(1u64 << n).min(30_000); + let jitter = if base <= 250 { + 0 + } else { + rand::random::() % (base / 4).max(1) + }; + self.backoff_step = self.backoff_step.saturating_add(1); + Duration::from_millis(base.saturating_sub(jitter).clamp(250, 30_000)) + } + pub fn reset_backoff(&mut self) { + self.backoff_step = 0; + } + pub async fn run(mut self) -> Result<(), TransportError> { + loop { + if self.shutdown.is_cancelled() { + self.status.lock().await.state = SupervisorState::Stopped; + return Ok(()); + } + match self.connect_once().await { + Ok(session) => { + self.reset_backoff(); + self.status.lock().await.backoff = Duration::from_millis(250); + tokio::select! { + _ = self.shutdown.cancelled() => { + let _ = session.close().await; + self.status.lock().await.state = SupervisorState::Stopped; + return Ok(()); + }, + _ = self.reconnect.notified() => { + let _ = session.close().await; + let mut s = self.status.lock().await; + s.state = SupervisorState::Reconnecting; + s.reconnections += 1; + }, + result = self.probe.wait_for_session_failure(session.as_ref()) => { + let _ = session.close().await; + let mut s = self.status.lock().await; + s.state = SupervisorState::Reconnecting; + s.reconnections += 1; + s.last_error = result.as_ref().err().map(ToString::to_string); + if let Err(error @ TransportError::AuthenticationRejected(_)) = result { + return Err(error); + } + drop(s); + let delay = self.backoff_delay(); + self.status.lock().await.backoff = delay; + tokio::select! { + _ = self.shutdown.cancelled() => { + self.status.lock().await.state = SupervisorState::Stopped; + return Ok(()); + }, + _ = self.reconnect.notified() => {}, + _ = time::sleep(delay) => {}, + } + } + } + } + Err(error @ TransportError::AuthenticationRejected(_)) => return Err(error), + Err(error) => { + warn!(error=%error, "all configured transports unavailable"); + let delay = self.backoff_delay(); + { + let mut s = self.status.lock().await; + s.state = SupervisorState::Reconnecting; + s.backoff = delay; + } + tokio::select! { _ = self.shutdown.cancelled() => { self.status.lock().await.state = SupervisorState::Stopped; return Ok(()); }, _ = self.reconnect.notified() => {}, _ = time::sleep(delay) => {} } + } + } + } + } +} + +pub struct SingleConnector { + connector: Arc, +} +impl SingleConnector { + pub fn new(connector: C) -> Self { + Self { + connector: Arc::new(connector), + } + } +} +impl TransportProbe for SingleConnector { + fn connect(&self, _mode: TransportMode) -> ProbeFuture { + let connector = self.connector.clone(); + Box::pin(async move { connector.connect().await }) + } +} + +/// Milestone 1 adapter. Unsupported carriers fail as connectivity errors, so +/// an automatic supervisor may continue with the next configured mode. +pub struct DirectUdpProbe { + config: ClientConfig, +} +impl DirectUdpProbe { + pub fn new(config: ClientConfig) -> Self { + Self { config } + } +} +impl TransportProbe for DirectUdpProbe { + fn connect(&self, mode: TransportMode) -> ProbeFuture { + let config = self.config.clone(); + Box::pin(async move { + if mode != TransportMode::DirectUdp { + return Err(TransportError::Io(std::io::Error::new( + std::io::ErrorKind::Unsupported, + format!("transport {mode:?} is not available"), + ))); + } + crate::transport::DirectUdpConnector::new(config) + .connect() + .await + }) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..96a9de0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,826 @@ +use std::{ + collections::HashSet, + fs, + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use rand::{rngs::OsRng, RngCore}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{de::Deserializer, Deserialize}; +use thiserror::Error; +use tracing::{info, warn}; + +const DEFAULT_MAX_DATAGRAM_SIZE: u16 = 1200; +const MIN_SECRET_BYTES: usize = 32; + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("could not read configuration {path}: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + #[error("could not parse TOML configuration: {0}")] + Parse(#[from] toml::de::Error), + #[error("configuration is neither a client nor a server configuration")] + UnknownRole, + #[error("invalid configuration: {0}")] + Invalid(String), + #[error("could not read secret file {path}: {source}")] + SecretRead { + path: PathBuf, + source: std::io::Error, + }, + #[error("secret file {path} must not be readable by group or other users (mode {mode:o})")] + SecretPermissions { path: PathBuf, mode: u32 }, + #[error("secret for {label} must contain at least 256 bits of entropy")] + SecretTooShort { label: String }, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum Config { + Client(ClientConfig), + Server(ServerConfig), +} + +pub enum RoleConfig { + Client(ClientConfig), + Server(ServerConfig), +} + +impl Config { + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let text = + fs::read_to_string(&path).map_err(|source| ConfigError::Read { path, source })?; + Self::from_str(&text) + } + + #[allow(clippy::should_implement_trait)] + pub fn from_str(text: &str) -> Result { + let value: toml::Value = toml::from_str(text)?; + let has_client = value.get("client").is_some(); + let has_server = value.get("server").is_some(); + match (has_client, has_server) { + (true, false) => Ok(Self::Client(value.try_into()?)), + (false, true) => Ok(Self::Server(value.try_into()?)), + _ => Err(ConfigError::UnknownRole), + } + } + + pub fn validate(&self) -> Result<(), ConfigError> { + match self { + Self::Client(config) => config.validate(), + Self::Server(config) => config.validate(), + } + } + + pub fn role_name(&self) -> &'static str { + match self { + Self::Client(_) => "client", + Self::Server(_) => "server", + } + } + + pub fn into_client(self) -> Result { + match self { + Self::Client(config) => Ok(config), + Self::Server(_) => Err("configuration is for server, not client"), + } + } + + pub fn into_server(self) -> Result { + match self { + Self::Server(config) => Ok(config), + Self::Client(_) => Err("configuration is for client, not server"), + } + } +} + +impl From for Config { + fn from(value: ClientConfig) -> Self { + Self::Client(value) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientConfig { + pub client: ClientIdentityConfig, + #[serde(default)] + pub local: LocalConfig, + #[serde(default)] + pub transport: ClientTransportConfig, + #[serde(default)] + pub network: NetworkConfig, + #[serde(default)] + pub logging: LoggingConfig, +} + +impl ClientConfig { + pub fn validate(&self) -> Result<(), ConfigError> { + if self.client.server.trim().is_empty() { + return Err(ConfigError::Invalid( + "client.server must not be empty".into(), + )); + } + if self.client.id.trim().is_empty() { + return Err(ConfigError::Invalid("client.id must not be empty".into())); + } + validate_secret_source(&self.client.secret, "client")?; + validate_max_datagram(self.network.max_datagram_size)?; + self.transport.validate() + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientIdentityConfig { + pub server: String, + pub id: String, + #[serde(flatten)] + pub secret: SecretSource, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LocalConfig { + #[serde( + default = "default_local_listen", + deserialize_with = "deserialize_socket_addr" + )] + pub listen: SocketAddr, +} + +impl Default for LocalConfig { + fn default() -> Self { + Self { + listen: default_local_listen(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientTransportConfig { + #[serde(default)] + pub mode: TransportMode, + #[serde(default)] + pub auto: AutoTransportConfig, + #[serde(default)] + pub direct_udp: ClientDirectUdpConfig, + #[serde(default)] + pub quic: ClientQuicConfig, + #[serde(default)] + pub http3: ClientHttp3Config, + #[serde(default)] + pub tls_tcp: ClientTlsTcpConfig, +} + +impl Default for ClientTransportConfig { + fn default() -> Self { + Self { + mode: TransportMode::Auto, + auto: AutoTransportConfig::default(), + direct_udp: ClientDirectUdpConfig::default(), + quic: ClientQuicConfig::default(), + http3: ClientHttp3Config::default(), + tls_tcp: ClientTlsTcpConfig::default(), + } + } +} + +impl ClientTransportConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.auto.order.is_empty() { + return Err(ConfigError::Invalid( + "transport.auto.order must not be empty".into(), + )); + } + if self.auto.probe_timeout_ms == 0 { + return Err(ConfigError::Invalid( + "transport.auto.probe_timeout_ms must be > 0".into(), + )); + } + if self.auto.retries == 0 { + return Err(ConfigError::Invalid( + "transport.auto.retries must be > 0".into(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AutoTransportConfig { + #[serde(default = "default_transport_order")] + pub order: Vec, + #[serde(default = "default_probe_timeout_ms")] + pub probe_timeout_ms: u64, + #[serde(default = "default_retries")] + pub retries: u8, +} + +impl Default for AutoTransportConfig { + fn default() -> Self { + Self { + order: default_transport_order(), + probe_timeout_ms: default_probe_timeout_ms(), + retries: default_retries(), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum TransportMode { + #[default] + Auto, + DirectUdp, + Quic, + Http3, + TlsTcp, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientDirectUdpConfig { + #[serde(default = "default_direct_port")] + pub port: u16, + #[serde(default = "default_keepalive")] + pub keepalive_secs: u64, +} +impl Default for ClientDirectUdpConfig { + fn default() -> Self { + Self { + port: default_direct_port(), + keepalive_secs: default_keepalive(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientQuicConfig { + #[serde(default = "default_tls_port")] + pub port: u16, + #[serde(default = "default_keepalive")] + pub keepalive_secs: u64, +} +impl Default for ClientQuicConfig { + fn default() -> Self { + Self { + port: default_tls_port(), + keepalive_secs: default_keepalive(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientHttp3Config { + #[serde(default = "default_tls_port")] + pub port: u16, + #[serde(default = "default_http3_path")] + pub path: String, +} +impl Default for ClientHttp3Config { + fn default() -> Self { + Self { + port: default_tls_port(), + path: default_http3_path(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ClientTlsTcpConfig { + #[serde(default = "default_tls_port")] + pub port: u16, +} +impl Default for ClientTlsTcpConfig { + fn default() -> Self { + Self { + port: default_tls_port(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct NetworkConfig { + #[serde(default = "default_max_datagram_size")] + pub max_datagram_size: u16, +} +impl Default for NetworkConfig { + fn default() -> Self { + Self { + max_datagram_size: default_max_datagram_size(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ServerConfig { + pub server: ServerListenConfig, + pub backend: BackendConfig, + #[serde(default)] + pub transports: ServerTransportsConfig, + #[serde(default)] + pub tls: TlsConfig, + #[serde(default)] + pub session: SessionConfig, + #[serde(default)] + pub limits: LimitsConfig, + pub auth: AuthConfig, + #[serde(default)] + pub logging: LoggingConfig, +} + +impl ServerConfig { + pub fn validate(&self) -> Result<(), ConfigError> { + if self.server.listen.trim().is_empty() { + return Err(ConfigError::Invalid( + "server.listen must not be empty".into(), + )); + } + self.backend + .target + .parse::() + .map_err(|_| ConfigError::Invalid("backend.target must be a socket address".into()))?; + if self.auth.clients.is_empty() { + return Err(ConfigError::Invalid( + "auth.clients must contain at least one client".into(), + )); + } + if self.session.idle_timeout_secs == 0 { + return Err(ConfigError::Invalid( + "session.idle_timeout_secs must be > 0".into(), + )); + } + if self.limits.max_sessions == 0 + || self.limits.max_sessions_per_ip == 0 + || self.limits.max_pending_handshakes == 0 + { + return Err(ConfigError::Invalid("session limits must be > 0".into())); + } + let mut client_ids = HashSet::with_capacity(self.auth.clients.len()); + for client in &self.auth.clients { + if client.id.trim().is_empty() { + return Err(ConfigError::Invalid( + "auth client id must not be empty".into(), + )); + } + if !client_ids.insert(client.id.as_str()) { + return Err(ConfigError::Invalid(format!( + "duplicate auth client id {:?}", + client.id + ))); + } + validate_secret_source(&client.secret, &format!("auth client {}", client.id))?; + } + self.tls.validate()?; + if (self.transports.quic.enabled + || self.transports.http3.enabled + || self.transports.tls_tcp.enabled) + && self.tls.certificate.is_none() + { + return Err(ConfigError::Invalid( + "tls.certificate and tls.private_key are required when a TLS transport is enabled" + .into(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ServerListenConfig { + pub listen: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct BackendConfig { + pub target: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ServerTransportsConfig { + #[serde(default)] + pub direct_udp: ServerDirectUdpConfig, + #[serde(default)] + pub quic: ServerQuicConfig, + #[serde(default)] + pub http3: ServerHttp3Config, + #[serde(default)] + pub tls_tcp: ServerTlsTcpConfig, +} +#[derive(Clone, Debug, Deserialize)] +pub struct ServerDirectUdpConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_direct_port")] + pub port: u16, +} +impl Default for ServerDirectUdpConfig { + fn default() -> Self { + Self { + enabled: true, + port: default_direct_port(), + } + } +} +#[derive(Clone, Debug, Deserialize)] +pub struct ServerQuicConfig { + #[serde(default = "default_disabled")] + pub enabled: bool, + #[serde(default = "default_tls_port")] + pub port: u16, +} +impl Default for ServerQuicConfig { + fn default() -> Self { + Self { + enabled: false, + port: default_tls_port(), + } + } +} +#[derive(Clone, Debug, Deserialize)] +pub struct ServerHttp3Config { + #[serde(default = "default_disabled")] + pub enabled: bool, + #[serde(default = "default_tls_port")] + pub port: u16, + #[serde(default = "default_http3_path")] + pub path: String, +} +impl Default for ServerHttp3Config { + fn default() -> Self { + Self { + enabled: false, + port: default_tls_port(), + path: default_http3_path(), + } + } +} +#[derive(Clone, Debug, Deserialize)] +pub struct ServerTlsTcpConfig { + #[serde(default = "default_disabled")] + pub enabled: bool, + #[serde(default = "default_tls_port")] + pub port: u16, +} +impl Default for ServerTlsTcpConfig { + fn default() -> Self { + Self { + enabled: false, + port: default_tls_port(), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct TlsConfig { + pub certificate: Option, + pub private_key: Option, +} +impl TlsConfig { + fn validate(&self) -> Result<(), ConfigError> { + if self.certificate.is_some() != self.private_key.is_some() { + return Err(ConfigError::Invalid( + "tls.certificate and tls.private_key must be provided together".into(), + )); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SessionConfig { + #[serde(default = "default_idle_timeout")] + pub idle_timeout_secs: u64, +} +impl Default for SessionConfig { + fn default() -> Self { + Self { + idle_timeout_secs: default_idle_timeout(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LimitsConfig { + #[serde(default = "default_max_sessions")] + pub max_sessions: usize, + #[serde(default = "default_max_sessions_per_ip")] + pub max_sessions_per_ip: usize, + #[serde(default = "default_max_pending_handshakes")] + pub max_pending_handshakes: usize, + #[serde(default = "default_handshake_timeout_ms")] + pub handshake_timeout_ms: u64, +} +impl Default for LimitsConfig { + fn default() -> Self { + Self { + max_sessions: default_max_sessions(), + max_sessions_per_ip: default_max_sessions_per_ip(), + max_pending_handshakes: default_max_pending_handshakes(), + handshake_timeout_ms: default_handshake_timeout_ms(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AuthConfig { + pub clients: Vec, +} +#[derive(Clone, Debug, Deserialize)] +pub struct AuthClientConfig { + pub id: String, + #[serde(flatten)] + pub secret: SecretSource, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct LoggingConfig { + #[serde(default = "default_log_level")] + pub level: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct SecretSource { + pub secret_file: Option, + pub secret: Option, +} + +impl SecretSource { + pub fn load(&self, label: &str) -> Result { + match (&self.secret_file, &self.secret) { + (Some(_), Some(_)) => Err(ConfigError::Invalid(format!( + "{label} must use either secret_file or secret, not both" + ))), + (Some(path), None) => { + let bytes = read_secret_file(path)?; + let value = String::from_utf8(bytes).map_err(|_| { + ConfigError::Invalid(format!("{label} secret file is not UTF-8")) + })?; + let value = value.trim().to_owned(); + ensure_secret_length(&value, label)?; + Ok(SecretString::new(value)) + } + (None, Some(value)) => { + warn!( + label, + "inline secret configured; use secret_file for production" + ); + ensure_secret_length(value, label)?; + Ok(SecretString::new(value.clone())) + } + (None, None) => Err(ConfigError::Invalid(format!( + "{label} requires secret_file or secret" + ))), + } + } + + pub fn load_bytes(&self, label: &str) -> Result, ConfigError> { + let value = self.load(label)?; + decode_secret(value.expose_secret(), label) + } +} + +pub fn generate_secret() -> String { + let mut bytes = [0u8; MIN_SECRET_BYTES]; + OsRng.fill_bytes(&mut bytes); + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +pub fn init_tracing(level: &str) -> anyhow::Result<()> { + let filter = tracing_subscriber::EnvFilter::try_new(level) + .or_else(|_| tracing_subscriber::EnvFilter::try_new("info"))?; + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .try_init() + .ok(); + info!(level, "structured logging initialized"); + Ok(()) +} + +fn validate_secret_source(source: &SecretSource, label: &str) -> Result<(), ConfigError> { + match (&source.secret_file, &source.secret) { + (Some(_), Some(_)) => Err(ConfigError::Invalid(format!( + "{label} must use either secret_file or secret, not both" + ))), + (Some(path), None) => { + let bytes = read_secret_file(path)?; + let value = String::from_utf8(bytes) + .map_err(|_| ConfigError::Invalid(format!("{label} secret file is not UTF-8")))?; + ensure_secret_length(value.trim(), label) + } + (None, Some(value)) => { + warn!( + label, + "inline secret configured; use secret_file for production" + ); + ensure_secret_length(value, label) + } + (None, None) => Err(ConfigError::Invalid(format!( + "{label} requires secret_file or secret" + ))), + } +} + +fn read_secret_file(path: &Path) -> Result, ConfigError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = fs::metadata(path).map_err(|source| ConfigError::SecretRead { + path: path.to_path_buf(), + source, + })?; + let mode = metadata.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + return Err(ConfigError::SecretPermissions { + path: path.to_path_buf(), + mode, + }); + } + } + fs::read(path).map_err(|source| ConfigError::SecretRead { + path: path.to_path_buf(), + source, + }) +} + +fn decode_secret(value: &str, label: &str) -> Result, ConfigError> { + use base64::Engine; + if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(value) { + if bytes.len() >= MIN_SECRET_BYTES { + return Ok(bytes); + } + } + if let Ok(bytes) = hex::decode(value) { + if bytes.len() >= MIN_SECRET_BYTES { + return Ok(bytes); + } + } + if value.len() >= MIN_SECRET_BYTES { + return Ok(value.as_bytes().to_vec()); + } + Err(ConfigError::SecretTooShort { + label: label.to_owned(), + }) +} + +fn ensure_secret_length(value: &str, label: &str) -> Result<(), ConfigError> { + decode_secret(value, label).map(|_| ()) +} + +fn deserialize_socket_addr<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) +} +fn default_local_listen() -> SocketAddr { + "127.0.0.1:51821".parse().expect("valid default") +} +fn default_transport_order() -> Vec { + vec![ + TransportMode::DirectUdp, + TransportMode::Quic, + TransportMode::Http3, + TransportMode::TlsTcp, + ] +} +fn default_probe_timeout_ms() -> u64 { + 1500 +} +fn default_retries() -> u8 { + 2 +} +fn default_direct_port() -> u16 { + 51830 +} +fn default_tls_port() -> u16 { + 443 +} +fn default_keepalive() -> u64 { + 15 +} +fn default_http3_path() -> String { + "/tunnel".to_owned() +} +fn default_max_datagram_size() -> u16 { + DEFAULT_MAX_DATAGRAM_SIZE +} +fn default_enabled() -> bool { + true +} +fn default_disabled() -> bool { + false +} +fn default_idle_timeout() -> u64 { + 120 +} +fn default_max_sessions() -> usize { + 1024 +} +fn default_max_sessions_per_ip() -> usize { + 32 +} +fn default_max_pending_handshakes() -> usize { + 256 +} +fn default_handshake_timeout_ms() -> u64 { + 3000 +} +fn default_log_level() -> String { + "info".to_owned() +} +fn validate_max_datagram(value: u16) -> Result<(), ConfigError> { + if value == 0 { + Err(ConfigError::Invalid( + "network.max_datagram_size must be > 0".into(), + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::fs::PermissionsExt}; + use tempfile::NamedTempFile; + + const SECRET: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + + #[test] + fn client_defaults_and_validation_work() { + let text = format!("[client]\nserver='vpn.example.com'\nid='laptop'\nsecret='{SECRET}'\n"); + let config = Config::from_str(&text).unwrap(); + config.validate().unwrap(); + let client = config.into_client().unwrap(); + assert_eq!(client.local.listen, "127.0.0.1:51821".parse().unwrap()); + assert_eq!(client.network.max_datagram_size, 1200); + assert_eq!(client.transport.mode, TransportMode::Auto); + } + + #[test] + fn malformed_client_is_rejected() { + let config = Config::from_str("[client]\nserver=''\nid='x'\nsecret='short'\n").unwrap(); + assert!(config.validate().is_err()); + } + + #[test] + fn server_example_parses() { + let text = format!("[server]\nlisten='0.0.0.0'\n[backend]\ntarget='127.0.0.1:51820'\n[auth]\n[[auth.clients]]\nid='laptop'\nsecret='{SECRET}'\n"); + let config = Config::from_str(&text).unwrap(); + config.validate().unwrap(); + assert_eq!(config.role_name(), "server"); + } + + #[test] + fn duplicate_auth_client_ids_are_rejected() { + let text = format!( + "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='same'\nsecret='{SECRET}'\n[[auth.clients]]\nid='same'\nsecret='{SECRET}'\n" + ); + let config = Config::from_str(&text).unwrap(); + assert!(matches!( + config.validate(), + Err(ConfigError::Invalid(message)) if message.contains("duplicate") + )); + } + + #[test] + fn direct_only_server_does_not_require_tls_identity() { + let text = format!( + "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='one'\nsecret='{SECRET}'\n" + ); + Config::from_str(&text).unwrap().validate().unwrap(); + } + + #[test] + fn enabled_tls_transport_requires_certificate_and_key() { + let text = format!( + "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[transports.quic]\nenabled=true\n[[auth.clients]]\nid='one'\nsecret='{SECRET}'\n" + ); + let config = Config::from_str(&text).unwrap(); + assert!(matches!( + config.validate(), + Err(ConfigError::Invalid(message)) if message.contains("tls.certificate") + )); + } + + #[test] + fn secret_file_must_be_private() { + let file = NamedTempFile::new().unwrap(); + fs::write(file.path(), SECRET).unwrap(); + fs::set_permissions(file.path(), fs::Permissions::from_mode(0o644)).unwrap(); + let source = SecretSource { + secret_file: Some(file.path().into()), + secret: None, + }; + assert!(matches!( + source.load("test"), + Err(ConfigError::SecretPermissions { .. }) + )); + } +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..d7fcf05 --- /dev/null +++ b/src/control.rs @@ -0,0 +1,170 @@ +//! Bounded newline-delimited JSON control socket for local administration. + +#[cfg(unix)] +use std::os::unix::fs::FileTypeExt; +use std::{ + io, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::{UnixListener, UnixStream}, + sync::{Mutex, Notify}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Clone)] +pub struct ControlState { + status: Arc>, + stats: Arc>, + pub reconnect: Arc, + pub reload: Arc, + pub shutdown: CancellationToken, +} +impl Default for ControlState { + fn default() -> Self { + Self { + status: Arc::new(Mutex::new("starting".into())), + stats: Arc::new(Mutex::new("{}".into())), + reconnect: Arc::new(Notify::new()), + reload: Arc::new(Notify::new()), + shutdown: CancellationToken::new(), + } + } +} +impl ControlState { + pub async fn set_status(&self, value: impl Into) { + *self.status.lock().await = value.into(); + } + pub async fn set_stats_json(&self, value: impl Into) { + *self.stats.lock().await = value.into(); + } +} + +pub struct ControlServer { + listener: UnixListener, + path: PathBuf, + state: ControlState, +} +impl ControlServer { + pub async fn bind(path: impl Into, state: ControlState) -> io::Result { + let path = path.into(); + if let Ok(metadata) = std::fs::symlink_metadata(&path) { + if !metadata.file_type().is_socket() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "control path is not a socket", + )); + } + std::fs::remove_file(&path)?; + } + let listener = UnixListener::bind(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(Self { + listener, + path, + state, + }) + } + 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; }); } } + } + while tasks.join_next().await.is_some() {} + Ok(()) + } +} +impl Drop for ControlServer { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +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 line = String::new(); + reader.read_line(&mut line).await?; + let response = if line.len() >= 8192 { + "{\"ok\":false,\"error\":\"request too large\"}\n".to_owned() + } else { + match command_name(&line) { + Some("status") => { + let value = state.status.lock().await.clone(); + format!("{{\"ok\":true,\"status\":\"{}\"}}\n", json_escape(&value)) + } + Some("stats") => format!("{{\"ok\":true,\"stats\":{}}}\n", state.stats.lock().await), + Some("reconnect") => { + state.reconnect.notify_one(); + "{\"ok\":true,\"command\":\"reconnect\"}\n".into() + } + Some("reload") => { + state.reload.notify_one(); + "{\"ok\":true,\"command\":\"reload\"}\n".into() + } + Some("shutdown") => { + state.shutdown.cancel(); + "{\"ok\":true,\"command\":\"shutdown\"}\n".into() + } + _ => "{\"ok\":false,\"error\":\"unknown command\"}\n".into(), + } + }; + write.write_all(response.as_bytes()).await +} + +fn command_name(input: &str) -> Option<&str> { + let input = input.trim(); + if matches!( + input, + "status" | "stats" | "reconnect" | "reload" | "shutdown" + ) { + return Some(input); + } + 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 + } +} + +fn json_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +pub fn default_socket_path() -> PathBuf { + std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("wgcloak.sock") +} +pub async fn send_command(path: impl AsRef, command: &str) -> io::Result { + let mut stream = UnixStream::connect(path).await?; + stream + .write_all(format!("{{\"command\":\"{}\"}}\n", command).as_bytes()) + .await?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + Ok(String::from_utf8_lossy(&response).into_owned()) +} diff --git a/src/datagram.rs b/src/datagram.rs new file mode 100644 index 0000000..d19d032 --- /dev/null +++ b/src/datagram.rs @@ -0,0 +1,23 @@ +use bytes::Bytes; + +/// An opaque application UDP datagram. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Datagram { + pub payload: Bytes, +} + +impl Datagram { + pub fn new(payload: impl Into) -> Self { + Self { + payload: payload.into(), + } + } + + pub fn len(&self) -> usize { + self.payload.len() + } + + pub fn is_empty(&self) -> bool { + self.payload.is_empty() + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a29fa22 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,63 @@ +mod cli; +pub mod config; +pub mod control; +pub mod datagram; +pub mod session; +pub mod stats; + +pub mod client; +pub mod protocol; +pub mod server; +pub mod transport; + +use anyhow::Result; +use clap::Parser; +use cli::{Cli, Command}; +use config::Config; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + match cli.command { + Command::Client { config } => { + let parsed = Config::from_file(&config)?; + let client = parsed.into_client().map_err(anyhow::Error::msg)?; + config::init_tracing(&client.logging.level)?; + client::run(client).await + } + Command::Server { config } => { + let parsed = Config::from_file(&config)?; + let server = parsed.into_server().map_err(anyhow::Error::msg)?; + config::init_tracing(&server.logging.level)?; + server::run(server).await + } + Command::CheckConfig { config } => { + let parsed = Config::from_file(&config)?; + let level = match &parsed { + Config::Client(value) => &value.logging.level, + Config::Server(value) => &value.logging.level, + }; + config::init_tracing(level)?; + parsed.validate().map_err(anyhow::Error::msg)?; + println!("configuration is valid ({})", parsed.role_name()); + Ok(()) + } + Command::GenerateSecret => { + println!("{}", config::generate_secret()); + Ok(()) + } + Command::Status { socket } => control_command(socket, "status").await, + Command::Stats { socket } => control_command(socket, "stats").await, + Command::Reconnect { socket } => control_command(socket, "reconnect").await, + Command::Reload { socket } => control_command(socket, "reload").await, + Command::Shutdown { socket } => control_command(socket, "shutdown").await, + } +} + +async fn control_command(socket: Option, command: &str) -> Result<()> { + let response = + control::send_command(socket.unwrap_or_else(control::default_socket_path), command).await?; + print!("{response}"); + Ok(()) +} diff --git a/src/protocol/auth.rs b/src/protocol/auth.rs new file mode 100644 index 0000000..a939421 --- /dev/null +++ b/src/protocol/auth.rs @@ -0,0 +1,309 @@ +//! Cryptographic operations used by the direct protocol. +//! +//! This module only composes maintained Rust crypto crates. It does not +//! implement any cryptographic primitive itself. + +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +use super::direct::PacketType; + +type HmacSha256 = Hmac; + +pub const HMAC_LEN: usize = 32; +pub const DATA_TAG_LEN: usize = 16; +pub const SESSION_KEY_LEN: usize = 32; + +/// Canonical HELLO authenticator input. +/// +/// The fields are concatenated in this exact order. Integer fields are +/// network byte order; the client-id length is deliberately not included, +/// matching the protocol specification. +pub fn hello_auth_input( + version: u8, + client_id: &[u8], + timestamp: u64, + client_nonce: &[u8; 16], +) -> Vec { + let mut input = Vec::with_capacity(1 + client_id.len() + 8 + client_nonce.len()); + input.push(version); + input.extend_from_slice(client_id); + input.extend_from_slice(×tamp.to_be_bytes()); + input.extend_from_slice(client_nonce); + input +} + +pub fn hello_authenticator( + client_secret: &[u8], + version: u8, + client_id: &[u8], + timestamp: u64, + client_nonce: &[u8; 16], +) -> [u8; HMAC_LEN] { + hmac_sha256( + client_secret, + &hello_auth_input(version, client_id, timestamp, client_nonce), + ) +} + +pub fn verify_hello_authenticator( + client_secret: &[u8], + version: u8, + client_id: &[u8], + timestamp: u64, + client_nonce: &[u8; 16], + received: &[u8; HMAC_LEN], +) -> bool { + let expected = hello_authenticator(client_secret, version, client_id, timestamp, client_nonce); + expected.ct_eq(received).into() +} + +/// Canonical ACCEPT authenticator input. +pub fn accept_auth_input( + session_id: u64, + client_nonce: &[u8; 16], + server_nonce: &[u8; 16], + idle_timeout: u32, + max_datagram: u16, +) -> [u8; 8 + 16 + 16 + 4 + 2] { + let mut input = [0u8; 46]; + let mut at = 0; + input[at..at + 8].copy_from_slice(&session_id.to_be_bytes()); + at += 8; + input[at..at + 16].copy_from_slice(client_nonce); + at += 16; + input[at..at + 16].copy_from_slice(server_nonce); + at += 16; + input[at..at + 4].copy_from_slice(&idle_timeout.to_be_bytes()); + at += 4; + input[at..at + 2].copy_from_slice(&max_datagram.to_be_bytes()); + input +} + +pub fn accept_authenticator( + client_secret: &[u8], + session_id: u64, + client_nonce: &[u8; 16], + server_nonce: &[u8; 16], + idle_timeout: u32, + max_datagram: u16, +) -> [u8; HMAC_LEN] { + hmac_sha256( + client_secret, + &accept_auth_input( + session_id, + client_nonce, + server_nonce, + idle_timeout, + max_datagram, + ), + ) +} + +pub fn verify_accept_authenticator( + client_secret: &[u8], + session_id: u64, + client_nonce: &[u8; 16], + server_nonce: &[u8; 16], + idle_timeout: u32, + max_datagram: u16, + received: &[u8; HMAC_LEN], +) -> bool { + let expected = accept_authenticator( + client_secret, + session_id, + client_nonce, + server_nonce, + idle_timeout, + max_datagram, + ); + expected.ct_eq(received).into() +} + +/// Derive the 32-byte per-session key. +/// +/// HKDF-SHA256 uses the configured secret as input keying material and the +/// handshake transcript as the info/context. No key material is returned to +/// the wire. The context is the exact concatenation specified on the wire: +/// client nonce, server nonce, and big-endian session ID. +pub fn derive_session_key( + client_secret: &[u8], + client_nonce: &[u8; 16], + server_nonce: &[u8; 16], + session_id: u64, +) -> [u8; SESSION_KEY_LEN] { + let mut context = Vec::with_capacity(16 + 16 + 8); + context.extend_from_slice(client_nonce); + context.extend_from_slice(server_nonce); + context.extend_from_slice(&session_id.to_be_bytes()); + + let hk = Hkdf::::new(None, client_secret); + let mut key = [0u8; SESSION_KEY_LEN]; + // The fixed-size output and fixed info cannot fail with the HKDF SHA-256 + // limits. Keep the assertion local so callers never receive a Result. + hk.expand(&context, &mut key) + .expect("fixed-size HKDF expansion is valid"); + key +} + +/// Canonical DATA-family MAC input: version, type, flags, session ID, +/// sequence, then the opaque payload. +pub fn data_auth_input( + version: u8, + packet_type: PacketType, + flags: u8, + session_id: u64, + sequence: u32, + payload: &[u8], +) -> Vec { + let mut input = Vec::with_capacity(1 + 1 + 1 + 8 + 4 + payload.len()); + input.push(version); + input.push(packet_type.as_u8()); + input.push(flags); + input.extend_from_slice(&session_id.to_be_bytes()); + input.extend_from_slice(&sequence.to_be_bytes()); + input.extend_from_slice(payload); + input +} + +pub fn data_auth_tag( + session_key: &[u8], + version: u8, + packet_type: PacketType, + flags: u8, + session_id: u64, + sequence: u32, + payload: &[u8], +) -> [u8; DATA_TAG_LEN] { + let full = hmac_sha256( + session_key, + &data_auth_input(version, packet_type, flags, session_id, sequence, payload), + ); + let mut tag = [0u8; DATA_TAG_LEN]; + tag.copy_from_slice(&full[..DATA_TAG_LEN]); + tag +} + +pub fn verify_data_auth_tag( + session_key: &[u8], + version: u8, + packet_type: PacketType, + flags: u8, + session_id: u64, + sequence: u32, + payload: &[u8], + received: &[u8; DATA_TAG_LEN], +) -> bool { + let expected = data_auth_tag( + session_key, + version, + packet_type, + flags, + session_id, + sequence, + payload, + ); + expected.ct_eq(received).into() +} + +pub fn hmac_sha256(key: &[u8], input: &[u8]) -> [u8; HMAC_LEN] { + let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of every length"); + mac.update(input); + let digest = mac.finalize().into_bytes(); + let mut output = [0u8; HMAC_LEN]; + output.copy_from_slice(&digest); + output +} + +/// Generate a cryptographically random, non-zero session or sequence value. +pub fn random_nonzero_u64() -> u64 { + use rand::{rngs::OsRng, RngCore}; + let mut rng = OsRng; + loop { + let value = rng.next_u64(); + if value != 0 { + return value; + } + } +} + +pub fn random_nonzero_u32() -> u32 { + use rand::{rngs::OsRng, RngCore}; + let mut rng = OsRng; + loop { + let value = rng.next_u32(); + if value != 0 { + return value; + } + } +} + +pub fn generate_session_id() -> u64 { + random_nonzero_u64() +} + +pub fn generate_sequence_number() -> u32 { + random_nonzero_u32() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::PROTOCOL_VERSION; + + #[test] + fn hmac_and_verification_round_trip() { + let nonce = [7u8; 16]; + let tag = hello_authenticator(b"secret", PROTOCOL_VERSION, b"client", 42, &nonce); + assert!(verify_hello_authenticator( + b"secret", + PROTOCOL_VERSION, + b"client", + 42, + &nonce, + &tag + )); + assert!(!verify_hello_authenticator( + b"wrong", + PROTOCOL_VERSION, + b"client", + 42, + &nonce, + &tag + )); + } + + #[test] + fn key_derivation_is_deterministic_and_context_bound() { + let key = derive_session_key(b"secret", &[1; 16], &[2; 16], 3); + assert_eq!(key, derive_session_key(b"secret", &[1; 16], &[2; 16], 3)); + assert_ne!(key, derive_session_key(b"secret", &[1; 16], &[2; 16], 4)); + } + + #[test] + fn data_tag_covers_header_fields_and_payload() { + let tag = data_auth_tag(b"k", PROTOCOL_VERSION, PacketType::Data, 0, 9, 4, b"x"); + assert!(verify_data_auth_tag( + b"k", + PROTOCOL_VERSION, + PacketType::Data, + 0, + 9, + 4, + b"x", + &tag + )); + assert!(!verify_data_auth_tag( + b"k", + PROTOCOL_VERSION, + PacketType::Data, + 1, + 9, + 4, + b"x", + &tag + )); + } +} diff --git a/src/protocol/direct.rs b/src/protocol/direct.rs new file mode 100644 index 0000000..04da8c0 --- /dev/null +++ b/src/protocol/direct.rs @@ -0,0 +1,950 @@ +//! Deterministic direct UDP packet encoding and parsing. + +use bytes::Bytes; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +use super::auth; + +pub const PROTOCOL_VERSION: u8 = 1; +pub const MAX_PACKET_SIZE: usize = u16::MAX as usize; +// 255 (u8 hdr_len maximum) - 69 bytes of fixed HELLO fields. +pub const MAX_CLIENT_ID_LEN: usize = 186; +pub const COMMON_HEADER_LEN: usize = 12; +pub const DATA_HEADER_LEN: usize = 32; + +const HELLO_FIXED_LEN: usize = COMMON_HEADER_LEN + 1 + 8 + 16 + 32; +const ACCEPT_LEN: usize = COMMON_HEADER_LEN + 16 + 4 + 2 + 32; +const REJECT_LEN: usize = COMMON_HEADER_LEN + 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum PacketType { + Hello = 0x01, + Accept = 0x02, + Reject = 0x03, + Data = 0x10, + Keepalive = 0x11, + Close = 0x12, +} + +impl PacketType { + pub const fn as_u8(self) -> u8 { + self as u8 + } + + pub const fn from_u8(value: u8) -> Option { + match value { + 0x01 => Some(Self::Hello), + 0x02 => Some(Self::Accept), + 0x03 => Some(Self::Reject), + 0x10 => Some(Self::Data), + 0x11 => Some(Self::Keepalive), + 0x12 => Some(Self::Close), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum RejectReason { + UnsupportedVersion = 0x01, + UnknownClient = 0x02, + AuthenticationFailed = 0x03, + ReplayDetected = 0x04, + ServerBusy = 0x05, + RateLimited = 0x06, +} + +impl RejectReason { + pub const fn as_u8(self) -> u8 { + self as u8 + } + + pub const fn from_u8(value: u8) -> Option { + match value { + 0x01 => Some(Self::UnsupportedVersion), + 0x02 => Some(Self::UnknownClient), + 0x03 => Some(Self::AuthenticationFailed), + 0x04 => Some(Self::ReplayDetected), + 0x05 => Some(Self::ServerBusy), + 0x06 => Some(Self::RateLimited), + _ => None, + } + } +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ProtocolError { + #[error("packet is truncated (need at least {needed} bytes, got {actual})")] + Truncated { needed: usize, actual: usize }, + #[error("packet exceeds maximum size of {maximum} bytes")] + Oversized { maximum: usize }, + #[error("unsupported protocol version {0}")] + UnsupportedVersion(u8), + #[error("unknown packet type 0x{0:02x}")] + UnknownPacketType(u8), + #[error("invalid header length {actual} for {packet_type:?}, expected {expected}")] + InvalidHeaderLength { + packet_type: PacketType, + actual: usize, + expected: usize, + }, + #[error("invalid packet length {actual}, expected {expected}")] + InvalidPacketLength { actual: usize, expected: usize }, + #[error("HELLO client id is too long")] + ClientIdTooLong, + #[error("HELLO must have a zero session id")] + HelloSessionNotZero, + #[error("session packet must have a non-zero session id")] + SessionIdZero, + #[error("unknown reject reason 0x{0:02x}")] + UnknownRejectReason(u8), + #[error("packet cannot be encoded with an invalid field")] + InvalidField, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HelloPacket { + pub version: u8, + pub flags: u8, + pub client_id: Bytes, + pub timestamp: u64, + pub client_nonce: [u8; 16], + pub authenticator: [u8; 32], +} + +impl HelloPacket { + pub fn new( + client_id: impl Into, + timestamp: u64, + client_nonce: [u8; 16], + authenticator: [u8; 32], + ) -> Result { + let client_id = client_id.into(); + if client_id.len() > MAX_CLIENT_ID_LEN { + return Err(ProtocolError::ClientIdTooLong); + } + Ok(Self { + version: PROTOCOL_VERSION, + flags: 0, + client_id, + timestamp, + client_nonce, + authenticator, + }) + } + + pub fn authenticated( + client_secret: &[u8], + client_id: impl Into, + timestamp: u64, + client_nonce: [u8; 16], + ) -> Result { + let client_id = client_id.into(); + let authenticator = auth::hello_authenticator( + client_secret, + PROTOCOL_VERSION, + &client_id, + timestamp, + &client_nonce, + ); + Self::new(client_id, timestamp, client_nonce, authenticator) + } + + pub fn verify_authenticator(&self, client_secret: &[u8]) -> bool { + auth::verify_hello_authenticator( + client_secret, + self.version, + &self.client_id, + self.timestamp, + &self.client_nonce, + &self.authenticator, + ) + } + + pub fn is_fresh(&self, now: SystemTime, allowed_clock_skew: Duration) -> bool { + let Ok(now) = now.duration_since(UNIX_EPOCH) else { + return false; + }; + let now = now.as_secs(); + if self.timestamp >= now { + self.timestamp - now <= allowed_clock_skew.as_secs() + } else { + now - self.timestamp <= allowed_clock_skew.as_secs() + } + } + + pub fn encode(&self) -> Result, ProtocolError> { + encode_hello(self) + } + + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.encode() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AcceptPacket { + pub version: u8, + pub flags: u8, + pub session_id: u64, + pub server_nonce: [u8; 16], + pub idle_timeout: u32, + pub max_datagram: u16, + pub authenticator: [u8; 32], +} + +impl AcceptPacket { + pub fn new( + session_id: u64, + server_nonce: [u8; 16], + idle_timeout: u32, + max_datagram: u16, + authenticator: [u8; 32], + ) -> Self { + Self { + version: PROTOCOL_VERSION, + flags: 0, + session_id, + server_nonce, + idle_timeout, + max_datagram, + authenticator, + } + } + + pub fn authenticated( + client_secret: &[u8], + session_id: u64, + client_nonce: [u8; 16], + server_nonce: [u8; 16], + idle_timeout: u32, + max_datagram: u16, + ) -> Self { + let authenticator = auth::accept_authenticator( + client_secret, + session_id, + &client_nonce, + &server_nonce, + idle_timeout, + max_datagram, + ); + Self::new( + session_id, + server_nonce, + idle_timeout, + max_datagram, + authenticator, + ) + } + + pub fn verify_authenticator(&self, client_secret: &[u8], client_nonce: &[u8; 16]) -> bool { + auth::verify_accept_authenticator( + client_secret, + self.session_id, + client_nonce, + &self.server_nonce, + self.idle_timeout, + self.max_datagram, + &self.authenticator, + ) + } + + pub fn encode(&self) -> Result, ProtocolError> { + encode_accept(self) + } + + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.encode() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RejectPacket { + pub version: u8, + pub flags: u8, + pub reason: RejectReason, +} + +impl RejectPacket { + pub fn new(reason: RejectReason) -> Self { + Self { + version: PROTOCOL_VERSION, + flags: 0, + reason, + } + } + + pub fn encode(&self) -> Result, ProtocolError> { + encode_reject(self) + } + + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.encode() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DataPacket { + pub version: u8, + pub flags: u8, + pub session_id: u64, + pub sequence: u32, + pub auth_tag: [u8; 16], + pub payload: Bytes, +} + +impl DataPacket { + pub fn new( + session_id: u64, + sequence: u32, + payload: impl Into, + auth_tag: [u8; 16], + ) -> Self { + Self { + version: PROTOCOL_VERSION, + flags: 0, + session_id, + sequence, + auth_tag, + payload: payload.into(), + } + } + + pub fn authenticated( + session_key: &[u8], + session_id: u64, + sequence: u32, + payload: impl Into, + ) -> Self { + let payload = payload.into(); + let auth_tag = auth::data_auth_tag( + session_key, + PROTOCOL_VERSION, + PacketType::Data, + 0, + session_id, + sequence, + &payload, + ); + Self::new(session_id, sequence, payload, auth_tag) + } + + pub fn verify_authenticator(&self, session_key: &[u8]) -> bool { + auth::verify_data_auth_tag( + session_key, + self.version, + PacketType::Data, + self.flags, + self.session_id, + self.sequence, + &self.payload, + &self.auth_tag, + ) + } + + pub fn encode(&self) -> Result, ProtocolError> { + encode_data(self) + } + + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.encode() + } +} + +/// KEEPALIVE and CLOSE have the same authenticated envelope as DATA but no +/// payload. The packet variant selects which type is placed in the header. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SessionPacket { + pub packet_type: PacketType, + pub version: u8, + pub flags: u8, + pub session_id: u64, + pub sequence: u32, + pub auth_tag: [u8; 16], +} + +pub type KeepalivePacket = SessionPacket; +pub type ClosePacket = SessionPacket; + +impl SessionPacket { + pub fn new( + packet_type: PacketType, + session_id: u64, + sequence: u32, + auth_tag: [u8; 16], + ) -> Self { + Self { + packet_type, + version: PROTOCOL_VERSION, + flags: 0, + session_id, + sequence, + auth_tag, + } + } + + pub fn keepalive(session_id: u64, sequence: u32, auth_tag: [u8; 16]) -> Self { + Self::new(PacketType::Keepalive, session_id, sequence, auth_tag) + } + + pub fn close(session_id: u64, sequence: u32, auth_tag: [u8; 16]) -> Self { + Self::new(PacketType::Close, session_id, sequence, auth_tag) + } + + pub fn authenticated( + packet_type: PacketType, + session_key: &[u8], + session_id: u64, + sequence: u32, + ) -> Self { + let auth_tag = auth::data_auth_tag( + session_key, + PROTOCOL_VERSION, + packet_type, + 0, + session_id, + sequence, + &[], + ); + Self::new(packet_type, session_id, sequence, auth_tag) + } + + pub fn verify_authenticator(&self, session_key: &[u8]) -> bool { + auth::verify_data_auth_tag( + session_key, + self.version, + self.packet_type, + self.flags, + self.session_id, + self.sequence, + &[], + &self.auth_tag, + ) + } + + pub fn encode(&self) -> Result, ProtocolError> { + encode_session(self) + } + + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.encode() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Packet { + Hello(HelloPacket), + Accept(AcceptPacket), + Reject(RejectPacket), + Data(DataPacket), + Keepalive(SessionPacket), + Close(SessionPacket), +} + +impl Packet { + pub fn packet_type(&self) -> PacketType { + match self { + Self::Hello(_) => PacketType::Hello, + Self::Accept(_) => PacketType::Accept, + Self::Reject(_) => PacketType::Reject, + Self::Data(_) => PacketType::Data, + Self::Keepalive(_) => PacketType::Keepalive, + Self::Close(_) => PacketType::Close, + } + } + + pub fn session_id(&self) -> u64 { + match self { + Self::Hello(_) | Self::Reject(_) => 0, + Self::Accept(packet) => packet.session_id, + Self::Data(packet) => packet.session_id, + Self::Keepalive(packet) | Self::Close(packet) => packet.session_id, + } + } + + pub fn encode(&self) -> Result, ProtocolError> { + match self { + Self::Hello(packet) => packet.encode(), + Self::Accept(packet) => packet.encode(), + Self::Reject(packet) => packet.encode(), + Self::Data(packet) => packet.encode(), + Self::Keepalive(packet) | Self::Close(packet) => packet.encode(), + } + } +} + +fn write_common( + output: &mut Vec, + version: u8, + packet_type: PacketType, + flags: u8, + hdr_len: usize, + session_id: u64, +) { + output.push(version); + output.push(packet_type.as_u8()); + output.push(flags); + output.push(hdr_len as u8); + output.extend_from_slice(&session_id.to_be_bytes()); +} + +fn encode_hello(packet: &HelloPacket) -> Result, ProtocolError> { + if packet.client_id.len() > MAX_CLIENT_ID_LEN { + return Err(ProtocolError::ClientIdTooLong); + } + if packet.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(packet.version)); + } + let hdr_len = HELLO_FIXED_LEN + packet.client_id.len(); + let mut output = Vec::with_capacity(hdr_len); + write_common( + &mut output, + packet.version, + PacketType::Hello, + packet.flags, + hdr_len, + 0, + ); + output.push(packet.client_id.len() as u8); + output.extend_from_slice(&packet.client_id); + output.extend_from_slice(&packet.timestamp.to_be_bytes()); + output.extend_from_slice(&packet.client_nonce); + output.extend_from_slice(&packet.authenticator); + Ok(output) +} + +fn encode_accept(packet: &AcceptPacket) -> Result, ProtocolError> { + if packet.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(packet.version)); + } + if packet.session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + let mut output = Vec::with_capacity(ACCEPT_LEN); + write_common( + &mut output, + packet.version, + PacketType::Accept, + packet.flags, + ACCEPT_LEN, + packet.session_id, + ); + output.extend_from_slice(&packet.server_nonce); + output.extend_from_slice(&packet.idle_timeout.to_be_bytes()); + output.extend_from_slice(&packet.max_datagram.to_be_bytes()); + output.extend_from_slice(&packet.authenticator); + Ok(output) +} + +fn encode_reject(packet: &RejectPacket) -> Result, ProtocolError> { + if packet.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(packet.version)); + } + let mut output = Vec::with_capacity(REJECT_LEN); + write_common( + &mut output, + packet.version, + PacketType::Reject, + packet.flags, + REJECT_LEN, + 0, + ); + output.push(packet.reason.as_u8()); + Ok(output) +} + +fn encode_data(packet: &DataPacket) -> Result, ProtocolError> { + if packet.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(packet.version)); + } + if packet.session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + let total = + DATA_HEADER_LEN + .checked_add(packet.payload.len()) + .ok_or(ProtocolError::Oversized { + maximum: MAX_PACKET_SIZE, + })?; + if total > MAX_PACKET_SIZE { + return Err(ProtocolError::Oversized { + maximum: MAX_PACKET_SIZE, + }); + } + let mut output = Vec::with_capacity(total); + write_common( + &mut output, + packet.version, + PacketType::Data, + packet.flags, + DATA_HEADER_LEN, + packet.session_id, + ); + output.extend_from_slice(&packet.sequence.to_be_bytes()); + output.extend_from_slice(&packet.auth_tag); + output.extend_from_slice(&packet.payload); + Ok(output) +} + +fn encode_session(packet: &SessionPacket) -> Result, ProtocolError> { + if packet.version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(packet.version)); + } + if packet.session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + if !matches!( + packet.packet_type, + PacketType::Keepalive | PacketType::Close + ) { + return Err(ProtocolError::InvalidField); + } + let mut output = Vec::with_capacity(DATA_HEADER_LEN); + write_common( + &mut output, + packet.version, + packet.packet_type, + packet.flags, + DATA_HEADER_LEN, + packet.session_id, + ); + output.extend_from_slice(&packet.sequence.to_be_bytes()); + output.extend_from_slice(&packet.auth_tag); + Ok(output) +} + +fn take<'a>(input: &'a [u8], at: &mut usize, len: usize) -> Result<&'a [u8], ProtocolError> { + let end = at.checked_add(len).ok_or(ProtocolError::InvalidField)?; + if end > input.len() { + return Err(ProtocolError::Truncated { + needed: end, + actual: input.len(), + }); + } + let output = &input[*at..end]; + *at = end; + Ok(output) +} + +fn array(input: &[u8]) -> [u8; N] { + let mut output = [0u8; N]; + output.copy_from_slice(input); + output +} + +/// Parse one complete direct UDP datagram. All structural checks occur before +/// the only potentially large allocation (copying a valid DATA payload). +pub fn parse_packet(input: &[u8]) -> Result { + if input.len() > MAX_PACKET_SIZE { + return Err(ProtocolError::Oversized { + maximum: MAX_PACKET_SIZE, + }); + } + if input.len() < COMMON_HEADER_LEN { + return Err(ProtocolError::Truncated { + needed: COMMON_HEADER_LEN, + actual: input.len(), + }); + } + let version = input[0]; + if version != PROTOCOL_VERSION { + return Err(ProtocolError::UnsupportedVersion(version)); + } + let packet_type = + PacketType::from_u8(input[1]).ok_or(ProtocolError::UnknownPacketType(input[1]))?; + let flags = input[2]; + let hdr_len = input[3] as usize; + let session_id = u64::from_be_bytes(array(&input[4..12])); + if hdr_len < COMMON_HEADER_LEN { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected: COMMON_HEADER_LEN, + }); + } + if hdr_len > input.len() { + return Err(ProtocolError::Truncated { + needed: hdr_len, + actual: input.len(), + }); + } + + let mut at = COMMON_HEADER_LEN; + match packet_type { + PacketType::Hello => { + if session_id != 0 { + return Err(ProtocolError::HelloSessionNotZero); + } + let id_len = *take(input, &mut at, 1)?.first().unwrap() as usize; + if id_len > MAX_CLIENT_ID_LEN { + return Err(ProtocolError::ClientIdTooLong); + } + let expected = HELLO_FIXED_LEN + id_len; + if hdr_len != expected { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected, + }); + } + if input.len() != expected { + return Err(ProtocolError::InvalidPacketLength { + actual: input.len(), + expected, + }); + } + let client_id = Bytes::copy_from_slice(take(input, &mut at, id_len)?); + let timestamp = u64::from_be_bytes(array(take(input, &mut at, 8)?)); + let client_nonce = array(take(input, &mut at, 16)?); + let authenticator = array(take(input, &mut at, 32)?); + Ok(Packet::Hello(HelloPacket { + version, + flags, + client_id, + timestamp, + client_nonce, + authenticator, + })) + } + PacketType::Accept => { + if session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + if hdr_len != ACCEPT_LEN { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected: ACCEPT_LEN, + }); + } + if input.len() != ACCEPT_LEN { + return Err(ProtocolError::InvalidPacketLength { + actual: input.len(), + expected: ACCEPT_LEN, + }); + } + let server_nonce = array(take(input, &mut at, 16)?); + let idle_timeout = u32::from_be_bytes(array(take(input, &mut at, 4)?)); + let max_datagram = u16::from_be_bytes(array(take(input, &mut at, 2)?)); + let authenticator = array(take(input, &mut at, 32)?); + Ok(Packet::Accept(AcceptPacket { + version, + flags, + session_id, + server_nonce, + idle_timeout, + max_datagram, + authenticator, + })) + } + PacketType::Reject => { + if session_id != 0 { + return Err(ProtocolError::InvalidField); + } + if hdr_len != REJECT_LEN { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected: REJECT_LEN, + }); + } + if input.len() != REJECT_LEN { + return Err(ProtocolError::InvalidPacketLength { + actual: input.len(), + expected: REJECT_LEN, + }); + } + let reason = RejectReason::from_u8(*take(input, &mut at, 1)?.first().unwrap()) + .ok_or(ProtocolError::UnknownRejectReason(input[12]))?; + Ok(Packet::Reject(RejectPacket { + version, + flags, + reason, + })) + } + PacketType::Data => { + if session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + if hdr_len != DATA_HEADER_LEN { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected: DATA_HEADER_LEN, + }); + } + let sequence = u32::from_be_bytes(array(take(input, &mut at, 4)?)); + let auth_tag = array(take(input, &mut at, 16)?); + // The size check above makes this bound safe and caps the copy to + // MAX_PACKET_SIZE - DATA_HEADER_LEN bytes. + let payload = Bytes::copy_from_slice(&input[at..]); + Ok(Packet::Data(DataPacket { + version, + flags, + session_id, + sequence, + auth_tag, + payload, + })) + } + PacketType::Keepalive | PacketType::Close => { + if session_id == 0 { + return Err(ProtocolError::SessionIdZero); + } + if hdr_len != DATA_HEADER_LEN { + return Err(ProtocolError::InvalidHeaderLength { + packet_type, + actual: hdr_len, + expected: DATA_HEADER_LEN, + }); + } + if input.len() != DATA_HEADER_LEN { + return Err(ProtocolError::InvalidPacketLength { + actual: input.len(), + expected: DATA_HEADER_LEN, + }); + } + let sequence = u32::from_be_bytes(array(take(input, &mut at, 4)?)); + let auth_tag = array(take(input, &mut at, 16)?); + let packet = SessionPacket { + packet_type, + version, + flags, + session_id, + sequence, + auth_tag, + }; + if packet_type == PacketType::Keepalive { + Ok(Packet::Keepalive(packet)) + } else { + Ok(Packet::Close(packet)) + } + } + } +} + +impl TryFrom<&[u8]> for Packet { + type Error = ProtocolError; + + fn try_from(value: &[u8]) -> Result { + parse_packet(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::auth; + + fn hello() -> HelloPacket { + HelloPacket::authenticated(b"secret", Bytes::from_static(b"laptop"), 123, [7; 16]).unwrap() + } + + #[test] + fn all_packet_types_round_trip() { + let hello = hello(); + let encoded = hello.encode().unwrap(); + assert_eq!(encoded.len(), 75); + assert_eq!(parse_packet(&encoded), Ok(Packet::Hello(hello))); + + let accept = AcceptPacket::authenticated(b"secret", 42, [7; 16], [8; 16], 120, 1200); + assert_eq!( + parse_packet(&accept.encode().unwrap()), + Ok(Packet::Accept(accept)) + ); + + let reject = RejectPacket::new(RejectReason::AuthenticationFailed); + assert_eq!( + parse_packet(&reject.encode().unwrap()), + Ok(Packet::Reject(reject)) + ); + + let key = auth::derive_session_key(b"secret", &[7; 16], &[8; 16], 42); + let data = DataPacket::authenticated(&key, 42, 9, Bytes::from_static(b"opaque UDP")); + assert_eq!( + parse_packet(&data.encode().unwrap()), + Ok(Packet::Data(data)) + ); + + let keepalive = SessionPacket::authenticated(PacketType::Keepalive, &key, 42, 10); + assert_eq!( + parse_packet(&keepalive.encode().unwrap()), + Ok(Packet::Keepalive(keepalive)) + ); + let close = SessionPacket::authenticated(PacketType::Close, &key, 42, 11); + assert_eq!( + parse_packet(&close.encode().unwrap()), + Ok(Packet::Close(close)) + ); + } + + #[test] + fn malformed_packets_are_rejected() { + assert!(matches!( + parse_packet(&[]), + Err(ProtocolError::Truncated { .. }) + )); + let mut packet = hello().encode().unwrap(); + packet[0] = 2; + assert_eq!( + parse_packet(&packet), + Err(ProtocolError::UnsupportedVersion(2)) + ); + packet = hello().encode().unwrap(); + packet[1] = 0xff; + assert_eq!( + parse_packet(&packet), + Err(ProtocolError::UnknownPacketType(0xff)) + ); + packet = hello().encode().unwrap(); + packet[3] = 12; + assert!(matches!( + parse_packet(&packet), + Err(ProtocolError::InvalidHeaderLength { .. }) + )); + packet = hello().encode().unwrap(); + packet.truncate(packet.len() - 1); + assert!(matches!( + parse_packet(&packet), + Err(ProtocolError::Truncated { .. }) + )); + let mut oversized = vec![0u8; MAX_PACKET_SIZE + 1]; + oversized[..12].copy_from_slice(&[1, 0x10, 0, 32, 0, 0, 0, 0, 0, 0, 0, 1]); + assert!(matches!( + parse_packet(&oversized), + Err(ProtocolError::Oversized { .. }) + )); + } + + #[test] + fn hello_header_length_bound_is_enforced() { + assert!(HelloPacket::new(vec![0; MAX_CLIENT_ID_LEN], 0, [0; 16], [0; 32]).is_ok()); + assert!(matches!( + HelloPacket::new(vec![0; MAX_CLIENT_ID_LEN + 1], 0, [0; 16], [0; 32]), + Err(ProtocolError::ClientIdTooLong) + )); + } + + #[test] + fn auth_failure_does_not_change_parsing() { + let mut packet = hello(); + assert!(packet.verify_authenticator(b"secret")); + packet.authenticator[0] ^= 1; + assert!(!packet.verify_authenticator(b"secret")); + } + + #[test] + fn freshness_uses_absolute_clock_skew() { + let packet = hello(); + let now = UNIX_EPOCH + Duration::from_secs(123); + assert!(packet.is_fresh(now, Duration::from_secs(60))); + let old = HelloPacket { + timestamp: 62, + ..packet + }; + assert!(!old.is_fresh(now, Duration::from_secs(60))); + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 0000000..7623c4d --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,21 @@ +//! Wire protocol primitives for the authenticated direct UDP transport. +//! +//! The parser is intentionally independent of sockets and runtime state. It +//! accepts a complete datagram and either returns a fully validated packet or +//! a small typed error. Authentication and replay decisions remain explicit +//! operations for the caller. + +pub mod auth; +pub mod direct; +pub mod replay; + +pub use auth::{ + derive_session_key, generate_sequence_number, generate_session_id, random_nonzero_u32, + random_nonzero_u64, +}; + +pub use direct::{ + parse_packet, AcceptPacket, ClosePacket, DataPacket, HelloPacket, KeepalivePacket, Packet, + PacketType, ProtocolError, RejectPacket, RejectReason, SessionPacket, MAX_CLIENT_ID_LEN, + MAX_PACKET_SIZE, PROTOCOL_VERSION, +}; diff --git a/src/protocol/replay.rs b/src/protocol/replay.rs new file mode 100644 index 0000000..2ed943f --- /dev/null +++ b/src/protocol/replay.rs @@ -0,0 +1,191 @@ +//! Replay protection for HELLO nonces and DATA-family sequence numbers. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +pub const WINDOW_SIZE: usize = 1024; +const WORDS: usize = WINDOW_SIZE / 64; + +/// A wrapping u32 sliding receive window with a 1024-packet history. +#[derive(Clone, Debug)] +pub struct ReplayWindow { + highest: Option, + bits: [u64; WORDS], +} + +impl Default for ReplayWindow { + fn default() -> Self { + Self::new() + } +} + +impl ReplayWindow { + pub const fn new() -> Self { + Self { + highest: None, + bits: [0; WORDS], + } + } + + pub fn highest(&self) -> Option { + self.highest + } + + /// Record a sequence number, returning false for duplicates or values + /// outside the receive window. Values are considered newer using the + /// standard half-range serial-number rule, making wraparound explicit and + /// deterministic. + pub fn accept(&mut self, sequence: u32) -> bool { + let Some(highest) = self.highest else { + self.highest = Some(sequence); + self.bits = [0; WORDS]; + self.bits[0] = 1; + return true; + }; + + let forward = sequence.wrapping_sub(highest); + if forward == 0 { + return false; + } + + if forward < (1u32 << 31) { + self.shift_left(forward as usize); + self.highest = Some(sequence); + self.bits[0] |= 1; + return true; + } + + let age = highest.wrapping_sub(sequence) as usize; + if age >= WINDOW_SIZE { + return false; + } + let word = age / 64; + let bit = age % 64; + let mask = 1u64 << bit; + if self.bits[word] & mask != 0 { + return false; + } + self.bits[word] |= mask; + true + } + + pub fn check_and_record(&mut self, sequence: u32) -> bool { + self.accept(sequence) + } + + fn shift_left(&mut self, amount: usize) { + if amount >= WINDOW_SIZE { + self.bits = [0; WORDS]; + return; + } + let words = amount / 64; + let bits = amount % 64; + if words != 0 { + for i in (0..WORDS).rev() { + self.bits[i] = if i >= words { self.bits[i - words] } else { 0 }; + } + } + if bits != 0 { + for i in (0..WORDS).rev() { + let carry = if i > 0 { self.bits[i - 1] } else { 0 }; + self.bits[i] = (self.bits[i] << bits) | (carry >> (64 - bits)); + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct NonceKey { + client_id: Vec, + nonce: [u8; 16], +} + +/// Recently accepted HELLO nonce state. Entries expire after the configured +/// duration (normally twice the allowed clock skew). +#[derive(Clone, Debug)] +pub struct NonceReplayCache { + expiry: Duration, + entries: HashMap, +} + +impl NonceReplayCache { + pub fn new(expiry: Duration) -> Self { + Self { + expiry, + entries: HashMap::new(), + } + } + + pub fn expiry(&self) -> Duration { + self.expiry + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Return true and retain the nonce if it has not been seen recently. + pub fn check_and_record(&mut self, client_id: &[u8], nonce: [u8; 16], now: Instant) -> bool { + self.purge_expired(now); + let key = NonceKey { + client_id: client_id.to_vec(), + nonce, + }; + if self.entries.contains_key(&key) { + return false; + } + self.entries.insert(key, now); + true + } + + pub fn purge_expired(&mut self, now: Instant) { + self.entries + .retain(|_, seen| now.saturating_duration_since(*seen) < self.expiry); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_handles_duplicate_old_and_out_of_order_packets() { + let mut window = ReplayWindow::new(); + assert!(window.accept(100)); + assert!(window.accept(102)); + assert!(window.accept(101)); + assert!(!window.accept(101)); + assert!(!window.accept(100)); + assert!(window.accept(110)); + for value in 111..=1134 { + assert!(window.accept(value)); + } + assert!(!window.accept(110)); + } + + #[test] + fn window_wraps_and_rejects_values_outside_history() { + let mut window = ReplayWindow::new(); + assert!(window.accept(u32::MAX - 2)); + assert!(window.accept(u32::MAX - 1)); + assert!(window.accept(u32::MAX)); + assert!(window.accept(0)); + assert!(window.accept(1)); + assert!(!window.accept(u32::MAX - 2 + 1)); // duplicate old value + assert!(!window.accept(u32::MAX - 1)); + } + + #[test] + fn nonce_replay_expires() { + let now = Instant::now(); + let mut cache = NonceReplayCache::new(Duration::from_secs(2)); + assert!(cache.check_and_record(b"a", [1; 16], now)); + assert!(!cache.check_and_record(b"a", [1; 16], now + Duration::from_secs(1))); + assert!(cache.check_and_record(b"a", [1; 16], now + Duration::from_secs(3))); + assert!(cache.check_and_record(b"b", [1; 16], now)); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..6f0de81 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; + +use anyhow::Result; +use tokio::net::UdpSocket; +use tracing::warn; + +use crate::{ + config::ServerConfig, + control::{ControlServer, ControlState}, + transport::{ + quic::QuicServer, DirectUdpServer, TlsTcpServer, TransportError, TransportSession, + }, +}; + +/// Run one configured server listener. Direct UDP remains the default; set it +/// off when selecting QUIC or TLS/TCP because each role owns its listener port. +pub async fn run(config: ServerConfig) -> Result<()> { + if config.transports.direct_udp.enabled { + let state = ControlState::default(); + start_control(state.clone()).await?; + return DirectUdpServer::bind(config).await?.run().await; + } + let state = ControlState::default(); + start_control(state.clone()).await?; + if config.transports.quic.enabled { + return run_quic(config, state).await; + } + if config.transports.tls_tcp.enabled { + return run_tls(config, state).await; + } + anyhow::bail!("no server transport enabled") +} + +async fn start_control(state: ControlState) -> Result<()> { + let control = ControlServer::bind(crate::control::default_socket_path(), state).await?; + tokio::spawn(async move { + let _ = control.run().await; + }); + Ok(()) +} + +async fn run_quic(config: ServerConfig, state: ControlState) -> Result<()> { + let listener = Arc::new(QuicServer::bind(&config).await?); + let backend = config.backend.target.parse()?; + loop { + tokio::select! { + _ = state.shutdown.cancelled() => { listener.close(); return Ok(()); } + result = listener.accept_session() => match result { + Ok(session) => { let state = state.clone(); tokio::spawn(async move { let _ = relay_session(Box::new(session), backend, state).await; }); } + Err(TransportError::AuthenticationRejected(_)) => warn!("QUIC client authentication rejected"), + Err(error) => warn!(error=%error, "QUIC session failed"), + } + } + } +} + +async fn run_tls(config: ServerConfig, state: ControlState) -> Result<()> { + let listener = Arc::new(TlsTcpServer::bind(&config).await?); + let backend = config.backend.target.parse()?; + loop { + tokio::select! { + _ = state.shutdown.cancelled() => return Ok(()), + result = listener.accept_session() => match result { + Ok(session) => { let state = state.clone(); tokio::spawn(async move { let _ = relay_session(session, backend, state).await; }); } + Err(error) => warn!(error=%error, "TLS/TCP session failed"), + } + } + } +} + +async fn relay_session( + session: Box, + backend_target: std::net::SocketAddr, + state: ControlState, +) -> Result<()> { + let backend = UdpSocket::bind("0.0.0.0:0").await?; + backend.connect(backend_target).await?; + let backend = Arc::new(backend); + let mut buf = vec![0u8; session.max_datagram_size().saturating_add(1)]; + loop { + tokio::select! { + _ = state.shutdown.cancelled() => { let _ = session.close().await; return Ok(()); } + received = session.recv_datagram() => { + let payload = received?; + if payload.len() <= session.max_datagram_size() { backend.send(&payload).await?; } + } + received = backend.recv(&mut buf) => { + let len = received?; + session.send_datagram(bytes::Bytes::copy_from_slice(&buf[..len])).await?; + } + } + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..df24847 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,52 @@ +use std::time::Instant; + +use tokio::net::UdpSocket; + +use crate::stats::Stats; + +pub type SessionId = u64; +pub type ClientId = String; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransportKind { + DirectUdp, + Quic, + Http3, + TlsTcp, +} + +/// Generic server-side session state. Transport-specific state belongs in the transport module. +#[derive(Debug)] +pub struct Session { + pub id: SessionId, + pub client_id: ClientId, + pub transport: TransportKind, + pub backend: UdpSocket, + pub created_at: Instant, + pub last_activity: Instant, + pub stats: Stats, +} + +impl Session { + pub fn new( + id: SessionId, + client_id: impl Into, + transport: TransportKind, + backend: UdpSocket, + ) -> Self { + let now = Instant::now(); + Self { + id, + client_id: client_id.into(), + transport, + backend, + created_at: now, + last_activity: now, + stats: Stats::default(), + } + } + + pub fn touch(&mut self) { + self.last_activity = Instant::now(); + } +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..489ea69 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,67 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Counters shared by a session or runtime. Values are monotonic. +#[derive(Debug, Default)] +pub struct Stats { + pub bytes_sent: AtomicU64, + pub bytes_received: AtomicU64, + pub datagrams_sent: AtomicU64, + pub datagrams_received: AtomicU64, + pub datagrams_dropped: AtomicU64, + pub oversized_datagrams: AtomicU64, + pub authentication_failures: AtomicU64, + pub reconnections: AtomicU64, + pub expired_sessions: AtomicU64, + pub handshake_attempts: AtomicU64, + pub authentication_rejections: AtomicU64, + pub rate_limited_attempts: AtomicU64, + pub backend_errors: AtomicU64, +} + +impl Stats { + pub fn add_sent(&self, bytes: usize) { + self.bytes_sent.fetch_add(bytes as u64, Ordering::Relaxed); + self.datagrams_sent.fetch_add(1, Ordering::Relaxed); + } + + pub fn add_received(&self, bytes: usize) { + self.bytes_received + .fetch_add(bytes as u64, Ordering::Relaxed); + self.datagrams_received.fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot(&self) -> StatsSnapshot { + StatsSnapshot { + bytes_sent: self.bytes_sent.load(Ordering::Relaxed), + bytes_received: self.bytes_received.load(Ordering::Relaxed), + datagrams_sent: self.datagrams_sent.load(Ordering::Relaxed), + datagrams_received: self.datagrams_received.load(Ordering::Relaxed), + datagrams_dropped: self.datagrams_dropped.load(Ordering::Relaxed), + oversized_datagrams: self.oversized_datagrams.load(Ordering::Relaxed), + authentication_failures: self.authentication_failures.load(Ordering::Relaxed), + reconnections: self.reconnections.load(Ordering::Relaxed), + expired_sessions: self.expired_sessions.load(Ordering::Relaxed), + handshake_attempts: self.handshake_attempts.load(Ordering::Relaxed), + authentication_rejections: self.authentication_rejections.load(Ordering::Relaxed), + rate_limited_attempts: self.rate_limited_attempts.load(Ordering::Relaxed), + backend_errors: self.backend_errors.load(Ordering::Relaxed), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct StatsSnapshot { + pub bytes_sent: u64, + pub bytes_received: u64, + pub datagrams_sent: u64, + pub datagrams_received: u64, + pub datagrams_dropped: u64, + pub oversized_datagrams: u64, + pub authentication_failures: u64, + pub reconnections: u64, + pub expired_sessions: u64, + pub handshake_attempts: u64, + pub authentication_rejections: u64, + pub rate_limited_attempts: u64, + pub backend_errors: u64, +} diff --git a/src/transport/direct_udp.rs b/src/transport/direct_udp.rs new file mode 100644 index 0000000..5d02fb0 --- /dev/null +++ b/src/transport/direct_udp.rs @@ -0,0 +1,662 @@ +//! Authenticated, opaque-datagram direct UDP transport. + +use std::{ + collections::HashMap, + net::SocketAddr, + sync::{ + atomic::{AtomicU32, Ordering}, + Arc, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use rand::{rngs::OsRng, RngCore}; +use tokio::{ + net::{lookup_host, UdpSocket}, + sync::Mutex, + task::JoinSet, + time::{self, timeout}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::{ + config::{ClientConfig, ServerConfig}, + protocol::{ + self, auth, + direct::{ + AcceptPacket, DataPacket, HelloPacket, Packet, PacketType, RejectPacket, RejectReason, + SessionPacket, + }, + replay::{NonceReplayCache, ReplayWindow}, + }, + stats::Stats, +}; + +use super::{TransportConnector, TransportError, TransportFuture, TransportSession}; + +const DEFAULT_MAX_DATAGRAM: usize = 1200; +const MAX_UDP_DATAGRAM: usize = u16::MAX as usize; + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +async fn resolve_server(server: &str, port: u16) -> Result { + if let Ok(addr) = server.parse::() { + return Ok(addr); + } + let endpoint = format!("{server}:{port}"); + lookup_host(endpoint.clone()) + .await? + .next() + .ok_or_else(|| anyhow!("could not resolve {endpoint}")) +} + +fn direct_bind_addr(listen: &str, port: u16) -> Result { + if listen.parse::().is_ok() { + Ok(listen.to_owned()) + } else { + Ok(format!("{listen}:{port}")) + } +} + +/// Established client-side direct UDP session. +pub struct DirectUdpSession { + socket: Arc, + peer: Mutex, + session_id: u64, + session_key: [u8; auth::SESSION_KEY_LEN], + send_sequence: AtomicU32, + receive_window: Mutex, + max_datagram: usize, + closed: CancellationToken, +} + +impl DirectUdpSession { + pub fn session_id(&self) -> u64 { + self.session_id + } + pub fn peer(&self) -> TransportFuture<'_, SocketAddr> { + Box::pin(async move { Ok(*self.peer.lock().await) }) + } + + pub async fn connect(config: &ClientConfig) -> Result { + let secret = config.client.secret.load_bytes("client").map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + )) + })?; + let peer = resolve_server(&config.client.server, config.transport.direct_udp.port) + .await + .map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + e.to_string(), + )) + })?; + let socket = UdpSocket::bind("0.0.0.0:0").await?; + let socket = Arc::new(socket); + let mut nonce = [0u8; 16]; + OsRng.fill_bytes(&mut nonce); + let hello = HelloPacket::authenticated( + &secret, + Bytes::copy_from_slice(config.client.id.as_bytes()), + unix_timestamp(), + nonce, + )?; + let encoded = hello.encode()?; + let handshake_timeout = + Duration::from_millis(config.transport.auto.probe_timeout_ms.max(1)) + .min(Duration::from_secs(30)); + for _attempt in 0..config.transport.auto.retries.max(1) { + socket.send_to(&encoded, peer).await?; + let mut buf = vec![0u8; MAX_UDP_DATAGRAM]; + match timeout(handshake_timeout, socket.recv_from(&mut buf)).await { + Ok(Ok((len, source))) => { + let packet = match protocol::parse_packet(&buf[..len]) { + Ok(v) => v, + Err(e) => { + debug!(error=%e, "ignoring malformed handshake response"); + continue; + } + }; + match packet { + Packet::Accept(accept) => { + if source.ip() != peer.ip() + || !accept.verify_authenticator(&secret, &nonce) + { + continue; + } + let max_datagram = (config.network.max_datagram_size as usize) + .min(accept.max_datagram as usize); + let session_key = auth::derive_session_key( + &secret, + &nonce, + &accept.server_nonce, + accept.session_id, + ); + info!(session_id=accept.session_id, remote_addr=%source, max_datagram, "direct UDP session authenticated"); + return Ok(Self { + socket, + peer: Mutex::new(source), + session_id: accept.session_id, + session_key, + send_sequence: AtomicU32::new(auth::generate_sequence_number()), + receive_window: Mutex::new(ReplayWindow::new()), + max_datagram, + closed: CancellationToken::new(), + }); + } + Packet::Reject(reject) => { + return Err(TransportError::AuthenticationRejected(reject.reason)) + } + _ => continue, + } + } + Ok(Err(e)) => return Err(e.into()), + Err(_) => continue, + } + } + Err(TransportError::HandshakeTimeout) + } + + async fn send_packet(&self, bytes: &[u8]) -> Result<(), TransportError> { + if self.closed.is_cancelled() { + return Err(TransportError::Closed); + } + let peer = *self.peer.lock().await; + self.socket.send_to(bytes, peer).await?; + Ok(()) + } + + /// Send an authenticated control keepalive. It intentionally shares the + /// outgoing DATA sequence space only within this direction. + pub async fn send_keepalive(&self) -> Result<(), TransportError> { + let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed); + let packet = SessionPacket::authenticated( + PacketType::Keepalive, + &self.session_key, + self.session_id, + sequence, + ); + self.send_packet(&packet.encode()?).await + } + + async fn receive_valid(&self) -> Result<(Packet, SocketAddr), TransportError> { + let mut buf = vec![0u8; MAX_UDP_DATAGRAM]; + loop { + let (len, source) = self.socket.recv_from(&mut buf).await?; + let packet = match protocol::parse_packet(&buf[..len]) { + Ok(p) => p, + Err(e) => { + debug!(error=%e, "dropping malformed direct UDP packet"); + continue; + } + }; + let valid = match &packet { + Packet::Data(p) => { + p.session_id == self.session_id && p.verify_authenticator(&self.session_key) + } + Packet::Keepalive(p) | Packet::Close(p) => { + p.session_id == self.session_id && p.verify_authenticator(&self.session_key) + } + _ => false, + }; + if !valid { + continue; + } + let sequence = match &packet { + Packet::Data(p) => p.sequence, + Packet::Keepalive(p) | Packet::Close(p) => p.sequence, + _ => unreachable!(), + }; + if !self.receive_window.lock().await.accept(sequence) { + continue; + } + *self.peer.lock().await = source; + return Ok((packet, source)); + } + } +} + +impl TransportSession for DirectUdpSession { + fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> { + Box::pin(async move { + if data.len() > self.max_datagram { + return Err(TransportError::Oversized { + maximum: self.max_datagram, + }); + } + let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed); + let packet = + DataPacket::authenticated(&self.session_key, self.session_id, sequence, data); + self.send_packet(&packet.encode()?).await + }) + } + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> { + Box::pin(async move { + loop { + match self.receive_valid().await? { + (Packet::Data(packet), _) => return Ok(packet.payload), + (Packet::Keepalive(_), _) => continue, + (Packet::Close(_), _) => { + self.closed.cancel(); + return Err(TransportError::Closed); + } + _ => continue, + } + } + }) + } + fn close<'a>(&'a self) -> TransportFuture<'a, ()> { + Box::pin(async move { + if self.closed.is_cancelled() { + return Ok(()); + } + let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed); + let packet = SessionPacket::authenticated( + PacketType::Close, + &self.session_key, + self.session_id, + sequence, + ); + let _ = self.send_packet(&packet.encode()?).await; + self.closed.cancel(); + Ok(()) + }) + } + fn max_datagram_size(&self) -> usize { + self.max_datagram + } +} + +pub struct DirectUdpConnector { + config: ClientConfig, +} +impl DirectUdpConnector { + pub fn new(config: ClientConfig) -> Self { + Self { config } + } +} +impl TransportConnector for DirectUdpConnector { + fn connect<'a>(&'a self) -> TransportFuture<'a, Box> { + Box::pin(async move { + Ok(Box::new(DirectUdpSession::connect(&self.config).await?) + as Box) + }) + } +} + +struct ServerSession { + id: u64, + client_id: String, + key: [u8; auth::SESSION_KEY_LEN], + peer: Mutex, + backend: Arc, + receive_window: Mutex, + send_sequence: AtomicU32, + last_activity: Mutex, + max_datagram: usize, + cancel: CancellationToken, +} + +/// Multi-client direct UDP server. Each authenticated session gets its own +/// connected backend socket and therefore natural response demultiplexing. +pub struct DirectUdpServer { + socket: Arc, + backend_target: SocketAddr, + clients: HashMap>, + sessions: Arc>>>, + nonces: Arc>, + pending: Arc>>, + config: ServerConfig, + stats: Arc, +} + +impl DirectUdpServer { + pub async fn bind(config: ServerConfig) -> Result { + let bind = direct_bind_addr(&config.server.listen, config.transports.direct_udp.port)?; + let socket = Arc::new( + UdpSocket::bind(&bind) + .await + .context("bind direct UDP listener")?, + ); + let backend_target = config + .backend + .target + .parse::() + .context("invalid backend target")?; + let mut clients = HashMap::new(); + for client in &config.auth.clients { + clients.insert( + client.id.clone(), + client + .secret + .load_bytes(&format!("auth client {}", client.id))?, + ); + } + Ok(Self { + socket, + backend_target, + clients, + sessions: Arc::new(Mutex::new(HashMap::new())), + nonces: Arc::new(Mutex::new(NonceReplayCache::new(Duration::from_secs(120)))), + pending: Arc::new(Mutex::new(HashMap::new())), + config, + stats: Arc::new(Stats::default()), + }) + } + pub fn local_addr(&self) -> Result { + Ok(self.socket.local_addr()?) + } + pub fn stats(&self) -> Arc { + self.stats.clone() + } + pub async fn active_sessions(&self) -> usize { + self.sessions.lock().await.len() + } + + async fn send_reject(&self, peer: SocketAddr, reason: RejectReason) { + if let Ok(bytes) = RejectPacket::new(reason).encode() { + if self.socket.send_to(&bytes, peer).await.is_ok() { + self.stats.add_sent(bytes.len()); + } + } + self.stats + .authentication_rejections + .fetch_add(1, Ordering::Relaxed); + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + } + + async fn handle_hello( + &self, + hello: protocol::HelloPacket, + peer: SocketAddr, + ) -> Result>> { + self.stats + .handshake_attempts + .fetch_add(1, Ordering::Relaxed); + let now = Instant::now(); + { + let mut pending = self.pending.lock().await; + let expiry = Duration::from_millis(self.config.limits.handshake_timeout_ms.max(1)); + pending.retain(|_, seen| now.duration_since(*seen) < expiry); + if !pending.contains_key(&peer) + && pending.len() >= self.config.limits.max_pending_handshakes + { + self.stats + .rate_limited_attempts + .fetch_add(1, Ordering::Relaxed); + drop(pending); + self.send_reject(peer, RejectReason::RateLimited).await; + return Ok(None); + } + pending.insert(peer, now); + } + let source_ip = peer.ip(); + let active = self.sessions.lock().await; + let sessions_for_ip = { + let mut count = 0; + for session in active.values() { + if session.peer.lock().await.ip() == source_ip { + count += 1; + } + } + count + }; + if active.len() >= self.config.limits.max_sessions + || sessions_for_ip >= self.config.limits.max_sessions_per_ip + { + drop(active); + self.pending.lock().await.remove(&peer); + self.send_reject(peer, RejectReason::ServerBusy).await; + return Ok(None); + } + drop(active); + let Some(secret) = self + .clients + .get(std::str::from_utf8(&hello.client_id).unwrap_or("")) + else { + self.pending.lock().await.remove(&peer); + self.send_reject(peer, RejectReason::AuthenticationFailed) + .await; + return Ok(None); + }; + if !hello.is_fresh(SystemTime::now(), Duration::from_secs(60)) + || !hello.verify_authenticator(secret) + { + self.stats + .authentication_failures + .fetch_add(1, Ordering::Relaxed); + self.pending.lock().await.remove(&peer); + self.send_reject(peer, RejectReason::AuthenticationFailed) + .await; + return Ok(None); + } + if !self.nonces.lock().await.check_and_record( + &hello.client_id, + hello.client_nonce, + Instant::now(), + ) { + self.pending.lock().await.remove(&peer); + self.send_reject(peer, RejectReason::ReplayDetected).await; + return Ok(None); + } + let backend = Arc::new(UdpSocket::bind("0.0.0.0:0").await?); + backend.connect(self.backend_target).await?; + let mut session_id = auth::generate_session_id(); + let mut sessions = self.sessions.lock().await; + while sessions.contains_key(&session_id) { + session_id = auth::generate_session_id(); + } + 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 key = auth::derive_session_key(secret, &hello.client_nonce, &server_nonce, session_id); + let session = Arc::new(ServerSession { + id: session_id, + client_id: String::from_utf8_lossy(&hello.client_id).into_owned(), + key, + peer: Mutex::new(peer), + backend, + receive_window: Mutex::new(ReplayWindow::new()), + send_sequence: AtomicU32::new(auth::generate_sequence_number()), + last_activity: Mutex::new(Instant::now()), + max_datagram: max_datagram as usize, + cancel: CancellationToken::new(), + }); + sessions.insert(session_id, session.clone()); + drop(sessions); + self.pending.lock().await.remove(&peer); + let accept = AcceptPacket::authenticated( + secret, + session_id, + hello.client_nonce, + server_nonce, + idle, + max_datagram, + ); + let accept_bytes = accept.encode()?; + self.socket.send_to(&accept_bytes, peer).await?; + self.stats.add_sent(accept_bytes.len()); + info!(session_id, client_id=%session.client_id, remote_addr=%peer, "direct UDP client authenticated"); + Ok(Some(session)) + } + + async fn remove_session(&self, id: u64, expired: bool) { + if let Some(session) = self.sessions.lock().await.remove(&id) { + session.cancel.cancel(); + if expired { + self.stats.expired_sessions.fetch_add(1, Ordering::Relaxed); + } + } + } + + pub async fn run(self) -> Result<()> { + let mut tasks = JoinSet::new(); + let mut buf = vec![0u8; MAX_UDP_DATAGRAM]; + let mut cleanup = time::interval(Duration::from_secs(1)); + let signal = shutdown_signal(); + tokio::pin!(signal); + loop { + tokio::select! { + _ = &mut signal => { info!("direct UDP server shutting down"); break; } + Some(done) = tasks.join_next() => { if let Ok(id) = done { self.remove_session(id, false).await; } } + _ = cleanup.tick() => { + let now = Instant::now(); + let expired: Vec = self.sessions.lock().await.iter().filter_map(|(&id,s)| async_ready_expired(s, now, Duration::from_secs(self.config.session.idle_timeout_secs)).map(|_| id)).collect(); + for id in expired { self.remove_session(id, true).await; } + } + received = self.socket.recv_from(&mut buf) => { + let (len, peer) = match received { Ok(v) => v, Err(e) => { warn!(error=%e, "direct UDP receive failed"); continue; } }; + self.stats.add_received(len); + let packet = match protocol::parse_packet(&buf[..len]) { Ok(p) => p, Err(e) => { self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); debug!(remote_addr=%peer,error=%e,"dropping malformed direct UDP packet"); continue; } }; + match packet { + Packet::Hello(hello) => match self.handle_hello(hello, peer).await { + Ok(Some(session)) => { + let listener = self.socket.clone(); let backend = session.backend.clone(); let session_clone = session.clone(); let stats = self.stats.clone(); + tasks.spawn(async move { backend_loop(listener, backend, session_clone, stats).await }); + } + Ok(None) => {} + Err(error) => warn!(remote_addr=%peer, error=%error, "failed to establish direct UDP session"), + }, + Packet::Data(data) => if let Err(error) = self.handle_session_packet(PacketType::Data, data.session_id, data.sequence, data.auth_tag, data.payload, peer).await { self.stats.backend_errors.fetch_add(1, Ordering::Relaxed); self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); warn!(session_id=data.session_id, error=%error, "backend forwarding failed"); }, + Packet::Keepalive(p) => if let Err(error) = self.handle_session_packet(p.packet_type, p.session_id, p.sequence, p.auth_tag, Bytes::new(), peer).await { warn!(session_id=p.session_id, error=%error, "keepalive handling failed"); }, + Packet::Close(p) => { if let Err(error) = self.handle_session_packet(p.packet_type, p.session_id, p.sequence, p.auth_tag, Bytes::new(), peer).await { warn!(session_id=p.session_id, error=%error, "close handling failed"); } self.remove_session(p.session_id, false).await; }, + _ => {} + } + } + } + } + let ids: Vec = self.sessions.lock().await.keys().copied().collect(); + for id in ids { + self.remove_session(id, false).await; + } + while tasks.join_next().await.is_some() {} + Ok(()) + } + + async fn handle_session_packet( + &self, + packet_type: PacketType, + id: u64, + sequence: u32, + tag: [u8; 16], + payload: Bytes, + peer: SocketAddr, + ) -> Result<()> { + let session = match self.sessions.lock().await.get(&id).cloned() { + Some(s) => s, + None => { + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + }; + if payload.len() > session.max_datagram { + self.stats + .oversized_datagrams + .fetch_add(1, Ordering::Relaxed); + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + if !auth::verify_data_auth_tag( + &session.key, + protocol::PROTOCOL_VERSION, + packet_type, + 0, + id, + sequence, + &payload, + &tag, + ) { + self.stats + .authentication_failures + .fetch_add(1, Ordering::Relaxed); + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + if !session.receive_window.lock().await.accept(sequence) { + self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + *session.peer.lock().await = peer; + *session.last_activity.lock().await = Instant::now(); + match packet_type { + PacketType::Data => { + session.backend.send(&payload).await?; + } + PacketType::Close => session.cancel.cancel(), + PacketType::Keepalive => {} + _ => {} + } + Ok(()) + } +} + +async fn backend_loop( + listener: Arc, + backend: Arc, + session: Arc, + stats: Arc, +) -> u64 { + let mut buf = vec![0u8; MAX_UDP_DATAGRAM]; + loop { + tokio::select! { + _ = session.cancel.cancelled() => break, + received = backend.recv(&mut buf) => { + let len = match received { Ok(len) => len, Err(_) => break }; + if len > session.max_datagram { + stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed); + stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + continue; + } + let sequence = session.send_sequence.fetch_add(1, Ordering::Relaxed); + let packet = DataPacket::authenticated(&session.key, session.id, sequence, Bytes::copy_from_slice(&buf[..len])); + let bytes = match packet.encode() { Ok(v) => v, Err(_) => continue }; + let peer = *session.peer.lock().await; + if listener.send_to(&bytes, peer).await.is_err() { + stats.backend_errors.fetch_add(1, Ordering::Relaxed); + stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); + break; + } + stats.add_sent(bytes.len()); + } + } + } + session.id +} + +fn async_ready_expired( + session: &Arc, + now: Instant, + timeout: Duration, +) -> Option { + session.last_activity.try_lock().ok().and_then(|last| { + if now.duration_since(*last) >= timeout { + Some(session.id) + } else { + None + } + }) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/src/transport/http3.rs b/src/transport/http3.rs new file mode 100644 index 0000000..7e45053 --- /dev/null +++ b/src/transport/http3.rs @@ -0,0 +1,428 @@ +//! HTTP/3 CONNECT-UDP protocol primitives. +//! +//! `h3` 0.0.8 exposes the extended-CONNECT and HTTP-Datagram settings, while +//! `h3-quinn` 0.0.10 exposes the actual datagram adapter only behind its +//! `datagram` feature. This module therefore keeps the wire-level +//! CONNECT-UDP/context/capsule logic independent of that optional adapter. +//! Callers can implement [`HttpDatagramIo`] for the h3-datagram handlers once +//! the feature is enabled; application packets are never sent as HTTP body +//! streams. + +use std::{fmt, future::Future, pin::Pin, str::FromStr, time::Duration}; + +use bytes::Bytes; +use hmac::{Hmac, Mac}; +use rand::{rngs::OsRng, RngCore}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use tokio_util::sync::CancellationToken; + +pub const CONNECT_UDP_CONTEXT_ID: u64 = 0; +pub const MAX_QUIC_VARINT: u64 = (1 << 62) - 1; +pub const DEFAULT_MAX_DATAGRAM: usize = 1200; + +// Private protocol capsule types. Capsule framing is standardized; these +// payload types are project-local and are consumed only before activation. +pub const AUTH_CHALLENGE_CAPSULE: u64 = 0x2a; +pub const AUTH_RESPONSE_CAPSULE: u64 = 0x2b; +pub const AUTH_ACCEPT_CAPSULE: u64 = 0x2c; +pub const AUTH_REJECT_CAPSULE: u64 = 0x2d; +const AUTH_VERSION: u8 = 1; +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; +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Http3Error { + InvalidRequest(&'static str), + VarintOverflow, + Truncated, + InvalidCapsule, + ContextMismatch { expected: u64, actual: u64 }, + Oversized { length: usize, maximum: usize }, + Io(String), + Closed, + IdleTimeout, +} + +impl fmt::Display for Http3Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidRequest(reason) => write!(f, "invalid CONNECT-UDP request: {reason}"), + Self::VarintOverflow => write!(f, "QUIC variable integer overflow"), + Self::Truncated => write!(f, "truncated HTTP/3 value"), + Self::InvalidCapsule => write!(f, "invalid HTTP capsule"), + Self::ContextMismatch { expected, actual } => { + write!( + f, + "HTTP Datagram context {actual} does not match {expected}" + ) + } + Self::Oversized { length, maximum } => { + write!(f, "datagram length {length} exceeds maximum {maximum}") + } + Self::Io(error) => write!(f, "HTTP/3 I/O error: {error}"), + Self::Closed => write!(f, "HTTP/3 session closed"), + Self::IdleTimeout => write!(f, "HTTP/3 session idle timeout"), + } + } +} + +impl std::error::Error for Http3Error {} + +/// Request metadata needed for an RFC 9220-style extended CONNECT request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectUdpRequest { + pub method: String, + pub protocol: String, + pub path: String, +} + +impl ConnectUdpRequest { + pub fn new(path: impl Into) -> Self { + Self { + method: "CONNECT".into(), + protocol: "connect-udp".into(), + path: path.into(), + } + } + + pub fn validate(&self, configured_path: &str) -> Result<(), Http3Error> { + validate_connect_udp_request(&self.method, &self.protocol, &self.path, configured_path) + } +} + +pub fn validate_connect_udp_request( + method: &str, + protocol: &str, + path: &str, + configured_path: &str, +) -> Result<(), Http3Error> { + if method != "CONNECT" { + return Err(Http3Error::InvalidRequest("method must be CONNECT")); + } + let parsed = h3::ext::Protocol::from_str(protocol) + .map_err(|_| Http3Error::InvalidRequest(":protocol must be connect-udp"))?; + if parsed != h3::ext::Protocol::CONNECT_UDP { + return Err(Http3Error::InvalidRequest(":protocol must be connect-udp")); + } + if path != configured_path { + return Err(Http3Error::InvalidRequest("request path is not configured")); + } + if !path.starts_with('/') { + return Err(Http3Error::InvalidRequest("path must be absolute")); + } + Ok(()) +} + +/// Build h3 settings required before sending CONNECT-UDP. +pub fn client_builder() -> h3::client::Builder { + let mut builder = h3::client::builder(); + builder.enable_extended_connect(true).enable_datagram(true); + builder +} + +/// Build h3 settings required before accepting CONNECT-UDP. +pub fn server_builder() -> h3::server::Builder { + let mut builder = h3::server::builder(); + builder.enable_extended_connect(true).enable_datagram(true); + builder +} + +fn encode_varint(value: u64) -> Result, Http3Error> { + if value > MAX_QUIC_VARINT { + return Err(Http3Error::VarintOverflow); + } + let (prefix, width) = if value < (1 << 6) { + (0u8, 1) + } else if value < (1 << 14) { + (0x40, 2) + } else if value < (1 << 30) { + (0x80, 4) + } else { + (0xc0, 8) + }; + let mut out = vec![0u8; width]; + for index in (0..width).rev() { + let shift = 8 * (width - 1 - index); + out[index] = ((value >> shift) & 0xff) as u8; + } + out[0] = (out[0] & 0x3f) | prefix; + Ok(out) +} + +fn decode_varint(input: &[u8]) -> Result<(u64, usize), Http3Error> { + let Some(&first) = input.first() else { + return Err(Http3Error::Truncated); + }; + let width = 1usize << (first >> 6); + if input.len() < width { + return Err(Http3Error::Truncated); + } + let mut value = (first & 0x3f) as u64; + for byte in &input[1..width] { + value = (value << 8) | (*byte as u64); + } + Ok((value, width)) +} + +/// Encode an HTTP Datagram payload (RFC 9297): context ID varint followed by +/// the opaque UDP datagram. CONNECT-UDP uses context ID zero. +pub fn encode_http_datagram( + context_id: u64, + payload: &[u8], + maximum: usize, +) -> Result { + if payload.len() > maximum { + return Err(Http3Error::Oversized { + length: payload.len(), + maximum, + }); + } + let context = encode_varint(context_id)?; + let mut out = Vec::with_capacity(context.len() + payload.len()); + out.extend_from_slice(&context); + out.extend_from_slice(payload); + Ok(Bytes::from(out)) +} + +pub fn decode_http_datagram( + encoded: &[u8], + expected_context_id: u64, + maximum: usize, +) -> Result { + let (context_id, offset) = decode_varint(encoded)?; + if context_id != expected_context_id { + return Err(Http3Error::ContextMismatch { + expected: expected_context_id, + actual: context_id, + }); + } + let payload = &encoded[offset..]; + if payload.len() > maximum { + return Err(Http3Error::Oversized { + length: payload.len(), + maximum, + }); + } + Ok(Bytes::copy_from_slice(payload)) +} + +/// Encode one RFC 9297 capsule (type varint, length varint, payload). +pub fn encode_capsule(capsule_type: u64, payload: &[u8]) -> Result { + let kind = encode_varint(capsule_type)?; + let length = encode_varint(payload.len() as u64)?; + let mut out = Vec::with_capacity(kind.len() + length.len() + payload.len()); + out.extend_from_slice(&kind); + out.extend_from_slice(&length); + out.extend_from_slice(payload); + Ok(Bytes::from(out)) +} + +pub fn decode_capsule(encoded: &[u8]) -> Result<(u64, Bytes), Http3Error> { + let (capsule_type, type_len) = decode_varint(encoded)?; + let (payload_len, length_len) = decode_varint(&encoded[type_len..])?; + let start = type_len + length_len; + let payload_len = usize::try_from(payload_len).map_err(|_| Http3Error::VarintOverflow)?; + let end = start + .checked_add(payload_len) + .ok_or(Http3Error::VarintOverflow)?; + if encoded.len() != end { + return Err(if encoded.len() < end { + Http3Error::Truncated + } else { + Http3Error::InvalidCapsule + }); + } + Ok((capsule_type, Bytes::copy_from_slice(&encoded[start..end]))) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthChallenge { + pub bytes: [u8; CHALLENGE_LEN], +} + +impl AuthChallenge { + pub fn random() -> Self { + let mut bytes = [0u8; CHALLENGE_LEN]; + OsRng.fill_bytes(&mut bytes); + Self { bytes } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedClient { + pub id: String, + pub nonce: [u8; CLIENT_NONCE_LEN], +} + +fn challenge_mac( + challenge: &[u8; CHALLENGE_LEN], + client_id: &[u8], + nonce: &[u8; CLIENT_NONCE_LEN], + secret: &[u8], +) -> [u8; MAC_LEN] { + let mut input = Vec::with_capacity(1 + CHALLENGE_LEN + 1 + client_id.len() + CLIENT_NONCE_LEN); + input.push(AUTH_VERSION); + input.extend_from_slice(challenge); + input.push(client_id.len() as u8); + input.extend_from_slice(client_id); + input.extend_from_slice(nonce); + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts every key length"); + mac.update(&input); + mac.finalize().into_bytes().into() +} + +pub fn encode_auth_challenge(challenge: &AuthChallenge) -> Result { + encode_capsule(AUTH_CHALLENGE_CAPSULE, &challenge.bytes) +} + +pub fn encode_auth_accept() -> Result { + encode_capsule(AUTH_ACCEPT_CAPSULE, &[]) +} + +pub fn encode_auth_reject() -> Result { + encode_capsule(AUTH_REJECT_CAPSULE, &[]) +} + +pub fn is_auth_accept(capsule: &[u8]) -> Result { + let (kind, payload) = decode_capsule(capsule)?; + if !payload.is_empty() { + return Err(Http3Error::InvalidCapsule); + } + match kind { + AUTH_ACCEPT_CAPSULE => Ok(true), + AUTH_REJECT_CAPSULE => Ok(false), + _ => Err(Http3Error::InvalidCapsule), + } +} + +pub fn encode_auth_response( + challenge: &AuthChallenge, + client_id: &str, + nonce: &[u8; CLIENT_NONCE_LEN], + secret: &[u8], +) -> Result { + let id = client_id.as_bytes(); + if id.is_empty() || id.len() > MAX_CLIENT_ID { + return Err(Http3Error::InvalidRequest("client ID length is invalid")); + } + let mac = challenge_mac(&challenge.bytes, id, nonce, secret); + let mut payload = Vec::with_capacity(1 + id.len() + CLIENT_NONCE_LEN + MAC_LEN); + payload.push(id.len() as u8); + payload.extend_from_slice(id); + payload.extend_from_slice(nonce); + payload.extend_from_slice(&mac); + encode_capsule(AUTH_RESPONSE_CAPSULE, &payload) +} + +pub fn verify_auth_response( + challenge: &AuthChallenge, + capsule: &[u8], + clients: &std::collections::HashMap>, +) -> Result { + let (kind, payload) = decode_capsule(capsule)?; + if kind != AUTH_RESPONSE_CAPSULE || payload.len() < 1 + CLIENT_NONCE_LEN + MAC_LEN { + return Err(Http3Error::InvalidCapsule); + } + let id_len = payload[0] as usize; + let expected = 1 + id_len + CLIENT_NONCE_LEN + MAC_LEN; + if id_len == 0 || id_len > MAX_CLIENT_ID || expected != payload.len() { + return Err(Http3Error::InvalidCapsule); + } + let id = + std::str::from_utf8(&payload[1..1 + id_len]).map_err(|_| Http3Error::InvalidCapsule)?; + let mut nonce = [0u8; CLIENT_NONCE_LEN]; + nonce.copy_from_slice(&payload[1 + id_len..1 + id_len + CLIENT_NONCE_LEN]); + let mut received = [0u8; MAC_LEN]; + received.copy_from_slice(&payload[1 + id_len + CLIENT_NONCE_LEN..]); + let Some(secret) = clients.get(id) else { + return Err(Http3Error::InvalidRequest("unknown client")); + }; + let expected_mac = challenge_mac(&challenge.bytes, id.as_bytes(), &nonce, secret); + if !bool::from(expected_mac.ct_eq(&received)) { + return Err(Http3Error::InvalidRequest("authentication failed")); + } + Ok(AuthenticatedClient { + id: id.to_owned(), + nonce, + }) +} + +/// Narrow adapter boundary for the optional h3-datagram implementation. +pub trait HttpDatagramIo: Send + Sync { + type Error: fmt::Display + Send + Sync + 'static; + fn send<'a>( + &'a self, + encoded: Bytes, + ) -> Pin> + Send + 'a>>; + fn recv<'a>(&'a self) -> Pin> + Send + 'a>>; + fn close<'a>(&'a self) -> Pin> + Send + 'a>>; +} + +pub struct ConnectUdpSession { + io: I, + context_id: u64, + maximum_datagram: usize, + idle_timeout: Option, + closed: CancellationToken, +} + +impl ConnectUdpSession { + pub fn new(io: I, maximum_datagram: usize) -> Self { + Self { + io, + context_id: CONNECT_UDP_CONTEXT_ID, + maximum_datagram, + idle_timeout: None, + closed: CancellationToken::new(), + } + } + + pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self { + self.idle_timeout = Some(idle_timeout); + self + } + + pub fn max_datagram_size(&self) -> usize { + self.maximum_datagram + } + + pub async fn send_datagram(&self, payload: &[u8]) -> Result<(), Http3Error> { + if self.closed.is_cancelled() { + return Err(Http3Error::Closed); + } + let encoded = encode_http_datagram(self.context_id, payload, self.maximum_datagram)?; + self.io + .send(encoded) + .await + .map_err(|error| Http3Error::Io(error.to_string())) + } + + pub async fn recv_datagram(&self) -> Result { + if self.closed.is_cancelled() { + return Err(Http3Error::Closed); + } + let future = self.io.recv(); + let encoded = match self.idle_timeout { + Some(timeout) => tokio::time::timeout(timeout, future) + .await + .map_err(|_| Http3Error::IdleTimeout)? + .map_err(|error| Http3Error::Io(error.to_string()))?, + None => future + .await + .map_err(|error| Http3Error::Io(error.to_string()))?, + }; + decode_http_datagram(&encoded, self.context_id, self.maximum_datagram) + } + + pub async fn close(&self, timeout: Duration) -> Result<(), Http3Error> { + self.closed.cancel(); + tokio::time::timeout(timeout, self.io.close()) + .await + .map_err(|_| Http3Error::Io("session close timeout".into()))? + .map_err(|error| Http3Error::Io(error.to_string())) + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 0000000..ff8edc5 --- /dev/null +++ b/src/transport/mod.rs @@ -0,0 +1,353 @@ +//! Common transport boundary and the Milestone 1 direct UDP transport. + +use std::{ + collections::HashMap, future::Future, net::SocketAddr, pin::Pin, sync::Arc, time::Duration, +}; + +use bytes::Bytes; +use thiserror::Error; +use tokio::{ + io::{split, AsyncRead, AsyncWrite}, + net::{TcpListener, TcpStream}, + sync::Mutex, +}; + +pub mod direct_udp; +pub mod http3; +pub mod quic; +pub mod tls_tcp; + +pub use direct_udp::{DirectUdpConnector, DirectUdpServer, DirectUdpSession}; + +pub type TransportFuture<'a, T> = + Pin> + Send + 'a>>; + +/// Errors which are meaningful to the transport supervisor. In particular, +/// authentication rejection is not folded into an ordinary I/O failure. +#[derive(Debug, Error)] +pub enum TransportError { + #[error("transport authentication rejected: {0:?}")] + AuthenticationRejected(crate::protocol::RejectReason), + #[error("transport protocol error: {0}")] + Protocol(#[from] crate::protocol::ProtocolError), + #[error("transport I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("datagram exceeds transport maximum ({maximum} bytes)")] + Oversized { maximum: usize }, + #[error("transport session is closed")] + Closed, + #[error("transport handshake timed out")] + HandshakeTimeout, + #[error("transport is not available: {0}")] + Unsupported(String), +} + +/// Logical datagram session shared by all future carrier implementations. +pub trait TransportSession: Send + Sync { + fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()>; + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes>; + fn close<'a>(&'a self) -> TransportFuture<'a, ()>; + fn max_datagram_size(&self) -> usize; +} + +/// Connection establishment is separate from an established session. +pub trait TransportConnector: Send + Sync { + fn connect<'a>(&'a self) -> TransportFuture<'a, Box>; +} + +type TlsRead = tokio::io::ReadHalf; +type TlsWrite = tokio::io::WriteHalf; +trait AsyncIo: AsyncRead + AsyncWrite + Send + Unpin {} +impl AsyncIo for T {} +type BoxIo = Box; + +struct TlsTcpSession { + reader: Mutex>>, + writer: Mutex>>, + max_datagram: usize, + closed: tokio_util::sync::CancellationToken, +} + +impl TlsTcpSession { + fn from_halves( + reader: TlsRead, + writer: TlsWrite, + max_datagram: usize, + idle_timeout: Duration, + ) -> Self { + Self { + reader: Mutex::new(Some(tls_tcp::FramedReader::new( + reader, + max_datagram, + Some(idle_timeout), + ))), + writer: Mutex::new(Some(tls_tcp::FramedWriter::new(writer, max_datagram))), + max_datagram, + closed: tokio_util::sync::CancellationToken::new(), + } + } +} + +impl TransportSession for TlsTcpSession { + fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> { + Box::pin(async move { + if data.len() > self.max_datagram { + return Err(TransportError::Oversized { + maximum: self.max_datagram, + }); + } + let mut guard = self.writer.lock().await; + let Some(writer) = guard.as_mut() else { + return Err(TransportError::Closed); + }; + writer + .send_datagram(&data) + .await + .map_err(|error| match error { + tls_tcp::SessionError::Frame(tls_tcp::FrameError::Oversized { + maximum, + .. + }) => TransportError::Oversized { maximum }, + tls_tcp::SessionError::Closed => TransportError::Closed, + other => TransportError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + other.to_string(), + )), + }) + }) + } + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> { + Box::pin(async move { + let mut guard = self.reader.lock().await; + let Some(reader) = guard.as_mut() else { + return Err(TransportError::Closed); + }; + reader + .recv_datagram() + .await + .map(Bytes::from) + .map_err(|error| match error { + tls_tcp::SessionError::Closed => TransportError::Closed, + tls_tcp::SessionError::Frame(tls_tcp::FrameError::Oversized { + maximum, + .. + }) => TransportError::Oversized { maximum }, + tls_tcp::SessionError::IdleTimeout => TransportError::Closed, + other => TransportError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + other.to_string(), + )), + }) + }) + } + fn close<'a>(&'a self) -> TransportFuture<'a, ()> { + Box::pin(async move { + if self.closed.is_cancelled() { + return Ok(()); + } + self.closed.cancel(); + let writer = self.writer.lock().await.take(); + if let Some(mut writer) = writer { + let _ = writer.send_close().await; + writer + .shutdown(Duration::from_secs(2)) + .await + .map_err(|error| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + error.to_string(), + )) + })?; + } + Ok(()) + }) + } + fn max_datagram_size(&self) -> usize { + self.max_datagram + } +} + +pub struct TlsTcpConnector { + config: crate::config::ClientConfig, + client_config: Option>, +} +impl TlsTcpConnector { + pub fn new(config: crate::config::ClientConfig) -> Self { + Self { + config, + client_config: None, + } + } + pub fn with_client_config( + config: crate::config::ClientConfig, + client_config: rustls::ClientConfig, + ) -> Self { + Self { + config, + client_config: Some(Arc::new(client_config)), + } + } + async fn connect_session(&self) -> Result, TransportError> { + let host = self.config.client.server.trim(); + let stream = TcpStream::connect((host, self.config.transport.tls_tcp.port)).await?; + let connector_config = match &self.client_config { + Some(config) => config.clone(), + None => { + let mut roots = rustls::RootCertStore::empty(); + let native = rustls_native_certs::load_native_certs(); + for cert in native.certs { + roots.add(cert).map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + e.to_string(), + )) + })?; + } + if roots.is_empty() { + return Err(TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "no system trust roots available", + ))); + } + tls_tcp::client_tls_config(roots) + } + }; + let connector = tokio_rustls::TlsConnector::from(connector_config); + let server_name = + rustls::pki_types::ServerName::try_from(host.to_owned()).map_err(|_| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid TLS server name", + )) + })?; + let tls = connector.connect(server_name, stream).await.map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + e.to_string(), + )) + })?; + let max = usize::from(self.config.network.max_datagram_size); + let secret = self + .config + .client + .secret + .load_bytes("client") + .map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + )) + })?; + let (mut reader, mut writer) = split(Box::new(tls) as BoxIo); + tls_tcp::client_authenticate( + &mut reader, + &mut writer, + &self.config.client.id, + &secret, + max.saturating_add(512), + Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)), + ) + .await + .map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + e.to_string(), + )) + })?; + let session = TlsTcpSession::from_halves(reader, writer, max, Duration::from_secs(120)); + Ok(Box::new(session)) + } +} +impl TransportConnector for TlsTcpConnector { + fn connect<'a>(&'a self) -> TransportFuture<'a, Box> { + Box::pin(async move { self.connect_session().await }) + } +} + +pub struct TlsTcpServer { + listener: TcpListener, + tls: Arc, + clients: Arc>>, + max_datagram: usize, +} +impl TlsTcpServer { + pub async fn bind(config: &crate::config::ServerConfig) -> Result { + let listen = if config.server.listen.parse::().is_ok() { + config.server.listen.clone() + } else { + format!( + "{}:{}", + config.server.listen, config.transports.tls_tcp.port + ) + }; + let 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()) + })?; + let key = config.tls.private_key.as_ref().ok_or_else(|| { + TransportError::Unsupported("TLS/TCP requires tls.certificate/private_key".into()) + })?; + let identity = quic::load_tls_identity(cert, key)?; + let tls = tls_tcp::server_tls_config(identity.0, identity.1).map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + )) + })?; + let mut clients = HashMap::new(); + for client in &config.auth.clients { + clients.insert( + client.id.clone(), + client + .secret + .load_bytes(&format!("auth client {}", client.id)) + .map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + )) + })?, + ); + } + Ok(Self { + listener, + tls, + clients: Arc::new(clients), + max_datagram: 1200, + }) + } + pub fn local_addr(&self) -> Result { + Ok(self.listener.local_addr()?) + } + pub async fn accept_session(&self) -> Result, TransportError> { + let (stream, _) = self.listener.accept().await?; + let acceptor = tokio_rustls::TlsAcceptor::from(self.tls.clone()); + let tls = acceptor.accept(stream).await.map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + e.to_string(), + )) + })?; + let (mut reader, mut writer) = split(Box::new(tls) as BoxIo); + tls_tcp::server_authenticate( + &mut reader, + &mut writer, + &self.clients, + self.max_datagram.saturating_add(512), + Duration::from_secs(3), + ) + .await + .map_err(|e| { + TransportError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + e.to_string(), + )) + })?; + Ok(Box::new(TlsTcpSession::from_halves( + reader, + writer, + self.max_datagram, + Duration::from_secs(120), + ))) + } +} diff --git a/src/transport/quic.rs b/src/transport/quic.rs new file mode 100644 index 0000000..8238c10 --- /dev/null +++ b/src/transport/quic.rs @@ -0,0 +1,600 @@ +//! QUIC DATAGRAM transport. +//! +//! A QUIC connection is the transport session. The only stream used here is +//! the short, reliable control stream used to authenticate a configured +//! 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 bytes::Bytes; +use quinn::{ + ClientConfig as QuinnClientConfig, Connection, Endpoint, ServerConfig as QuinnServerConfig, +}; +use rand::RngCore; +use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; +use subtle::ConstantTimeEq; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info}; + +use crate::{ + config::{ClientConfig, ServerConfig}, + protocol::{auth, RejectReason, PROTOCOL_VERSION}, +}; + +use super::{TransportConnector, TransportError, TransportFuture, TransportSession}; + +const MAGIC: &[u8; 4] = b"WGCQ"; +const CONTROL_VERSION: u8 = PROTOCOL_VERSION; +const MSG_INIT: u8 = 0; +const MSG_CHALLENGE: u8 = 1; +const MSG_RESPONSE: u8 = 2; +const MSG_RESULT: u8 = 3; +const CHALLENGE_LEN: usize = 4 + 1 + 1 + 32; +const RESULT_LEN: usize = 4 + 1 + 1 + 1 + 2; +const MAX_CLIENT_ID: usize = 255; +const DEFAULT_MAX_DATAGRAM: usize = 1200; + +fn io_other(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::Other, message.into()) +} + +fn io_invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn quinn_io(error: E) -> TransportError { + TransportError::Io(io_other(error.to_string())) +} + +fn transport_config(keepalive_secs: u64) -> Arc { + let mut cfg = quinn::TransportConfig::default(); + if keepalive_secs > 0 { + cfg.keep_alive_interval(Some(Duration::from_secs(keepalive_secs))); + } + // A bounded send queue prevents an application producer from retaining + // unbounded memory while the path is congested. + cfg.datagram_send_buffer_size(1024 * 1024); + Arc::new(cfg) +} + +fn apply_transport_config(config: &mut QuinnClientConfig, keepalive_secs: u64) { + config.transport_config(transport_config(keepalive_secs)); +} + +fn apply_server_transport_config(config: &mut QuinnServerConfig, keepalive_secs: u64) { + config.transport_config(transport_config(keepalive_secs)); +} + +/// Read an X.509 chain and private key from normal PEM or DER files. +/// +/// `rustls-pki-types` owns the PEM parser, so no additional PEM dependency is +/// needed. DER fallback is useful for deployments that provision `.der` +/// files directly. +pub fn load_tls_identity( + certificate: impl AsRef, + private_key: impl AsRef, +) -> Result<(Vec>, PrivateKeyDer<'static>), TransportError> { + let cert_path = certificate.as_ref(); + let key_path = private_key.as_ref(); + let cert_bytes = fs::read(cert_path).map_err(TransportError::Io)?; + let certs: Vec> = if cert_bytes.starts_with(b"-----BEGIN") { + CertificateDer::pem_slice_iter(&cert_bytes) + .collect::, _>>() + .map_err(|e| io_invalid(format!("invalid certificate PEM: {e}")))? + } else if cert_bytes.is_empty() { + return Err(TransportError::Io(io_invalid("certificate file is empty"))); + } else { + vec![CertificateDer::from(cert_bytes)] + }; + if certs.is_empty() { + return Err(TransportError::Io(io_invalid( + "certificate PEM contains no certificate", + ))); + } + + let key_bytes = fs::read(key_path).map_err(TransportError::Io)?; + let key = if key_bytes.starts_with(b"-----BEGIN") { + PrivateKeyDer::from_pem_slice(&key_bytes) + .map_err(|e| io_invalid(format!("invalid private-key PEM: {e}")))? + } else { + PrivateKeyDer::try_from(key_bytes.as_slice()) + .map_err(|_| io_invalid("private key is not a supported DER key"))? + .clone_key() + }; + Ok((certs, key)) +} + +fn server_config( + config: &ServerConfig, + keepalive_secs: u64, +) -> Result { + let certificate = config.tls.certificate.as_ref().ok_or_else(|| { + TransportError::Io(io_invalid( + "QUIC requires tls.certificate and tls.private_key", + )) + })?; + let private_key = config.tls.private_key.as_ref().ok_or_else(|| { + TransportError::Io(io_invalid( + "QUIC requires tls.certificate and tls.private_key", + )) + })?; + 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); + Ok(result) +} + +fn client_config() -> Result { + // Quinn's default build enables rustls' platform verifier. This uses the + // operating system trust store and performs ordinary hostname validation. + QuinnClientConfig::try_with_platform_verifier() + .map_err(|e| io_invalid(format!("cannot construct TLS verifier: {e}"))) + .map_err(TransportError::Io) +} + +fn resolve_server(host: &str, port: u16) -> Result { + let mut addrs = (host, port).to_socket_addrs().map_err(TransportError::Io)?; + addrs + .next() + .ok_or_else(|| TransportError::Io(io_invalid("server hostname resolved to no address"))) +} + +fn resolve_listen(host: &str, port: u16) -> Result { + let address = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + }; + address + .parse::() + .map_err(|e| TransportError::Io(io_invalid(format!("invalid QUIC listen address: {e}")))) +} + +fn write_header(kind: u8, out: &mut Vec) { + out.extend_from_slice(MAGIC); + out.push(CONTROL_VERSION); + out.push(kind); +} + +async fn authenticate_client( + connection: &Connection, + client_id: &str, + secret: &[u8], + timeout_duration: Duration, +) -> Result { + let (mut send, mut recv) = timeout(timeout_duration, connection.open_bi()) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let mut init = Vec::with_capacity(6); + write_header(MSG_INIT, &mut init); + timeout(timeout_duration, send.write_all(&init)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let mut challenge_msg = [0u8; CHALLENGE_LEN]; + timeout(timeout_duration, recv.read_exact(&mut challenge_msg)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + if &challenge_msg[..4] != MAGIC + || challenge_msg[4] != CONTROL_VERSION + || challenge_msg[5] != MSG_CHALLENGE + { + return Err(TransportError::Io(io_invalid( + "invalid QUIC authentication challenge", + ))); + } + let mut challenge = [0u8; 32]; + challenge.copy_from_slice(&challenge_msg[6..]); + let mut nonce = [0u8; 16]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let id = client_id.as_bytes(); + if id.is_empty() || id.len() > MAX_CLIENT_ID { + return Err(TransportError::Io(io_invalid( + "QUIC client id length out of range", + ))); + } + let mut mac_input = Vec::with_capacity(32 + 16 + id.len()); + mac_input.extend_from_slice(&challenge); + mac_input.extend_from_slice(&nonce); + mac_input.extend_from_slice(id); + let tag = auth::hmac_sha256(secret, &mac_input); + let mut response = Vec::with_capacity(8 + id.len() + 16 + 32); + write_header(MSG_RESPONSE, &mut response); + response.extend_from_slice(&(id.len() as u16).to_be_bytes()); + response.extend_from_slice(id); + response.extend_from_slice(&nonce); + response.extend_from_slice(&tag); + timeout(timeout_duration, send.write_all(&response)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + send.finish().map_err(quinn_io)?; + + let mut result = [0u8; RESULT_LEN]; + timeout(timeout_duration, recv.read_exact(&mut result)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + if &result[..4] != MAGIC || result[4] != CONTROL_VERSION || result[5] != MSG_RESULT { + return Err(TransportError::Io(io_invalid( + "invalid QUIC authentication result", + ))); + } + if result[6] != 0 { + return Err(TransportError::AuthenticationRejected( + RejectReason::AuthenticationFailed, + )); + } + let remote_max = u16::from_be_bytes([result[7], result[8]]) as usize; + if remote_max == 0 { + return Err(TransportError::Io(io_invalid( + "server reported zero QUIC datagram maximum", + ))); + } + Ok(remote_max) +} + +/// An authenticated QUIC DATAGRAM session. +pub struct QuicDatagramSession { + connection: Connection, + endpoint: Endpoint, + max_datagram: usize, + closed: CancellationToken, +} + +impl QuicDatagramSession { + fn new(connection: Connection, endpoint: Endpoint, max_datagram: usize) -> Self { + Self { + connection, + endpoint, + max_datagram, + closed: CancellationToken::new(), + } + } + + /// Underlying QUIC connection, useful for diagnostics and RTT metrics. + pub fn connection(&self) -> &Connection { + &self.connection + } +} + +impl TransportSession for QuicDatagramSession { + fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> { + Box::pin(async move { + if self.closed.is_cancelled() { + return Err(TransportError::Closed); + } + if data.len() > self.max_datagram { + return Err(TransportError::Oversized { + maximum: self.max_datagram, + }); + } + let quic_max = self + .connection + .max_datagram_size() + .unwrap_or(self.max_datagram); + if data.len() > quic_max { + return Err(TransportError::Oversized { maximum: quic_max }); + } + self.connection.send_datagram(data).map_err(|e| match e { + quinn::SendDatagramError::TooLarge => { + TransportError::Oversized { maximum: quic_max } + } + quinn::SendDatagramError::ConnectionLost(_) => TransportError::Closed, + other => quinn_io(other), + }) + }) + } + + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> { + Box::pin(async move { + loop { + let datagram = tokio::select! { + _ = self.closed.cancelled() => return Err(TransportError::Closed), + result = self.connection.read_datagram() => result.map_err(quinn_io)?, + }; + // A peer may have negotiated a larger QUIC limit than the + // project policy advertised during authentication. Enforce + // that project limit on receives as well as sends. + if datagram.len() <= self.max_datagram { + return Ok(datagram); + } + } + }) + } + + fn close<'a>(&'a self) -> TransportFuture<'a, ()> { + Box::pin(async move { + if !self.closed.is_cancelled() { + self.closed.cancel(); + self.connection.close(0u32.into(), b"closed"); + // Dropping the endpoint handle is intentionally deferred to + // session drop; this keeps the connection driver alive while + // the close frame is emitted. + let _ = &self.endpoint; + } + Ok(()) + }) + } + + fn max_datagram_size(&self) -> usize { + self.max_datagram + } +} + +/// Client-side QUIC connector. +pub struct QuicClientConnector { + config: ClientConfig, + client_config: Option, +} + +/// Short aliases for callers that prefer the role names used by the +/// configuration and listener model. +pub type QuicClient = QuicClientConnector; +pub type QuicSession = QuicDatagramSession; + +impl QuicClientConnector { + pub fn new(config: ClientConfig) -> Self { + Self { + config, + client_config: None, + } + } + + /// Use a caller-supplied rustls-backed Quinn config (for example a test + /// trust store). Production callers should use [`Self::new`], which uses + /// the platform trust store and hostname verification. + pub fn with_client_config(config: ClientConfig, client_config: QuinnClientConfig) -> Self { + Self { + config, + client_config: Some(client_config), + } + } + + pub async fn connect_session(&self) -> Result { + let host = self.config.client.server.trim(); + let addr = resolve_server(host, self.config.transport.quic.port)?; + let mut endpoint = Endpoint::client("[::]:0".parse().expect("valid wildcard address")) + .map_err(TransportError::Io)?; + let mut cfg = match self.client_config.clone() { + Some(cfg) => cfg, + None => client_config()?, + }; + 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 connection = timeout( + Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)), + connecting, + ) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let secret = self + .config + .client + .secret + .load_bytes("client") + .map_err(|e| TransportError::Io(io_invalid(e.to_string())))?; + let server_max = authenticate_client( + &connection, + &self.config.client.id, + &secret, + Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)), + ) + .await?; + let quic_max = connection + .max_datagram_size() + .ok_or_else(|| TransportError::Io(io_invalid("QUIC DATAGRAM is not negotiated")))?; + let max_datagram = usize::from(self.config.network.max_datagram_size) + .min(server_max) + .min(quic_max); + if max_datagram == 0 { + return Err(TransportError::Io(io_invalid( + "negotiated QUIC datagram maximum is zero", + ))); + } + info!(remote_addr=%addr, max_datagram, "QUIC session authenticated"); + Ok(QuicDatagramSession::new(connection, endpoint, max_datagram)) + } + + pub async fn connect(&self) -> Result, TransportError> { + Ok(Box::new(self.connect_session().await?)) + } +} + +impl TransportConnector for QuicClientConnector { + fn connect<'a>(&'a self) -> TransportFuture<'a, Box> { + Box::pin(async move { self.connect().await }) + } +} + +/// Multi-client QUIC listener. Authentication failures are returned to the +/// caller as `AuthenticationRejected`; the endpoint itself remains usable for +/// subsequent clients. +pub struct QuicServer { + endpoint: Endpoint, + clients: Arc>>, + max_datagram: usize, + handshake_timeout: Duration, +} + +/// Alias matching the listener terminology used in the specification. +pub type QuicListener = QuicServer; + +impl QuicServer { + pub async fn bind(config: &ServerConfig) -> Result { + let addr = resolve_listen(&config.server.listen, config.transports.quic.port)?; + let tls = server_config(config, 15); + let endpoint = Endpoint::server(tls?, addr).map_err(TransportError::Io)?; + let mut clients = HashMap::new(); + for client in &config.auth.clients { + let secret = client + .secret + .load_bytes(&format!("auth client {}", client.id)) + .map_err(|e| TransportError::Io(io_invalid(e.to_string())))?; + clients.insert(client.id.clone(), secret); + } + Ok(Self { + endpoint, + clients: Arc::new(clients), + max_datagram: DEFAULT_MAX_DATAGRAM, + handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)), + }) + } + + /// Build a listener from an already configured endpoint. This keeps unit + /// tests and embedders independent of certificate file provisioning. + pub fn from_endpoint( + endpoint: Endpoint, + clients: HashMap>, + max_datagram: usize, + handshake_timeout: Duration, + ) -> Self { + Self { + endpoint, + clients: Arc::new(clients), + max_datagram: max_datagram.max(1), + handshake_timeout, + } + } + + pub fn local_addr(&self) -> Result { + self.endpoint.local_addr().map_err(TransportError::Io) + } + + pub fn endpoint(&self) -> &Endpoint { + &self.endpoint + } + + pub async fn accept_session(&self) -> Result { + let incoming = self.endpoint.accept().await.ok_or(TransportError::Closed)?; + let connection = timeout(self.handshake_timeout, incoming) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let (mut send, mut recv) = timeout(self.handshake_timeout, connection.accept_bi()) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let mut init = [0u8; 6]; + timeout(self.handshake_timeout, recv.read_exact(&mut init)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + if &init[..4] != MAGIC || init[4] != CONTROL_VERSION || init[5] != MSG_INIT { + return Err(TransportError::Io(io_invalid( + "invalid QUIC authentication preface", + ))); + } + let mut challenge = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut challenge); + let mut challenge_msg = Vec::with_capacity(CHALLENGE_LEN); + write_header(MSG_CHALLENGE, &mut challenge_msg); + challenge_msg.extend_from_slice(&challenge); + timeout(self.handshake_timeout, send.write_all(&challenge_msg)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + let response = timeout( + self.handshake_timeout, + recv.read_to_end(4 + 1 + 1 + 2 + MAX_CLIENT_ID + 16 + 32), + ) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + if response.len() < 8 { + return Err(TransportError::Io(io_invalid( + "truncated QUIC authentication response", + ))); + } + if &response[..4] != MAGIC || response[4] != CONTROL_VERSION || response[5] != MSG_RESPONSE + { + return Err(TransportError::Io(io_invalid( + "invalid QUIC authentication response", + ))); + } + let id_len = u16::from_be_bytes([response[6], response[7]]) as usize; + if id_len == 0 || id_len > MAX_CLIENT_ID || response.len() != 8 + id_len + 16 + 32 { + return Err(TransportError::Io(io_invalid( + "invalid QUIC client id length", + ))); + } + let id_bytes = &response[8..8 + id_len]; + let id = std::str::from_utf8(id_bytes).map_err(|_| { + TransportError::AuthenticationRejected(RejectReason::AuthenticationFailed) + })?; + let Some(secret) = self.clients.get(id) else { + let _ = send_result(&mut send, 1, 0, self.handshake_timeout).await; + // Give the endpoint driver a chance to transmit the rejection + // before this per-connection handler drops its stream. + tokio::time::sleep(Duration::from_millis(10)).await; + return Err(TransportError::AuthenticationRejected( + RejectReason::AuthenticationFailed, + )); + }; + let nonce_at = 8 + id_len; + let mut nonce = [0u8; 16]; + nonce.copy_from_slice(&response[nonce_at..nonce_at + 16]); + let mut input = Vec::with_capacity(32 + 16 + id_len); + input.extend_from_slice(&challenge); + input.extend_from_slice(&nonce); + input.extend_from_slice(id_bytes); + let expected = auth::hmac_sha256(secret, &input); + if !bool::from(expected.as_slice().ct_eq(&response[nonce_at + 16..])) { + let _ = send_result(&mut send, 1, 0, self.handshake_timeout).await; + tokio::time::sleep(Duration::from_millis(10)).await; + return Err(TransportError::AuthenticationRejected( + RejectReason::AuthenticationFailed, + )); + } + let quic_max = connection + .max_datagram_size() + .ok_or_else(|| TransportError::Io(io_invalid("QUIC DATAGRAM is not negotiated")))?; + let max_datagram = self.max_datagram.min(quic_max).min(u16::MAX as usize); + send_result(&mut send, 0, max_datagram as u16, self.handshake_timeout).await?; + debug!(client_id=%id, remote_addr=%connection.remote_address(), max_datagram, "QUIC client authenticated"); + Ok(QuicDatagramSession::new( + connection, + self.endpoint.clone(), + max_datagram, + )) + } + + pub async fn accept(&self) -> Result { + self.accept_session().await + } + + pub fn close(&self) { + self.endpoint.close(0u32.into(), b"server stopped"); + } +} + +async fn send_result( + send: &mut quinn::SendStream, + status: u8, + max_datagram: u16, + duration: Duration, +) -> Result<(), TransportError> { + let mut result = Vec::with_capacity(RESULT_LEN); + write_header(MSG_RESULT, &mut result); + result.push(status); + result.extend_from_slice(&max_datagram.to_be_bytes()); + timeout(duration, send.write_all(&result)) + .await + .map_err(|_| TransportError::HandshakeTimeout)? + .map_err(quinn_io)?; + send.finish().map_err(quinn_io) +} diff --git a/src/transport/tls_tcp.rs b/src/transport/tls_tcp.rs new file mode 100644 index 0000000..b4f6472 --- /dev/null +++ b/src/transport/tls_tcp.rs @@ -0,0 +1,643 @@ +//! TLS/TCP fallback building blocks. +//! +//! TCP has no datagram boundaries, so this module supplies a bounded, big-endian +//! u16 length codec and an authenticated framed session. The framing/session +//! code is intentionally independent of the async TLS adapter: the repository +//! currently does not depend on `tokio-rustls`. Callers can wrap a Tokio stream +//! with `tokio_rustls::TlsConnector`/`TlsAcceptor`, then pass the resulting +//! stream halves to these helpers. + +use std::{collections::HashMap, fmt, io, path::Path, sync::Arc, time::Duration}; + +use base64::Engine; +use hmac::{Hmac, Mac}; +use rand::{rngs::OsRng, RngCore}; +use rustls::{ + pki_types::{ + CertificateDer, PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer, + }, + ClientConfig, RootCertStore, ServerConfig, +}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const MAX_WIRE_FRAME: usize = u16::MAX as usize; +const AUTH_VERSION: u8 = 1; +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; +const DATA_KIND: u8 = 0x01; +const KEEPALIVE_KIND: u8 = 0x02; +const CLOSE_KIND: u8 = 0x03; +const AUTH_CHALLENGE_KIND: u8 = 0x10; +const AUTH_RESPONSE_KIND: u8 = 0x11; +const AUTH_ACCEPT_KIND: u8 = 0x12; +const AUTH_REJECT_KIND: u8 = 0x13; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + MaxTooLarge, + Oversized { length: usize, maximum: usize }, + Truncated, + Io(String), + InvalidControl, +} + +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MaxTooLarge => write!(f, "frame maximum exceeds u16 wire limit"), + Self::Oversized { length, maximum } => { + write!( + f, + "frame length {length} exceeds configured maximum {maximum}" + ) + } + Self::Truncated => write!(f, "truncated frame"), + Self::Io(error) => write!(f, "I/O error: {error}"), + Self::InvalidControl => write!(f, "invalid TLS/TCP control frame"), + } + } +} + +impl std::error::Error for FrameError {} + +impl From for FrameError { + fn from(error: io::Error) -> Self { + Self::Io(error.to_string()) + } +} + +/// Incremental u16-big-endian frame decoder. +/// +/// It consumes only the declared payload length and rejects an oversized +/// header before allocating a payload buffer. Consequently, arbitrary TCP +/// chunks, coalesced frames, and a truncated final frame are handled without +/// relying on `read()` boundaries. +#[derive(Debug)] +pub struct FrameCodec { + maximum: usize, + header: [u8; 2], + header_len: usize, + payload: Option<(Vec, usize)>, +} + +impl FrameCodec { + pub fn new(maximum: usize) -> Result { + if maximum > MAX_WIRE_FRAME { + return Err(FrameError::MaxTooLarge); + } + Ok(Self { + maximum, + header: [0; 2], + header_len: 0, + payload: None, + }) + } + + pub fn maximum(&self) -> usize { + self.maximum + } + + pub fn encode(&self, payload: &[u8]) -> Result, FrameError> { + if payload.len() > self.maximum || payload.len() > MAX_WIRE_FRAME { + return Err(FrameError::Oversized { + length: payload.len(), + maximum: self.maximum, + }); + } + let mut frame = Vec::with_capacity(payload.len() + 2); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + Ok(frame) + } + + pub fn feed(&mut self, mut input: &[u8]) -> Result>, FrameError> { + let mut frames = Vec::new(); + while !input.is_empty() { + if self.header_len < 2 { + let count = (2 - self.header_len).min(input.len()); + self.header[self.header_len..self.header_len + count] + .copy_from_slice(&input[..count]); + self.header_len += count; + input = &input[count..]; + if self.header_len < 2 { + break; + } + + let length = u16::from_be_bytes(self.header) as usize; + if length > self.maximum { + self.reset(); + return Err(FrameError::Oversized { + length, + maximum: self.maximum, + }); + } + self.payload = Some((vec![0; length], 0)); + } + + let Some((payload, filled)) = self.payload.as_mut() else { + return Err(FrameError::InvalidControl); + }; + let count = (payload.len() - *filled).min(input.len()); + payload[*filled..*filled + count].copy_from_slice(&input[..count]); + *filled += count; + input = &input[count..]; + if *filled == payload.len() { + let (payload, _) = self.payload.take().expect("payload exists"); + frames.push(payload); + self.header_len = 0; + } + } + Ok(frames) + } + + pub fn finish(&self) -> Result<(), FrameError> { + if self.header_len != 0 || self.payload.is_some() { + Err(FrameError::Truncated) + } else { + Ok(()) + } + } + + fn reset(&mut self) { + self.header_len = 0; + self.payload = None; + } +} + +pub async fn read_frame( + reader: &mut R, + maximum: usize, +) -> Result, FrameError> { + let codec = FrameCodec::new(maximum)?; + let mut header = [0u8; 2]; + reader + .read_exact(&mut header) + .await + .map_err(map_read_error)?; + let length = u16::from_be_bytes(header) as usize; + if length > codec.maximum() { + return Err(FrameError::Oversized { length, maximum }); + } + let mut payload = vec![0u8; length]; + reader + .read_exact(&mut payload) + .await + .map_err(map_read_error)?; + Ok(payload) +} + +fn map_read_error(error: io::Error) -> FrameError { + if error.kind() == io::ErrorKind::UnexpectedEof { + FrameError::Truncated + } else { + FrameError::Io(error.to_string()) + } +} + +pub async fn write_frame( + writer: &mut W, + payload: &[u8], + maximum: usize, +) -> Result<(), FrameError> { + let codec = FrameCodec::new(maximum)?; + let frame = codec.encode(payload)?; + writer.write_all(&frame).await?; + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthChallenge { + pub bytes: [u8; CHALLENGE_LEN], +} + +impl AuthChallenge { + pub fn random() -> Self { + let mut bytes = [0u8; CHALLENGE_LEN]; + OsRng.fill_bytes(&mut bytes); + Self { bytes } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedClient { + pub id: String, + pub nonce: [u8; CLIENT_NONCE_LEN], +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + Frame(FrameError), + InvalidMessage, + Rejected, + UnknownClient, + InvalidCredentials, +} + +impl fmt::Display for AuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => error.fmt(f), + Self::InvalidMessage => write!(f, "invalid authentication message"), + Self::Rejected => write!(f, "server rejected authentication"), + Self::UnknownClient => write!(f, "unknown client"), + Self::InvalidCredentials => write!(f, "invalid client credentials"), + } + } +} + +impl std::error::Error for AuthError {} + +impl From for AuthError { + fn from(error: FrameError) -> Self { + Self::Frame(error) + } +} + +fn challenge_mac( + challenge: &[u8; CHALLENGE_LEN], + client_id: &[u8], + client_nonce: &[u8; CLIENT_NONCE_LEN], + secret: &[u8], +) -> [u8; MAC_LEN] { + let mut input = Vec::with_capacity(1 + CHALLENGE_LEN + 1 + client_id.len() + CLIENT_NONCE_LEN); + input.push(AUTH_VERSION); + input.extend_from_slice(challenge); + input.push(client_id.len() as u8); + input.extend_from_slice(client_id); + input.extend_from_slice(client_nonce); + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts every key length"); + mac.update(&input); + mac.finalize().into_bytes().into() +} + +pub fn encode_auth_response( + challenge: &AuthChallenge, + client_id: &str, + client_nonce: &[u8; CLIENT_NONCE_LEN], + secret: &[u8], +) -> Result, AuthError> { + let id = client_id.as_bytes(); + if id.is_empty() || id.len() > MAX_CLIENT_ID { + return Err(AuthError::InvalidMessage); + } + let mac = challenge_mac(&challenge.bytes, id, client_nonce, secret); + let mut message = Vec::with_capacity(1 + 1 + id.len() + CLIENT_NONCE_LEN + MAC_LEN); + message.push(AUTH_RESPONSE_KIND); + message.push(id.len() as u8); + message.extend_from_slice(id); + message.extend_from_slice(client_nonce); + message.extend_from_slice(&mac); + Ok(message) +} + +fn decode_auth_response( + challenge: &AuthChallenge, + message: &[u8], + clients: &HashMap>, +) -> Result { + if message.len() < 2 + CLIENT_NONCE_LEN + MAC_LEN || message[0] != AUTH_RESPONSE_KIND { + return Err(AuthError::InvalidMessage); + } + let id_len = message[1] as usize; + let expected = 2 + id_len + CLIENT_NONCE_LEN + MAC_LEN; + if id_len == 0 || id_len > MAX_CLIENT_ID || expected != message.len() { + return Err(AuthError::InvalidMessage); + } + let id = std::str::from_utf8(&message[2..2 + id_len]).map_err(|_| AuthError::InvalidMessage)?; + let mut nonce = [0u8; CLIENT_NONCE_LEN]; + nonce.copy_from_slice(&message[2 + id_len..2 + id_len + CLIENT_NONCE_LEN]); + let mut received = [0u8; MAC_LEN]; + received.copy_from_slice(&message[2 + id_len + CLIENT_NONCE_LEN..]); + let Some(secret) = clients.get(id) else { + return Err(AuthError::UnknownClient); + }; + let expected_mac = challenge_mac(&challenge.bytes, id.as_bytes(), &nonce, secret); + if !bool::from(expected_mac.ct_eq(&received)) { + return Err(AuthError::InvalidCredentials); + } + Ok(AuthenticatedClient { + id: id.to_owned(), + nonce, + }) +} + +/// Perform the server side challenge/response exchange over an already TLS +/// protected stream. Backend allocation must happen only after this returns. +pub async fn server_authenticate( + reader: &mut R, + writer: &mut W, + clients: &HashMap>, + maximum: usize, + timeout: Duration, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let challenge = AuthChallenge::random(); + let mut message = Vec::with_capacity(1 + CHALLENGE_LEN); + message.push(AUTH_CHALLENGE_KIND); + message.extend_from_slice(&challenge.bytes); + tokio::time::timeout(timeout, write_frame(writer, &message, maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + let response = tokio::time::timeout(timeout, read_frame(reader, maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + let result = decode_auth_response(&challenge, &response, clients); + let outcome = if result.is_ok() { + AUTH_ACCEPT_KIND + } else { + // Keep rejection details on the server; expose only a generic class. + AUTH_REJECT_KIND + }; + tokio::time::timeout(timeout, write_frame(writer, &[outcome], maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + result +} + +/// Perform the client side challenge/response exchange over an already TLS +/// protected stream. +pub async fn client_authenticate( + reader: &mut R, + writer: &mut W, + client_id: &str, + secret: &[u8], + maximum: usize, + timeout: Duration, +) -> Result<(), AuthError> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let challenge = tokio::time::timeout(timeout, read_frame(reader, maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + if challenge.len() != 1 + CHALLENGE_LEN || challenge[0] != AUTH_CHALLENGE_KIND { + return Err(AuthError::InvalidMessage); + } + let mut bytes = [0u8; CHALLENGE_LEN]; + bytes.copy_from_slice(&challenge[1..]); + let challenge = AuthChallenge { bytes }; + let mut nonce = [0u8; CLIENT_NONCE_LEN]; + OsRng.fill_bytes(&mut nonce); + let response = encode_auth_response(&challenge, client_id, &nonce, secret)?; + tokio::time::timeout(timeout, write_frame(writer, &response, maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + let outcome = tokio::time::timeout(timeout, read_frame(reader, maximum)) + .await + .map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??; + match outcome.as_slice() { + [AUTH_ACCEPT_KIND] => Ok(()), + [AUTH_REJECT_KIND] => Err(AuthError::Rejected), + _ => Err(AuthError::InvalidMessage), + } +} + +#[derive(Debug)] +pub enum SessionError { + Frame(FrameError), + IdleTimeout, + Closed, + InvalidMessage, +} + +impl fmt::Display for SessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Frame(error) => error.fmt(f), + Self::IdleTimeout => write!(f, "session idle timeout"), + Self::Closed => write!(f, "session closed"), + Self::InvalidMessage => write!(f, "invalid session message"), + } + } +} + +impl std::error::Error for SessionError {} + +impl From for SessionError { + fn from(error: FrameError) -> Self { + Self::Frame(error) + } +} + +impl From for SessionError { + fn from(error: io::Error) -> Self { + Self::Frame(FrameError::from(error)) + } +} + +/// Read side of an authenticated framed session. Data frames have a one-byte +/// kind prefix so zero-length UDP datagrams remain distinguishable from +/// keepalive frames. +pub struct FramedReader { + reader: R, + maximum_datagram: usize, + idle_timeout: Option, +} + +impl FramedReader { + pub fn new(reader: R, maximum_datagram: usize, idle_timeout: Option) -> Self { + Self { + reader, + maximum_datagram, + idle_timeout, + } + } + + pub async fn recv_datagram(&mut self) -> Result, SessionError> { + loop { + let future = read_frame(&mut self.reader, self.maximum_datagram.saturating_add(1)); + let frame = match self.idle_timeout { + Some(timeout) => tokio::time::timeout(timeout, future) + .await + .map_err(|_| SessionError::IdleTimeout)??, + None => future.await?, + }; + let Some((&kind, payload)) = frame.split_first() else { + return Err(SessionError::InvalidMessage); + }; + match kind { + DATA_KIND if payload.len() <= self.maximum_datagram => return Ok(payload.to_vec()), + DATA_KIND => { + return Err(SessionError::Frame(FrameError::Oversized { + length: payload.len(), + maximum: self.maximum_datagram, + })) + } + KEEPALIVE_KIND if payload.is_empty() => continue, + CLOSE_KIND if payload.is_empty() => return Err(SessionError::Closed), + _ => return Err(SessionError::InvalidMessage), + } + } + } + + pub fn into_inner(self) -> R { + self.reader + } +} + +pub struct FramedWriter { + writer: W, + maximum_datagram: usize, +} + +impl FramedWriter { + pub fn new(writer: W, maximum_datagram: usize) -> Self { + Self { + writer, + maximum_datagram, + } + } + + pub async fn send_datagram(&mut self, payload: &[u8]) -> Result<(), SessionError> { + if payload.len() > self.maximum_datagram { + return Err(SessionError::Frame(FrameError::Oversized { + length: payload.len(), + maximum: self.maximum_datagram, + })); + } + let mut frame = Vec::with_capacity(payload.len() + 1); + frame.push(DATA_KIND); + frame.extend_from_slice(payload); + write_frame( + &mut self.writer, + &frame, + self.maximum_datagram.saturating_add(1), + ) + .await?; + Ok(()) + } + + pub async fn send_keepalive(&mut self) -> Result<(), SessionError> { + write_frame( + &mut self.writer, + &[KEEPALIVE_KIND], + self.maximum_datagram.saturating_add(1), + ) + .await?; + Ok(()) + } + + pub async fn send_close(&mut self) -> Result<(), SessionError> { + write_frame( + &mut self.writer, + &[CLOSE_KIND], + self.maximum_datagram.saturating_add(1), + ) + .await?; + Ok(()) + } + + pub async fn shutdown(mut self, timeout: Duration) -> Result<(), SessionError> { + tokio::time::timeout(timeout, self.writer.shutdown()) + .await + .map_err(|_| SessionError::Frame(FrameError::Io("shutdown timeout".into())))??; + Ok(()) + } + + pub fn into_inner(self) -> W { + self.writer + } +} + +/// Build a client config with ordinary root-certificate verification. The +/// caller supplies roots from the host policy or a CA bundle; an empty store +/// is intentionally accepted by the type but will reject all certificates. +pub fn client_tls_config(roots: RootCertStore) -> Arc { + Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) +} + +pub fn server_tls_config( + certificates: Vec>, + private_key: PrivateKeyDer<'static>, +) -> Result, rustls::Error> { + Ok(Arc::new( + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certificates, private_key)?, + )) +} + +pub fn load_certificates(path: impl AsRef) -> io::Result>> { + let bytes = std::fs::read(path)?; + if bytes.starts_with(b"-----BEGIN") { + parse_pem_blocks(&bytes, "CERTIFICATE") + .map(|blocks| blocks.into_iter().map(CertificateDer::from).collect()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + } else { + Ok(vec![CertificateDer::from(bytes)]) + } +} + +pub fn load_private_key(path: impl AsRef) -> io::Result> { + let bytes = std::fs::read(path)?; + if !bytes.starts_with(b"-----BEGIN") { + return Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes))); + } + if let Ok(blocks) = parse_pem_blocks(&bytes, "PRIVATE KEY") { + return blocks + .into_iter() + .next() + .map(|block| PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(block))) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty private key PEM")); + } + if let Ok(blocks) = parse_pem_blocks(&bytes, "RSA PRIVATE KEY") { + return blocks + .into_iter() + .next() + .map(|block| PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from(block))) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty RSA key PEM")); + } + if let Ok(blocks) = parse_pem_blocks(&bytes, "EC PRIVATE KEY") { + return blocks + .into_iter() + .next() + .map(|block| PrivateKeyDer::Sec1(PrivateSec1KeyDer::from(block))) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty EC key PEM")); + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported private key PEM (expected PRIVATE KEY, RSA PRIVATE KEY, or EC PRIVATE KEY)", + )) +} + +fn parse_pem_blocks(input: &[u8], label: &str) -> Result>, String> { + let begin = format!("-----BEGIN {label}-----"); + let end = format!("-----END {label}-----"); + let text = std::str::from_utf8(input).map_err(|_| "PEM is not UTF-8".to_owned())?; + let mut rest = text; + let mut blocks = Vec::new(); + while let Some(start) = rest.find(&begin) { + rest = &rest[start + begin.len()..]; + let stop = rest + .find(&end) + .ok_or_else(|| "unterminated PEM block".to_owned())?; + let encoded: String = rest[..stop] + .chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| "invalid PEM base64".to_owned())?; + blocks.push(decoded); + rest = &rest[stop + end.len()..]; + } + if blocks.is_empty() { + Err(format!("no {label} PEM block found")) + } else { + Ok(blocks) + } +} diff --git a/tests/control.rs b/tests/control.rs new file mode 100644 index 0000000..c498d68 --- /dev/null +++ b/tests/control.rs @@ -0,0 +1,45 @@ +#[path = "../src/control.rs"] +mod control; + +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; +use tokio::time::{sleep, Duration}; + +#[tokio::test] +async fn control_commands_and_socket_permissions() { + let dir = tempdir().unwrap(); + let path = dir.path().join("control.sock"); + let state = control::ControlState::default(); + state.set_status("ready").await; + state.set_stats_json("{\"packets\":3}").await; + let server = control::ControlServer::bind(&path, state.clone()) + .await + .unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let task = tokio::spawn(server.run()); + sleep(Duration::from_millis(10)).await; + assert!(control::send_command(&path, "status") + .await + .unwrap() + .contains("ready")); + assert!(control::send_command(&path, "stats") + .await + .unwrap() + .contains("packets")); + assert!(control::send_command(&path, "reconnect") + .await + .unwrap() + .contains("true")); + assert!(control::send_command(&path, "reload") + .await + .unwrap() + .contains("true")); + assert!(control::send_command(&path, "shutdown") + .await + .unwrap() + .contains("true")); + task.await.unwrap().unwrap(); +} diff --git a/tests/direct_udp.rs b/tests/direct_udp.rs new file mode 100644 index 0000000..1593e1f --- /dev/null +++ b/tests/direct_udp.rs @@ -0,0 +1,295 @@ +#[path = "../src/config.rs"] +mod config; +#[path = "../src/datagram.rs"] +mod datagram; +#[path = "../src/protocol/mod.rs"] +mod protocol; +#[path = "../src/stats.rs"] +mod stats; +#[path = "../src/transport/mod.rs"] +mod transport; + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use bytes::Bytes; +use config::Config; +use tokio::{net::UdpSocket, time::timeout}; +use transport::{ + direct_udp::{DirectUdpServer, DirectUdpSession}, + TransportError, TransportSession, +}; + +const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +fn configs(port: u16, backend: u16) -> (config::ServerConfig, config::ClientConfig) { + let server = Config::from_str(&format!( + "[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n" + )).unwrap().into_server().unwrap(); + let client = Config::from_str(&format!( + "[client]\nserver = \"127.0.0.1\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n" + )).unwrap().into_client().unwrap(); + (server, client) +} + +fn multi_configs( + port: u16, + backend: u16, +) -> ( + config::ServerConfig, + config::ClientConfig, + config::ClientConfig, +) { + let server = Config::from_str(&format!( + "[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"one\"\nsecret = \"{SECRET}\"\n[[auth.clients]]\nid = \"two\"\nsecret = \"{SECRET}\"\n" + )).unwrap().into_server().unwrap(); + let client = |id: &str| { + Config::from_str(&format!( + "[client]\nserver = \"127.0.0.1\"\nid = \"{id}\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n" + )).unwrap().into_client().unwrap() + }; + (server, client("one"), client("two")) +} + +#[tokio::test] +async fn direct_udp_round_trip_and_auth_rejection() { + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let port = 45781; + let (server_config, client_config) = configs(port, backend_addr.port()); + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let stats = server.stats(); + let server_task = tokio::spawn(server.run()); + let backend_task = tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let (len, peer) = backend.recv_from(&mut buf).await.unwrap(); + backend.send_to(&buf[..len], peer).await.unwrap(); + }); + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + session + .send_datagram(Bytes::from_static(b"opaque")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), session.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"opaque") + ); + let mut wrong = client_config.clone(); + wrong.client.secret.secret = + Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned()); + let result = DirectUdpSession::connect(&wrong).await; + assert!(matches!( + result, + Err(TransportError::AuthenticationRejected(_)) + )); + session.close().await.unwrap(); + backend_task.await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(stats.snapshot().expired_sessions, 0); + server_task.abort(); +} + +#[tokio::test] +async fn direct_udp_enforces_payload_limit_and_expires_idle_session() { + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (mut server_config, client_config) = configs(45782, backend_addr.port()); + server_config.session.idle_timeout_secs = 1; + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let stats = server.stats(); + let server_task = tokio::spawn(server.run()); + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + assert!(matches!( + session.send_datagram(Bytes::from(vec![0u8; 1201])).await, + Err(TransportError::Oversized { maximum: 1200 }) + )); + tokio::time::sleep(Duration::from_secs(3)).await; + assert_eq!(stats.snapshot().expired_sessions, 1); + session.close().await.unwrap(); + server_task.abort(); +} + +#[tokio::test] +async fn concurrent_clients_use_independent_backend_sockets() { + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (server_config, mut client_one, mut client_two) = multi_configs(45783, backend_addr.port()); + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let stats = server.stats(); + let server_task = tokio::spawn(server.run()); + client_one.transport.direct_udp.port = 45783; + client_two.transport.direct_udp.port = 45783; + let backend_task = tokio::spawn(async move { + let mut peers = Vec::new(); + let mut buf = [0u8; 2048]; + for _ in 0..2 { + let (len, peer) = backend.recv_from(&mut buf).await.unwrap(); + peers.push(peer); + backend.send_to(&buf[..len], peer).await.unwrap(); + } + peers + }); + let first = DirectUdpSession::connect(&client_one).await.unwrap(); + let second = DirectUdpSession::connect(&client_two).await.unwrap(); + assert!(stats.snapshot().handshake_attempts >= 2); + first + .send_datagram(Bytes::from_static(b"first")) + .await + .unwrap(); + second + .send_datagram(Bytes::from_static(b"second")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), first.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"first") + ); + assert_eq!( + timeout(Duration::from_secs(2), second.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"second") + ); + let peers = backend_task.await.unwrap(); + assert_ne!( + peers[0], peers[1], + "each authenticated client must have its own backend socket" + ); + first.close().await.unwrap(); + second.close().await.unwrap(); + server_task.abort(); +} + +#[tokio::test] +async fn malformed_and_unauthenticated_packets_do_not_kill_server() { + let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let backend_addr = backend.local_addr().unwrap(); + let (server_config, client_config) = configs(45784, backend_addr.port()); + 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(); + raw.send_to(&[1, 2, 3], "127.0.0.1:45784").await.unwrap(); + raw.send_to(&[0u8; 64], "127.0.0.1:45784").await.unwrap(); + let mut wrong = client_config.clone(); + wrong.client.secret.secret = Some("B".repeat(64)); + assert!(matches!( + DirectUdpSession::connect(&wrong).await, + Err(TransportError::AuthenticationRejected(_)) + )); + let backend_task = tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let (len, peer) = backend.recv_from(&mut buf).await.unwrap(); + backend.send_to(&buf[..len], peer).await.unwrap(); + }); + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + session + .send_datagram(Bytes::from_static(b"still-alive")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), session.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"still-alive") + ); + assert!(stats.snapshot().datagrams_dropped >= 3); + session.close().await.unwrap(); + backend_task.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(); + let backend_addr = backend.local_addr().unwrap(); + let (server_config, _) = configs(45785, backend_addr.port()); + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let server_task = tokio::spawn(server.run()); + let first = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let second = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let secret = base64::engine::general_purpose::STANDARD + .decode(SECRET) + .unwrap(); + let mut nonce = [0u8; 16]; + nonce[0] = 1; + let hello = protocol::HelloPacket::authenticated( + &secret, + Bytes::from_static(b"test"), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + nonce, + ) + .unwrap(); + first + .send_to(&hello.encode().unwrap(), "127.0.0.1:45785") + .await + .unwrap(); + let mut wire = [0u8; 2048]; + let (len, _) = timeout(Duration::from_secs(2), first.recv_from(&mut wire)) + .await + .unwrap() + .unwrap(); + let accept = match protocol::parse_packet(&wire[..len]).unwrap() { + protocol::Packet::Accept(packet) => packet, + other => panic!("unexpected handshake response: {other:?}"), + }; + let key = + protocol::derive_session_key(&secret, &nonce, &accept.server_nonce, accept.session_id); + let data = protocol::DataPacket::authenticated( + &key, + accept.session_id, + 1, + Bytes::from_static(b"rebound"), + ); + second + .send_to(&data.encode().unwrap(), "127.0.0.1:45785") + .await + .unwrap(); + let backend_task = tokio::spawn(async move { + let mut buf = [0u8; 2048]; + let (len, peer) = backend.recv_from(&mut buf).await.unwrap(); + backend.send_to(&buf[..len], peer).await.unwrap(); + }); + let (len, _) = timeout(Duration::from_secs(2), second.recv_from(&mut wire)) + .await + .unwrap() + .unwrap(); + match protocol::parse_packet(&wire[..len]).unwrap() { + protocol::Packet::Data(packet) => { + assert_eq!(packet.payload, Bytes::from_static(b"rebound")) + } + other => panic!("unexpected rebound response: {other:?}"), + } + backend_task.await.unwrap(); + server_task.abort(); +} + +#[tokio::test] +async fn unavailable_backend_does_not_terminate_listener() { + let (server_config, client_config) = configs(45786, 9); + let server = DirectUdpServer::bind(server_config).await.unwrap(); + let server_task = tokio::spawn(server.run()); + let session = DirectUdpSession::connect(&client_config).await.unwrap(); + session + .send_datagram(Bytes::from_static(b"backend-may-be-down")) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !server_task.is_finished(), + "backend failure must not terminate the public listener" + ); + session.close().await.unwrap(); + server_task.abort(); +} diff --git a/tests/http3.rs b/tests/http3.rs new file mode 100644 index 0000000..87fef54 --- /dev/null +++ b/tests/http3.rs @@ -0,0 +1,96 @@ +#[path = "../src/transport/http3.rs"] +mod http3; + +use std::collections::HashMap; + +use http3::{ + decode_capsule, decode_http_datagram, encode_auth_challenge, encode_auth_response, + encode_capsule, encode_http_datagram, validate_connect_udp_request, AuthChallenge, + ConnectUdpRequest, Http3Error, CONNECT_UDP_CONTEXT_ID, +}; + +#[test] +fn validates_extended_connect_request() { + validate_connect_udp_request("CONNECT", "connect-udp", "/tunnel", "/tunnel").unwrap(); + ConnectUdpRequest::new("/tunnel") + .validate("/tunnel") + .unwrap(); + assert!(matches!( + validate_connect_udp_request("POST", "connect-udp", "/tunnel", "/tunnel"), + Err(Http3Error::InvalidRequest("method must be CONNECT")) + )); + assert!(matches!( + validate_connect_udp_request("CONNECT", "webtransport", "/tunnel", "/tunnel"), + Err(Http3Error::InvalidRequest(":protocol must be connect-udp")) + )); + assert!(validate_connect_udp_request("CONNECT", "connect-udp", "/other", "/tunnel").is_err()); + assert!(validate_connect_udp_request("CONNECT", "connect-udp", "tunnel", "tunnel").is_err()); +} + +#[test] +fn http_datagram_uses_context_id_and_preserves_opaque_payload() { + for context in [0, 63, 64, 16_383, 16_384, (1 << 30) - 1, 1 << 30] { + let encoded = encode_http_datagram(context, b"opaque", 1200).unwrap(); + assert_eq!( + decode_http_datagram(&encoded, context, 1200).unwrap(), + b"opaque"[..] + ); + } + assert!(matches!( + decode_http_datagram( + &encode_http_datagram(7, b"x", 1200).unwrap(), + CONNECT_UDP_CONTEXT_ID, + 1200 + ), + Err(Http3Error::ContextMismatch { .. }) + )); + assert!(matches!( + encode_http_datagram(CONNECT_UDP_CONTEXT_ID, &[0; 1201], 1200), + Err(Http3Error::Oversized { + length: 1201, + maximum: 1200 + }) + )); +} + +#[test] +fn capsule_round_trip_and_truncation_are_checked() { + let encoded = encode_capsule(0x2a, b"challenge").unwrap(); + assert_eq!( + decode_capsule(&encoded).unwrap(), + (0x2a, bytes::Bytes::from_static(b"challenge")) + ); + assert!(matches!( + decode_capsule(&encoded[..encoded.len() - 1]), + Err(Http3Error::Truncated) + )); + let mut extra = encoded.to_vec(); + extra.push(0); + assert!(matches!( + decode_capsule(&extra), + Err(Http3Error::InvalidCapsule) + )); +} + +#[test] +fn authentication_is_completed_before_session_activation() { + let challenge = AuthChallenge::random(); + let nonce = [5u8; 16]; + let secret = [7u8; 32]; + let challenge_capsule = encode_auth_challenge(&challenge).unwrap(); + assert_eq!( + decode_capsule(&challenge_capsule).unwrap().0, + http3::AUTH_CHALLENGE_CAPSULE + ); + let response = encode_auth_response(&challenge, "laptop", &nonce, &secret).unwrap(); + let mut clients = HashMap::new(); + clients.insert("laptop".to_owned(), secret.to_vec()); + let client = http3::verify_auth_response(&challenge, &response, &clients).unwrap(); + assert_eq!(client.id, "laptop"); + assert_eq!(client.nonce, nonce); + + let bad = encode_auth_response(&challenge, "laptop", &nonce, &[8u8; 32]).unwrap(); + assert!(http3::verify_auth_response(&challenge, &bad, &clients).is_err()); + assert!(http3::is_auth_accept(&http3::encode_auth_accept().unwrap()).unwrap()); + assert!(!http3::is_auth_accept(&http3::encode_auth_reject().unwrap()).unwrap()); +} diff --git a/tests/integration_transports.rs b/tests/integration_transports.rs new file mode 100644 index 0000000..ef86446 --- /dev/null +++ b/tests/integration_transports.rs @@ -0,0 +1,102 @@ +#[path = "../src/config.rs"] +mod config; +#[path = "../src/datagram.rs"] +mod datagram; +#[path = "../src/protocol/mod.rs"] +mod protocol; +#[path = "../src/stats.rs"] +mod stats; +#[path = "../src/transport/mod.rs"] +mod transport; + +use std::{process::Command, sync::Arc, time::Duration}; + +use bytes::Bytes; +use config::Config; +use rustls::RootCertStore; +use tempfile::tempdir; +use tokio::time::timeout; +use transport::{TlsTcpConnector, TlsTcpServer, TransportConnector, TransportSession}; + +const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +#[tokio::test] +async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() { + if Command::new("openssl").arg("version").output().is_err() { + return; + } + 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", + "-days", + "1", + "-keyout", + ]) + .arg(&key) + .args(["-out"]) + .arg(&cert) + .output() + .unwrap(); + assert!( + generated.status.success(), + "openssl failed: {:?}", + generated + ); + let port = free_port(); + let server_config = Config::from_str(&format!( + "[server]\nlisten = \"127.0.0.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 client_config = Config::from_str(&format!( + "[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport]\nmode = \"tls_tcp\"\n[transport.tls_tcp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n" + )) + .unwrap() + .into_client() + .unwrap(); + let certs = transport::tls_tcp::load_certificates(&cert).unwrap(); + let mut roots = RootCertStore::empty(); + roots.add(certs[0].clone()).unwrap(); + let client_tls = (*transport::tls_tcp::client_tls_config(roots)).clone(); + let server = Arc::new(TlsTcpServer::bind(&server_config).await.unwrap()); + let accept = { + let server = server.clone(); + tokio::spawn(async move { server.accept_session().await }) + }; + let client = TlsTcpConnector::with_client_config(client_config, client_tls); + let client_session = client.connect().await.unwrap(); + let server_session = accept.await.unwrap().unwrap(); + client_session + .send_datagram(Bytes::from_static(b"tls-opaque")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), server_session.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"tls-opaque") + ); + client_session.close().await.unwrap(); + server_session.close().await.unwrap(); +} diff --git a/tests/quic.rs b/tests/quic.rs new file mode 100644 index 0000000..8c757cf --- /dev/null +++ b/tests/quic.rs @@ -0,0 +1,148 @@ +#[path = "../src/config.rs"] +mod config; +#[path = "../src/datagram.rs"] +mod datagram; +#[path = "../src/protocol/mod.rs"] +mod protocol; +#[path = "../src/stats.rs"] +mod stats; +#[path = "../src/transport/mod.rs"] +mod transport; + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use base64::Engine; +use bytes::Bytes; +use quinn::{ClientConfig as QuinnClientConfig, Endpoint, ServerConfig as QuinnServerConfig}; +use rustls::pki_types::{CertificateDer, PrivatePkcs8KeyDer}; +use tokio::time::timeout; +use transport::{ + quic::{QuicClientConnector, QuicServer}, + TransportError, TransportSession, +}; + +const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +// A test-only localhost certificate, valid for ten years. Keeping the DER +// here avoids adding a certificate-generation crate to the application. +const CERT_DER_B64: &str = "MIIDHDCCAgSgAwIBAgIUJnI/VlHMWu6j0dXr/t1OmMQrLgowDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkxNDIwNDEyMFoXDTM2MDkxMTIwNDEyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4d4r5Py4GdfQrLit2oytLv20L+y7oM/VTDzzJfFMPmewOh0XyS0BI4lyRbCgOuUh686UPvF/BU8uJq24OO7wt/2CrE3FfwYX7BDjIWMuFevHuv896imabbX4IJ7lzVugI3862QSW09QSEQk3ggYs3z/F16cnSRZm9aIURgdPQsLmMzOwcPtDGwxp2jf0Rky0kFRxhUnqfIWko4hny7GjWL8VG0WzYJY6aUevjPlN/Oq/akr9xUAr2QJenEesP77do4X31LIZmsvB6GqKwaSQ0AbwNHW7rDiaE4dkZMIk5l95YgYnrzyOC8IhUwaSZt0wODxk/euXPYr+RFJv2FuOQIDAQABo2YwZDAdBgNVHQ4EFgQUhViBEXLkp7YUVZaWzLi2SfRmfmYwHwYDVR0jBBgwFoAUhViBEXLkp7YUVZaWzLi2SfRmfmYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAEFC7da0bK78IUL0k6lZ82pqFImX2bNteMh/r1IJG0xqB/O2dOLUDHybpF7HToommALgRu6zhlT0d1eHg0cMYEgrKyq4BAAXGHqOt3OdKKL5nAWmI04dlWsrG9aTU3hTQhe0++hnTbVm+YJnYAp+ufOxx858FeothjLWu1Lr81Nm01FnmZbXcFno3nosyl1GPxSRVZl2XH9NsfMSZJUbSU6mHXTO1pEH6mjYmgD362CeJc+0DOzevdLsDozxUoWmdEuqlldauESW6XVK0WxRSJ1i2/B/mtYViHr5R/j5NYgG5STnNnwaNwjYK+0AhxvTtH3jAp0Bsluw4d+xKdsV/YI="; +const KEY_DER_B64: &str = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbh3ivk/LgZ19CsuK3ajK0u/bQv7Lugz9VMPPMl8Uw+Z7A6HRfJLQEjiXJFsKA65SHrzpQ+8X8FTy4mrbg47vC3/YKsTcV/BhfsEOMhYy4V68e6/z3qKZpttfggnuXNW6AjfzrZBJbT1BIRCTeCBizfP8XXpydJFmb1ohRGB09CwuYzM7Bw+0MbDGnaN/RGTLSQVHGFSep8haSjiGfLsaNYvxUbRbNgljppR6+M+U386r9qSv3FQCvZAl6cR6w/vt2jhffUshmay8HoaorBpJDQBvA0dbusOJoTh2RkwiTmX3liBievPI4LwiFTBpJm3TA4PGT965c9iv5EUm/YW45AgMBAAECggEAMs0EtCD4t6i8Ol6Q5EUZa+RrgZLkILkZEjwLnjvCqrUWUD99Z2hnLoe5ut2myZJpcQpg23vpluXe8BOFAZ8RQN2DkcmByzoNQhAieD9VEA+AAXMmrURWmvggulIUiXbn8bCvOLn2rwGQoRI1EpsFanc9IUaDlGZ/qIHiA88DGeFDuchbcgClpXAX4lBzqhLKv/CCIhhGxmyWc1RPlZUef7uTO1Zh7q2HxnO5iBKobvPfMq5VfvzE4rXeSQIHtW+yJFLU2sNjaYry2JTLEqGIZhWMROmzlkW8x/cj0GvjLbY4ltkIpLNSb+WRbY3zZNhQ81kosBZtiYRj3vgr/FzvSwKBgQDQ/dxghryGDqIIOfyZ59+2tpPrrLX4vy7tmAQg6zJIiQVHMZ/Av60QvY7lA0dvDfQmfJuNzTOXtwhgnYkHbg6jlFqpT7P1CZQOzbF0UuFCNvwzE/jgCEKUExckDa+Du7g/VOglB9B4XKqJnQLpZkzz4Hwmuvz4oUtZJCl796HTMwKBgQC+gyPKrNp6AthxZ1gAu+aIjf18vgGZjANQx3cRFQl5BqSyTwZpxIfuiKrT+sU65N2VCWpGH6oTzGruaXBXzG0rlNCMAL5XaJqJehvLAnejUqmXhyi8ouH174Q4azDngPOCFaSjuYJwpX4nUXwMExqs9hwkH9pV3tn6qzEpKJA44wKBgBa+SalHgGSmWwPCK7ckALiFjftrxDHZvGWm0WwzrDkwkB57TANR2KeluVM61B9FyXgWv2bjGeY8yPCatDrWw0kR7/vU7KSR8kbaI6GhE1p9WVzFo6PdwkETAsWtp5gSaa6asti0zP8MNpLbP2He/CjeGSSt3LHB+80rnkgbXsT5AoGAVaTytCxc0zjwQJrqzvmpu33JlQh+A58T5n6lIkLIzWWVKKuqAGZfjXZXBZDYlY9JpbRj2D5n4Rqmf6Na47183ni6ABZde0IFJwSD9hGX4w5IO1yBjbvTxRH16L8XbxtjXPmJ2CiPYUCko9xX3b3A4jyJjhvbtXrHl47aPNyFaW0CgYEAxDOcW5r5pXXkCF/mIqSklGa1jDO9TeMavLo1AguHaUlJEQU8DgGM9Flx3senFASN2EjwwTB3aScVuxe3X29LeZnCNsuTIzCVGpSNyFLUjZVU0fRaf6R9JIcI7dVoflPAPCdrqBVkezK5Eyu8f3NCwnVD+zv+iWA+RE1zUyKy4sM="; + +fn endpoint_and_client( + max_datagram: usize, +) -> (Endpoint, QuinnClientConfig, CertificateDer<'static>) { + let cert_bytes = base64::engine::general_purpose::STANDARD + .decode(CERT_DER_B64) + .unwrap(); + let key_bytes = base64::engine::general_purpose::STANDARD + .decode(KEY_DER_B64) + .unwrap(); + let cert = CertificateDer::from(cert_bytes); + let key = PrivatePkcs8KeyDer::from(key_bytes); + let mut server_config = + QuinnServerConfig::with_single_cert(vec![cert.clone()], key.into()).unwrap(); + let mut transport_config = quinn::TransportConfig::default(); + transport_config.keep_alive_interval(Some(Duration::from_secs(1))); + transport_config.datagram_send_buffer_size(max_datagram * 8); + server_config.transport_config(Arc::new(transport_config)); + let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert.clone()).unwrap(); + let client = QuinnClientConfig::with_root_certificates(Arc::new(roots)).unwrap(); + (endpoint, client, cert) +} + +fn client_config(port: u16, secret: &str) -> config::ClientConfig { + config::Config::from_str(&format!( + "[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{secret}\"\n[transport.quic]\nport = {port}\n[transport.auto]\nprobe_timeout_ms = 2000\n[network]\nmax_datagram_size = 1200\n" + )) + .unwrap() + .into_client() + .unwrap() +} + +#[tokio::test] +async fn authenticated_datagram_round_trip_and_limit() { + let (endpoint, client_tls, _cert) = endpoint_and_client(1200); + let mut clients = HashMap::new(); + clients.insert( + "test".to_owned(), + base64::engine::general_purpose::STANDARD + .decode(SECRET) + .unwrap(), + ); + let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2)); + let port = server.local_addr().unwrap().port(); + let client = QuicClientConnector::with_client_config(client_config(port, SECRET), client_tls); + let server_task = tokio::spawn(async move { server.accept().await }); + let client_session = client.connect_session().await.unwrap(); + let server_session = server_task.await.unwrap().unwrap(); + client_session + .send_datagram(Bytes::from_static(b"opaque")) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(2), server_session.recv_datagram()) + .await + .unwrap() + .unwrap(), + Bytes::from_static(b"opaque") + ); + assert!(matches!( + client_session + .send_datagram(Bytes::from(vec![0; 1201])) + .await, + Err(TransportError::Oversized { maximum: 1200 }) + )); + client_session.close().await.unwrap(); + server_session.close().await.unwrap(); +} + +#[tokio::test] +async fn bad_secret_is_rejected_as_authentication_error() { + let (endpoint, client_tls, _cert) = endpoint_and_client(1200); + let mut clients = HashMap::new(); + clients.insert( + "test".to_owned(), + base64::engine::general_purpose::STANDARD + .decode(SECRET) + .unwrap(), + ); + let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2)); + let port = server.local_addr().unwrap().port(); + let wrong = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + let client = QuicClientConnector::with_client_config(client_config(port, wrong), client_tls); + let server_task = tokio::spawn(async move { server.accept().await }); + let result = client.connect_session().await; + assert!(matches!( + result, + Err(TransportError::AuthenticationRejected(_)) + )); + assert!(matches!( + server_task.await.unwrap(), + Err(TransportError::AuthenticationRejected(_)) + )); +} + +#[tokio::test] +async fn certificate_hostname_mismatch_is_not_accepted() { + let (endpoint, client_tls, _cert) = endpoint_and_client(1200); + let mut clients = HashMap::new(); + clients.insert( + "test".to_owned(), + base64::engine::general_purpose::STANDARD + .decode(SECRET) + .unwrap(), + ); + let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2)); + let port = server.local_addr().unwrap().port(); + let server_task = tokio::spawn(async move { server.accept().await }); + // The connector's configured hostname is localhost, which matches. This + // test changes it to an unrelated name while retaining the localhost root. + let mut mismatch = client_config(port, SECRET); + mismatch.client.server = "not-localhost".to_owned(); + let mismatch = QuicClientConnector::with_client_config(mismatch, client_tls); + let result = mismatch.connect_session().await; + assert!(result.is_err()); + server_task.abort(); +} diff --git a/tests/supervisor.rs b/tests/supervisor.rs new file mode 100644 index 0000000..e379291 --- /dev/null +++ b/tests/supervisor.rs @@ -0,0 +1,166 @@ +#[path = "../src/config.rs"] +mod config; +#[path = "../src/datagram.rs"] +mod datagram; +#[path = "../src/protocol/mod.rs"] +mod protocol; +#[path = "../src/stats.rs"] +mod stats; +#[path = "../src/client/supervisor.rs"] +mod supervisor; +#[path = "../src/transport/mod.rs"] +mod transport; + +use bytes::Bytes; +use config::{Config, TransportMode}; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use supervisor::{ProbeFuture, TransportProbe, TransportSupervisor}; +use tokio::{sync::Mutex, time::timeout}; +use transport::{TransportError, TransportFuture, TransportSession}; + +struct Session; +impl TransportSession for Session { + fn send_datagram<'a>(&'a self, _: Bytes) -> TransportFuture<'a, ()> { + Box::pin(async { Ok(()) }) + } + fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> { + Box::pin(async { Err(TransportError::Closed) }) + } + fn close<'a>(&'a self) -> TransportFuture<'a, ()> { + Box::pin(async { Ok(()) }) + } + fn max_datagram_size(&self) -> usize { + 1200 + } +} +struct Probe { + calls: Arc>>, + auth: bool, +} +impl TransportProbe for Probe { + fn connect(&self, mode: TransportMode) -> ProbeFuture { + let calls = self.calls.clone(); + let auth = self.auth; + Box::pin(async move { + calls.lock().await.push(mode); + if auth { + Err(TransportError::AuthenticationRejected( + protocol::RejectReason::AuthenticationFailed, + )) + } else { + Err(TransportError::HandshakeTimeout) + } + }) + } +} +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() +} + +#[tokio::test] +async fn selection_order_retries_and_auth_stop() { + let calls = Arc::new(Mutex::new(Vec::new())); + let supervisor = TransportSupervisor::new( + config("auto"), + Probe { + calls: calls.clone(), + auth: false, + }, + ); + assert!(matches!( + supervisor.connect_once().await, + Err(TransportError::HandshakeTimeout) + )); + assert_eq!( + *calls.lock().await, + vec![ + TransportMode::DirectUdp, + TransportMode::DirectUdp, + TransportMode::Quic, + TransportMode::Quic + ] + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let supervisor = TransportSupervisor::new( + config("auto"), + Probe { + calls: calls.clone(), + auth: true, + }, + ); + assert!(matches!( + supervisor.connect_once().await, + Err(TransportError::AuthenticationRejected(_)) + )); + assert_eq!(*calls.lock().await, vec![TransportMode::DirectUdp]); +} + +#[test] +fn backoff_is_bounded_and_resettable() { + let mut supervisor = TransportSupervisor::new( + config("direct_udp"), + Probe { + calls: Arc::new(Mutex::new(Vec::new())), + auth: false, + }, + ); + for _ in 0..30 { + let delay = supervisor.backoff_delay(); + assert!((250..=30_000).contains(&delay.as_millis())); + } + supervisor.reset_backoff(); + assert_eq!(supervisor.backoff_delay(), Duration::from_millis(250)); +} + +struct LifecycleProbe { + calls: Arc, +} + +impl TransportProbe for LifecycleProbe { + fn connect(&self, _mode: TransportMode) -> ProbeFuture { + let calls = self.calls.clone(); + Box::pin(async move { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(Box::new(Session) as Box) + } else { + Err(TransportError::HandshakeTimeout) + } + }) + } + + fn wait_for_session_failure<'a>( + &'a self, + _session: &'a dyn TransportSession, + ) -> supervisor::SessionWatchFuture<'a> { + Box::pin(async { Err(TransportError::Closed) }) + } +} + +#[tokio::test] +async fn established_failure_reprobes_without_reading_datagrams() { + let calls = Arc::new(AtomicUsize::new(0)); + let supervisor = TransportSupervisor::new( + config("direct_udp"), + LifecycleProbe { + calls: calls.clone(), + }, + ); + let shutdown = supervisor.shutdown_token(); + let task = tokio::spawn(supervisor.run()); + // The failed established session uses the normal minimum reconnect + // backoff before the second probe. + tokio::time::sleep(Duration::from_millis(320)).await; + shutdown.cancel(); + let result = timeout(Duration::from_secs(2), task) + .await + .expect("supervisor should stop") + .unwrap(); + assert!(result.is_ok()); + assert!(calls.load(Ordering::SeqCst) >= 2); +} diff --git a/tests/tls_tcp.rs b/tests/tls_tcp.rs new file mode 100644 index 0000000..3eba8bc --- /dev/null +++ b/tests/tls_tcp.rs @@ -0,0 +1,159 @@ +#[path = "../src/transport/tls_tcp.rs"] +mod tls_tcp; + +use std::{collections::HashMap, time::Duration}; + +use tls_tcp::{ + client_authenticate, server_authenticate, AuthChallenge, FrameCodec, FrameError, FramedReader, + FramedWriter, +}; +use tokio::io::{duplex, split}; + +#[test] +fn codec_handles_split_and_coalesced_frames() { + let codec = FrameCodec::new(32).expect("valid maximum"); + let mut wire = codec.encode(b"first").unwrap(); + wire.extend_from_slice(&codec.encode(b"second").unwrap()); + let mut decoder = FrameCodec::new(32).unwrap(); + assert!(decoder.feed(&wire[..1]).unwrap().is_empty()); + assert!(decoder.feed(&wire[1..4]).unwrap().is_empty()); + assert_eq!( + decoder.feed(&wire[4..]).unwrap(), + vec![b"first".to_vec(), b"second".to_vec()] + ); + decoder.finish().unwrap(); +} + +#[test] +fn codec_rejects_oversized_before_payload() { + let mut decoder = FrameCodec::new(4).unwrap(); + let error = decoder.feed(&[0, 5]).unwrap_err(); + assert_eq!( + error, + FrameError::Oversized { + length: 5, + maximum: 4 + } + ); + assert!(decoder.finish().is_ok()); +} + +#[test] +fn codec_reports_truncated_header_or_payload() { + let mut decoder = FrameCodec::new(32).unwrap(); + decoder.feed(&[0]).unwrap(); + assert_eq!(decoder.finish(), Err(FrameError::Truncated)); + let mut decoder = FrameCodec::new(32).unwrap(); + decoder.feed(&[0, 3, b'x']).unwrap(); + assert_eq!(decoder.finish(), Err(FrameError::Truncated)); +} + +#[test] +fn auth_response_is_bound_to_challenge_and_secret() { + let challenge = AuthChallenge::random(); + let nonce = [9u8; 16]; + let message = tls_tcp::encode_auth_response(&challenge, "laptop", &nonce, &[7u8; 32]).unwrap(); + let mut clients = HashMap::new(); + clients.insert("laptop".to_owned(), vec![7u8; 32]); + // Exercise the full asynchronous exchange below; this assertion also + // ensures the generated challenge is not the all-zero sentinel. + assert_ne!(challenge.bytes, [0u8; 32]); + assert_eq!(message[0], 0x11); + assert_eq!(clients["laptop"].len(), 32); +} + +#[tokio::test] +async fn tls_stream_authentication_exchange_uses_challenge_response() { + 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, + 1024, + Duration::from_secs(1), + ) + .await + }); + let client = tokio::spawn(async move { + client_authenticate( + &mut client_reader, + &mut client_writer, + "laptop", + &[7u8; 32], + 1024, + Duration::from_secs(1), + ) + .await + }); + assert!(client.await.unwrap().is_ok()); + let authenticated = server.await.unwrap().unwrap(); + assert_eq!(authenticated.id, "laptop"); +} + +#[tokio::test] +async fn bad_secret_is_rejected_without_authenticating() { + 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, + 1024, + Duration::from_secs(1), + ) + .await + }); + let client = tokio::spawn(async move { + client_authenticate( + &mut client_reader, + &mut client_writer, + "laptop", + &[8u8; 32], + 1024, + Duration::from_secs(1), + ) + .await + }); + assert!(matches!( + client.await.unwrap(), + Err(tls_tcp::AuthError::Rejected) + )); + assert!(matches!( + server.await.unwrap(), + Err(tls_tcp::AuthError::InvalidCredentials) + )); +} + +#[tokio::test] +async fn framed_session_preserves_empty_datagrams_and_heartbeats() { + let (left, right) = duplex(4096); + let (mut reader, _unused_writer) = split(left); + let (unused_reader, writer) = split(right); + drop(unused_reader); + let mut writer = FramedWriter::new(writer, 1200); + let mut reader = FramedReader::new(&mut reader, 1200, Some(Duration::from_secs(1))); + // Write frames using the writer half and read them from the other side. + // The opposite halves are intentionally dropped only after construction; + // duplex keeps the active directions independent. + let send = async { + writer.send_keepalive().await.unwrap(); + writer.send_datagram(b"").await.unwrap(); + writer.send_datagram(b"payload").await.unwrap(); + }; + tokio::pin!(send); + send.await; + assert_eq!(reader.recv_datagram().await.unwrap(), b""); + assert_eq!(reader.recv_datagram().await.unwrap(), b"payload"); +}