checkpoint: integrate authenticated transports
This commit is contained in:
1 parent
313ab00765
commit
586ff8f223
42 files changed
+6277
-771
No files matched your search
Generated
+2
-103
@@ -67,29 +67,6 @@ 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"
|
||||
@@ -130,8 +107,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -198,15 +173,6 @@ 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"
|
||||
@@ -278,12 +244,6 @@ dependencies = [
|
||||
"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"
|
||||
@@ -300,18 +260,6 @@ dependencies = [
|
||||
"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"
|
||||
@@ -324,18 +272,6 @@ 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"
|
||||
@@ -618,16 +554,6 @@ dependencies = [
|
||||
"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"
|
||||
@@ -651,12 +577,6 @@ 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"
|
||||
@@ -766,18 +686,6 @@ 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"
|
||||
@@ -824,7 +732,6 @@ 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",
|
||||
@@ -1000,8 +907,6 @@ 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",
|
||||
@@ -1065,7 +970,6 @@ 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",
|
||||
@@ -1230,12 +1134,6 @@ 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"
|
||||
@@ -1470,7 +1368,6 @@ 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",
|
||||
@@ -1651,10 +1548,12 @@ dependencies = [
|
||||
"bytes",
|
||||
"clap",
|
||||
"h3",
|
||||
"h3-datagram",
|
||||
"h3-quinn",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"http",
|
||||
"quinn",
|
||||
"rand 0.8.8",
|
||||
"rustls",
|
||||
|
||||
+6
-4
@@ -3,7 +3,7 @@ name = "wgcloak"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Protocol-agnostic authenticated UDP transport layer"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
@@ -12,12 +12,14 @@ base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = { version = "0.0.10", features = ["datagram"] }
|
||||
h3-datagram = "0.0.2"
|
||||
http = "1"
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
quinn = "0.11"
|
||||
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "platform-verifier"] }
|
||||
rand = "0.8"
|
||||
rustls = "0.23"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
secrecy = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
@@ -26,7 +28,7 @@ subtle = "2.6"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "rt"] }
|
||||
tokio-rustls = "0.26"
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
rustls-native-certs = "0.8"
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -1,98 +1,99 @@
|
||||
# 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.
|
||||
wgcloak relays opaque UDP datagrams between a local UDP application and a
|
||||
remote UDP backend. It does not inspect WireGuard or other application
|
||||
protocols and does not configure interfaces, routes, or firewalls.
|
||||
|
||||
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)
|
||||
The current runtime implements authenticated direct UDP, QUIC DATAGRAM,
|
||||
HTTP/3 CONNECT-UDP, and TLS/TCP framed fallback. Client `mode = "auto"` probes
|
||||
the configured transport order; authentication rejection stops fallback.
|
||||
See [`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md) for
|
||||
acceptance evidence and remaining v1 work.
|
||||
|
||||
## Build and install
|
||||
|
||||
Rust and Cargo are required. From the repository root:
|
||||
Install Rust and Cargo, then run from the repository root:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo test --all-targets
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
For a host installation (choose a reviewed release artifact):
|
||||
For a local host installation:
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/wgcloak /usr/local/bin/wgcloak
|
||||
sudo install -d -m0750 /etc/wgcloak
|
||||
sudo install -d -o root -g wgcloak -m0750 /etc/wgcloak/clients
|
||||
```
|
||||
|
||||
The binary name and subcommands are the intended interface from `spec.md`:
|
||||
The CLI supports:
|
||||
|
||||
```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
|
||||
wgcloak status
|
||||
wgcloak stats
|
||||
wgcloak reconnect
|
||||
wgcloak reload
|
||||
wgcloak shutdown
|
||||
```
|
||||
|
||||
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.
|
||||
`reload` is retained as a CLI/control command but currently returns an explicit
|
||||
unsupported response. Configuration and certificate hot reload are not
|
||||
implemented. `reconnect` applies to the client; the server rejects it.
|
||||
|
||||
## Configuration
|
||||
## Configure and run
|
||||
|
||||
Copy and edit one of the safe, placeholder-only examples:
|
||||
Copy a placeholder-only example and adjust it for the selected transport:
|
||||
|
||||
- [`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).
|
||||
Store each secret in a separate file owned by the service account with mode
|
||||
`0600`. Keep configuration and key files out of version control. TLS-capable
|
||||
transports require a certificate and private key on the server; clients use
|
||||
normal certificate-chain and hostname verification.
|
||||
|
||||
## Run with systemd
|
||||
Run in the foreground first:
|
||||
|
||||
The templates in [`packaging/systemd/`](packaging/systemd/) assume a dedicated
|
||||
```bash
|
||||
wgcloak check-config --config /etc/wgcloak/client.toml
|
||||
wgcloak client --config /etc/wgcloak/client.toml
|
||||
```
|
||||
|
||||
See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for control commands, systemd
|
||||
deployment, shutdown behavior, and troubleshooting pointers.
|
||||
|
||||
## systemd
|
||||
|
||||
The units 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`.
|
||||
Review the paths and enabled listeners before installing. The server unit
|
||||
contains an optional, commented `CAP_NET_BIND_SERVICE` setting for ports below
|
||||
1024. Normal operation needs neither `CAP_NET_ADMIN` nor `CAP_SYS_ADMIN`.
|
||||
|
||||
## Security boundaries
|
||||
|
||||
TLS transports authenticate the server with normal X.509 verification and
|
||||
authenticate clients with the configured shared secret. Direct UDP provides
|
||||
message integrity and replay protection but no outer confidentiality. The
|
||||
control socket is local, stored in a private runtime directory, and mode
|
||||
`0600`.
|
||||
|
||||
wgcloak does not configure WireGuard, create TUN/TAP devices, change routes or
|
||||
firewalls, fragment application datagrams, spoof browser fingerprints,
|
||||
cluster servers, or provide multiple simultaneous local client tunnels.
|
||||
|
||||
## 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.
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): runtime roles and ownership.
|
||||
- [`docs/PROTOCOL.md`](docs/PROTOCOL.md): wire protocol and transport behavior.
|
||||
- [`docs/OPERATIONS.md`](docs/OPERATIONS.md): setup, control, and systemd.
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md): threat model and permissions.
|
||||
- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md): common failures.
|
||||
- [`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md):
|
||||
milestones 1–6 and all 17 v1 criteria.
|
||||
+10
-6
@@ -49,10 +49,12 @@ STARTING → RESOLVING → PROBING → AUTHENTICATING → CONNECTED
|
||||
└→ 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.
|
||||
In `auto` mode the configured order defaults to 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 (250 ms to 30 s), reset after a successful
|
||||
connection. These transports are wired into the current runtime; the
|
||||
acceptance checklist distinguishes implementation from completed v1 sign-off.
|
||||
|
||||
## Server isolation
|
||||
|
||||
@@ -69,5 +71,7 @@ Normal operation uses ordinary UDP/TCP/Unix sockets and needs no root,
|
||||
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.
|
||||
Configuration reload and certificate/key reload are not implemented. The
|
||||
control `reload` command returns an explicit unsupported response. The
|
||||
`reconnect` command applies to the client, while the server supports `status`,
|
||||
`stats`, and graceful `shutdown`.
|
||||
+95
-91
@@ -1,109 +1,113 @@
|
||||
# Development state
|
||||
|
||||
Checkpoint: 2026-09-14, end of day. Development is paused at a compilable
|
||||
checkpoint; no commit was made.
|
||||
End-of-day checkpoint: 2026-09-15. Branch: `main`. Baseline commit:
|
||||
`313ab00` (`checkpoint: implement wgcloak v1 foundations`). Work after that
|
||||
checkpoint remains uncommitted in the shared worktree. Several agents have
|
||||
edited files in parallel; use the inventory below and review the complete diff
|
||||
before committing.
|
||||
|
||||
## Branch and worktree
|
||||
## Implemented behavior
|
||||
|
||||
- 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.
|
||||
- The package now has a reusable Rust library (`src/lib.rs`) and a thin binary
|
||||
(`src/main.rs`). Integration tests import `wgcloak` instead of compiling
|
||||
production modules through `#[path]`; module unit tests run once in the
|
||||
library test target.
|
||||
- Client and server runtime paths support authenticated direct UDP, QUIC
|
||||
DATAGRAM, HTTP/3 CONNECT-UDP with HTTP Datagrams, and TLS/TCP length-framed
|
||||
fallback. TLS client connections use normal certificate-chain and hostname
|
||||
verification. For encrypted transports, shared-secret authentication occurs
|
||||
before server backend-session activation.
|
||||
- Client `auto` uses its configured transport order and bounded connection
|
||||
probes, stops on authentication rejection, and reconnects with jittered
|
||||
exponential backoff from 250 ms through 30 s. Successful connections reset
|
||||
the backoff. The client control `reconnect` command forces another attempt.
|
||||
- The server runs enabled listeners concurrently, rejects incompatible UDP
|
||||
listener port conflicts, and aggregates listener/session statistics. Sessions
|
||||
are isolated and use per-session backend UDP sockets. Admission limits cover
|
||||
active sessions, per-IP sessions, pending handshakes, and authentication
|
||||
attempts.
|
||||
- The Unix control socket uses a private runtime directory and mode `0600`,
|
||||
refuses an active socket, and removes only a verified stale socket. `status`,
|
||||
`stats`, and `shutdown` are supported; `reconnect` is client-only. Config and
|
||||
certificate/key reload are not implemented. The CLI/control reload request
|
||||
returns an explicit unsupported response.
|
||||
- The local client UDP listener currently tracks one most-recent application
|
||||
peer; concurrent local source peers can replace the return target.
|
||||
- Direct UDP keepalives are one-way. Silent peer loss may not be detected
|
||||
immediately; a local transport error or explicit client reconnect is the
|
||||
recovery path covered by tests.
|
||||
|
||||
## Implemented and wired behavior
|
||||
## Verification checkpoint
|
||||
|
||||
The current tree contains the following work, subject to integration review:
|
||||
Before the final config and client-ID edits, the full gates passed:
|
||||
|
||||
- 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.
|
||||
- `cargo fmt --all -- --check`
|
||||
- `cargo check --all-targets`
|
||||
- `cargo clippy --all-targets -- -D warnings`
|
||||
- `cargo test --all-targets --quiet` — 76 tests passed.
|
||||
|
||||
## Files changed in this work
|
||||
After those edits, the config-focused suite passed 12/12, the client-ID focused
|
||||
transport tests passed, and the current `cargo check` passes. Rerun the full
|
||||
gates after these edits before treating the tree as audited. The broader
|
||||
encrypted daemon-runtime end-to-end proof described below is not yet created or
|
||||
complete; focused transport and HTTP/3 runtime tests do not replace it.
|
||||
|
||||
Primary files touched by the operations/integration work are:
|
||||
## Remaining work, prioritized
|
||||
|
||||
- `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/`
|
||||
1. Add and complete encrypted daemon-runtime end-to-end proof: local UDP
|
||||
application traffic through client runtime, encrypted transport, server
|
||||
runtime, and UDP echo backend, including auth rejection, session isolation,
|
||||
status/stats, and bounded shutdown. Exercise each encrypted mode where
|
||||
practical; do not mistake lower-level transport tests for daemon proof.
|
||||
2. Rerun formatting, all-target check, strict all-target Clippy, and the full
|
||||
suite after the final config/client-ID edits; then review the complete diff
|
||||
and reconcile acceptance evidence with `docs/IMPLEMENTATION_CHECKLIST.md`.
|
||||
3. Add fuzz and benchmark targets for parsers, framing, protocol/auth paths,
|
||||
and transport overhead; record the environment and baseline.
|
||||
4. Decide and document a practical maximum datagram-size cap, with config and
|
||||
boundary tests tied to transport overhead and MTU constraints.
|
||||
5. Confirm whether configuration and certificate/key reload are required by
|
||||
the spec/product scope. Until implemented and tested, they remain explicitly
|
||||
unsupported; do not imply hot reload in CLI or deployment 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.
|
||||
## First recovery tasks
|
||||
|
||||
## Verification and current failures
|
||||
1. Inspect current client/server orchestration and transport test APIs, then
|
||||
create the encrypted daemon-runtime echo test without duplicating transport
|
||||
internals.
|
||||
2. Re-run the four full gates above against the current tree; resolve any
|
||||
compile, lint, or test regressions before adding further features.
|
||||
3. Review spec milestones and the 17 completion criteria, and record evidence
|
||||
for each criterion in the coordinator checklist. Keep fuzzing, benchmarks,
|
||||
datagram-cap decision, and reload scope visible as open items.
|
||||
|
||||
- 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:
|
||||
## Current worktree inventory
|
||||
|
||||
`Could not automatically determine process-level CryptoProvider from Rustls crate features.`
|
||||
Captured with `git status --short` for this checkpoint. These are shared-tree
|
||||
changes; ownership is not implied by this list.
|
||||
|
||||
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.
|
||||
Modified files:
|
||||
|
||||
## Remaining gaps, prioritized
|
||||
- `Cargo.lock`, `Cargo.toml`
|
||||
- `README.md`
|
||||
- `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT_STATE.md`,
|
||||
`docs/IMPLEMENTATION_CHECKLIST.md`, `docs/OPERATIONS.md`, `docs/PROTOCOL.md`,
|
||||
`docs/TROUBLESHOOTING.md`
|
||||
- `examples/client.advanced.toml`, `examples/server.advanced.toml`,
|
||||
`examples/server.minimal.toml`
|
||||
- `packaging/systemd/wgcloak-client.service`,
|
||||
`packaging/systemd/wgcloak-server.service`
|
||||
- `src/cli.rs`, `src/client/mod.rs`, `src/client/supervisor.rs`, `src/config.rs`,
|
||||
`src/control.rs`, `src/main.rs`, `src/protocol/auth.rs`,
|
||||
`src/protocol/direct.rs`, `src/server/mod.rs`, `src/stats.rs`,
|
||||
`src/transport/direct_udp.rs`, `src/transport/http3.rs`,
|
||||
`src/transport/mod.rs`, `src/transport/quic.rs`
|
||||
- `tests/control.rs`, `tests/direct_udp.rs`, `tests/http3.rs`,
|
||||
`tests/integration_transports.rs`, `tests/quic.rs`, `tests/supervisor.rs`,
|
||||
`tests/tls_tcp.rs`
|
||||
|
||||
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.
|
||||
Untracked files:
|
||||
|
||||
- `src/lib.rs`, `src/server/limits.rs`
|
||||
- `tests/http3_integration.rs`, `tests/http3_runtime.rs`, `tests/limits.rs`,
|
||||
`tests/reconnect.rs`, `tests/runtime_lifecycle.rs`
|
||||
@@ -1,9 +1,10 @@
|
||||
# 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.
|
||||
This is a coordinator checklist, not a claim that v1 is signed off. The current
|
||||
runtime has code paths for all four carriers, configured auto selection,
|
||||
client reconnect backoff, and local control commands. Mark an acceptance item
|
||||
only with code evidence, tests, and an integration result; record the test
|
||||
command or issue beside each checked item.
|
||||
|
||||
## Milestones 1–6
|
||||
|
||||
@@ -14,17 +15,18 @@ planned in the other documents must remain visibly planned here until proven.
|
||||
- [ ] **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.
|
||||
- [ ] **3 — automatic selection:** `mode = "auto"`; configured order across
|
||||
direct UDP, QUIC, HTTP/3, and TLS/TCP; bounded probes/retries; auth rejection
|
||||
stops probing; jittered reconnect; status reports selected transport.
|
||||
- [ ] **4 — HTTP/3 CONNECT-UDP:** standard CONNECT-UDP/HTTP Datagram semantics;
|
||||
authentication; backend mapping; reuse QUIC infrastructure where practical.
|
||||
- [ ] **5 — TLS/TCP fallback:** TLS listener; client authentication; bounded
|
||||
big-endian length framing; bidirectional datagram reconstruction; fallback
|
||||
integration; expected head-of-line behavior documented.
|
||||
- [ ] **6 — operational polish:** Unix control socket; status/stats/reconnect
|
||||
commands; config validation; systemd units; capability guidance; certificate
|
||||
and configuration reload; benchmarks; fuzzing; installation docs.
|
||||
commands; config validation; systemd units; capability guidance; benchmarks;
|
||||
fuzzing; installation docs. Config and certificate/key reload are not
|
||||
implemented; the control command explicitly reports reload as unsupported.
|
||||
|
||||
## v1 completion criteria (spec §89)
|
||||
|
||||
|
||||
+65
-51
@@ -1,79 +1,91 @@
|
||||
# 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.
|
||||
This guide assumes a dedicated `wgcloak` service account and configuration
|
||||
under `/etc/wgcloak/`. Adjust paths to match the reviewed deployment.
|
||||
|
||||
## First installation
|
||||
## Build and install
|
||||
|
||||
```bash
|
||||
cargo test --all-targets
|
||||
cargo build --release
|
||||
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:
|
||||
Store each client secret in a separate file readable only by the service
|
||||
account (normally owner `wgcloak`, mode `0600`). Protect the parent directory
|
||||
from untrusted writes. The server needs its TLS certificate and private key
|
||||
readable by its service account when QUIC, HTTP/3, or TLS/TCP is enabled.
|
||||
|
||||
Validate configuration before starting a daemon:
|
||||
|
||||
```bash
|
||||
sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/client.toml
|
||||
sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/server.toml
|
||||
```
|
||||
|
||||
The command is a target v1 CLI and is delivered in the operational-polish
|
||||
milestone; confirm with `wgcloak --help`.
|
||||
## Start, stop, and reconnect
|
||||
|
||||
## Starting and stopping
|
||||
|
||||
Foreground operation is useful for first-run diagnostics:
|
||||
Foreground operation is useful during initial setup:
|
||||
|
||||
```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.
|
||||
On Linux, SIGINT, SIGTERM, or the control `shutdown` command initiates bounded
|
||||
shutdown. The server stops accepting sessions and gives listeners/sessions a
|
||||
bounded cleanup window before abort fallback. A client reconnects after
|
||||
transport failures using exponential backoff with jitter from 250 ms to 30 s;
|
||||
the sequence resets after a successful connection. Authentication rejection
|
||||
stops automatic probing rather than trying other transports.
|
||||
|
||||
## Control socket (intended interface)
|
||||
Direct UDP keepalives are one-way and do not acknowledge peer liveness. A
|
||||
silent direct-UDP server restart may therefore not be detected immediately;
|
||||
use `wgcloak reconnect` on the client to force a fresh handshake. The tested
|
||||
restart path waits through normal reconnect backoff while the server is
|
||||
unavailable, then resumes when it returns.
|
||||
|
||||
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.
|
||||
## Local control socket
|
||||
|
||||
Target commands are:
|
||||
By default the socket is `$XDG_RUNTIME_DIR/wgcloak.sock` when that runtime
|
||||
directory is owned by the current user and has mode `0700`. Otherwise the
|
||||
daemon uses `/run/user/<uid>/wgcloak/wgcloak.sock` when that private user
|
||||
runtime directory exists, or `$HOME/.local/run/wgcloak/wgcloak.sock` as a
|
||||
per-user fallback. The socket's parent is created with mode `0700`; the socket
|
||||
itself is mode `0600`. An active socket path is never unlinked; a stale socket
|
||||
is removed only after its type and identity are checked. Use the same runtime
|
||||
directory for the CLI that is used by the daemon, or pass `--socket`
|
||||
explicitly (its parent directory must be owned by you and mode `0700`):
|
||||
|
||||
```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
|
||||
```bash
|
||||
wgcloak status
|
||||
wgcloak stats
|
||||
wgcloak reconnect
|
||||
wgcloak reload
|
||||
wgcloak 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.
|
||||
`status` and `stats` report the running state. `reconnect` asks a client to
|
||||
leave its current session and reconnect; the server rejects this client-only
|
||||
command. `reload` currently returns “configuration reload is not supported”.
|
||||
Neither configuration hot reload nor certificate/key reload is implemented.
|
||||
`shutdown` requests graceful process shutdown. The control protocol is local
|
||||
newline-delimited JSON; do not send credentials or application payloads over
|
||||
it.
|
||||
|
||||
For a non-default control path, set `XDG_RUNTIME_DIR` consistently for both
|
||||
daemon and CLI, or use the CLI's `--socket PATH` option.
|
||||
|
||||
## 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:
|
||||
Review and copy the units under `packaging/systemd/`. They assume binary
|
||||
`/usr/bin/wgcloak`, user/group `wgcloak`, and role configuration under
|
||||
`/etc/wgcloak/`. Each unit creates a private runtime directory and sets
|
||||
`XDG_RUNTIME_DIR` there.
|
||||
|
||||
```bash
|
||||
sudo install -Dm0644 packaging/systemd/wgcloak-client.service /etc/systemd/system/wgcloak-client.service
|
||||
sudo install -Dm0644 packaging/systemd/wgcloak-client.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wgcloak-client.service
|
||||
systemctl status wgcloak-client.service
|
||||
@@ -81,13 +93,15 @@ 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.
|
||||
and its matching bounding-set entry in the server unit only if required. Normal
|
||||
high-port operation needs no capability. Keep `CAP_NET_ADMIN` and
|
||||
`CAP_SYS_ADMIN` disabled.
|
||||
|
||||
## 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.
|
||||
Start with one enabled transport and one test client. Verify authentication,
|
||||
backend reachability, reply delivery, status/statistics, and clean shutdown.
|
||||
Add transports one at a time. Direct UDP, QUIC, and HTTP/3 are UDP listeners;
|
||||
QUIC and HTTP/3 cannot bind the same address/port in the current architecture.
|
||||
TLS/TCP may use the same numeric port because it uses TCP. Keep an independent
|
||||
management path available when changing public listener addresses.
|
||||
+23
-13
@@ -49,20 +49,30 @@ 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.
|
||||
- **Direct UDP:** native datagrams; uses the authenticated protocol above and
|
||||
provides integrity but not outer confidentiality.
|
||||
- **QUIC DATAGRAM:** TLS 1.3 protects the connection; a client shared-secret
|
||||
challenge/response authenticates the configured client. Application
|
||||
datagrams use QUIC DATAGRAM frames.
|
||||
- **HTTP/3 CONNECT-UDP:** uses extended CONNECT with `:protocol=connect-udp`
|
||||
and HTTP Datagrams with the RFC 9297 context ID. The request path is
|
||||
configurable. Capsules are used for the authentication exchange, not as the
|
||||
application-data carrier.
|
||||
- **TLS/TCP:** uses the same client authentication model. Each application
|
||||
datagram is framed by a big-endian `u16` payload length and opaque payload;
|
||||
the configured maximum applies. 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.
|
||||
All four carriers are wired into the current client/server runtime. Client
|
||||
`mode = "auto"` probes its configured order, retries bounded probe attempts,
|
||||
and stops on authentication rejection. Reconnection after transport loss uses
|
||||
bounded exponential backoff with jitter; successful connection resets the
|
||||
backoff. This describes the current code path, not a claim that every v1
|
||||
acceptance criterion has passed.
|
||||
|
||||
The control `reload` command is currently unsupported, including certificate
|
||||
or key reload. See the checklist for acceptance work that remains before a v1
|
||||
release. The direct packet details and the encapsulated transport APIs are
|
||||
project-specific; interoperability requires matching this protocol.
|
||||
|
||||
## Authentication and resource order
|
||||
|
||||
|
||||
+18
-12
@@ -8,15 +8,16 @@ Check the simplest layer that can explain the symptom, then move outward.
|
||||
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.
|
||||
- Run `wgcloak --help` and `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.
|
||||
- Confirm the selected server listener is enabled and that the matching
|
||||
transport/port appears in its `status` output. In auto mode, check the
|
||||
configured client order and `retry_ms` status while it probes.
|
||||
- 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
|
||||
@@ -44,17 +45,22 @@ fallback probing rather than generate traffic across every transport.
|
||||
|
||||
## 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.
|
||||
The server's configured idle timeout applies per session. Check transport
|
||||
keepalive behavior, 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.
|
||||
Check the daemon and CLI use the same `XDG_RUNTIME_DIR`, and verify the
|
||||
directory is private and the socket mode is `0600`. If `XDG_RUNTIME_DIR` is
|
||||
unset or is not current-user-owned mode `0700`, the daemon uses
|
||||
`/run/user/<uid>/wgcloak/` when available, or `$HOME/.local/run/wgcloak/`.
|
||||
An active socket is never removed; a stale socket can be replaced only after
|
||||
its type and identity are checked.
|
||||
The `reload` command is present but currently returns an unsupported response;
|
||||
neither configuration nor certificate hot reload is implemented.
|
||||
|
||||
## Host hardening prevents startup
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ listen = "127.0.0.1:51821"
|
||||
[transport]
|
||||
mode = "auto"
|
||||
|
||||
# Auto probing is target behavior; use only transports supported by the build.
|
||||
# Auto probing follows this order; authentication rejection stops fallback.
|
||||
[transport.auto]
|
||||
order = ["direct_udp", "quic", "http3", "tls_tcp"]
|
||||
probe_timeout_ms = 1500
|
||||
@@ -26,7 +26,7 @@ port = 443
|
||||
keepalive_secs = 15
|
||||
|
||||
[transport.http3]
|
||||
port = 443
|
||||
port = 4443
|
||||
path = "/tunnel"
|
||||
|
||||
[transport.tls_tcp]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Advanced server example. Values are placeholders, not credentials.
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0:51830"
|
||||
listen = "0.0.0.0"
|
||||
|
||||
[backend]
|
||||
target = "127.0.0.1:51820"
|
||||
@@ -16,7 +16,7 @@ port = 443
|
||||
|
||||
[transports.http3]
|
||||
enabled = true
|
||||
port = 443
|
||||
port = 4443
|
||||
path = "/tunnel"
|
||||
|
||||
[transports.tls_tcp]
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
# Minimal server. Replace the backend and client secret path before use.
|
||||
# The listener address/port is an example; open only the transports you need.
|
||||
# This is a host address; the transport section below selects the port.
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0:51830"
|
||||
listen = "0.0.0.0"
|
||||
|
||||
[backend]
|
||||
target = "127.0.0.1:51820"
|
||||
|
||||
# Milestone 1 direct-UDP deployment. Other transports are planned or optional.
|
||||
# Direct UDP only. Other transports can be enabled explicitly when needed.
|
||||
[transports.direct_udp]
|
||||
enabled = true
|
||||
port = 51830
|
||||
|
||||
[transports.quic]
|
||||
enabled = false
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ UMask=0077
|
||||
|
||||
# Keep the control socket in a private runtime directory.
|
||||
RuntimeDirectory=wgcloak-client
|
||||
RuntimeDirectoryMode=0750
|
||||
RuntimeDirectoryMode=0700
|
||||
Environment=XDG_RUNTIME_DIR=/run/wgcloak-client
|
||||
|
||||
# Process and filesystem hardening. Review paths if the deployment changes.
|
||||
|
||||
@@ -15,7 +15,7 @@ UMask=0077
|
||||
|
||||
# Keep the control socket in a private runtime directory.
|
||||
RuntimeDirectory=wgcloak-server
|
||||
RuntimeDirectoryMode=0750
|
||||
RuntimeDirectoryMode=0700
|
||||
Environment=XDG_RUNTIME_DIR=/run/wgcloak-server
|
||||
|
||||
# Process and filesystem hardening. Review paths if the deployment changes.
|
||||
|
||||
+3
-3
@@ -50,7 +50,7 @@ pub enum Command {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
/// Request configuration reload.
|
||||
/// Request configuration reload (currently unsupported).
|
||||
Reload {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
@@ -71,12 +71,12 @@ mod tests {
|
||||
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"))
|
||||
matches!(cli.command, Command::Client { config } if config == std::path::Path::new("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"))
|
||||
matches!(cli.command, Command::CheckConfig { config } if config == std::path::Path::new("server.toml"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+407
-50
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
@@ -8,39 +8,148 @@ use tracing::{debug, info, warn};
|
||||
use crate::{
|
||||
config::{ClientConfig, TransportMode},
|
||||
control::{ControlServer, ControlState},
|
||||
stats::Stats,
|
||||
transport::{
|
||||
quic::QuicClientConnector, DirectUdpConnector, TlsTcpConnector, TransportConnector,
|
||||
TransportError, TransportSession,
|
||||
quic::QuicClientConnector, DirectUdpConnector, Http3ClientConnector, TlsTcpConnector,
|
||||
TransportConnector, TransportError, TransportSession,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod supervisor;
|
||||
use supervisor::ReconnectBackoff;
|
||||
|
||||
pub async fn run(config: ClientConfig) -> Result<()> {
|
||||
run_with_http3_connector_and_control_path(config, crate::control::default_socket_path(), None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Client entrypoint with an isolated local control socket and optional
|
||||
/// caller-supplied HTTP/3 connector. The connector override is useful for
|
||||
/// managed trust stores and is also used by loopback runtime tests.
|
||||
pub async fn run_with_http3_connector_and_control_path(
|
||||
config: ClientConfig,
|
||||
control_path: impl Into<std::path::PathBuf>,
|
||||
http3_connector: Option<Arc<dyn TransportConnector>>,
|
||||
) -> Result<()> {
|
||||
run_with_connector_override_and_control_path(
|
||||
config,
|
||||
control_path,
|
||||
http3_connector.map(|connector| (TransportMode::Http3, connector)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run the real client loop with a caller-supplied connector for one concrete
|
||||
/// transport. Production normally uses the built-in connector; this hook is
|
||||
/// useful for embedders with explicit trust stores and runtime integration
|
||||
/// tests that verify normal certificate and hostname checks.
|
||||
pub async fn run_with_transport_connector_and_control_path(
|
||||
config: ClientConfig,
|
||||
control_path: impl Into<std::path::PathBuf>,
|
||||
transport: TransportMode,
|
||||
connector: Arc<dyn TransportConnector>,
|
||||
) -> Result<()> {
|
||||
if transport == TransportMode::Auto {
|
||||
anyhow::bail!("runtime connector override requires a concrete transport");
|
||||
}
|
||||
run_with_connector_override_and_control_path(config, control_path, Some((transport, connector)))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_with_connector_override_and_control_path(
|
||||
config: ClientConfig,
|
||||
control_path: impl Into<std::path::PathBuf>,
|
||||
connector_override: Option<(TransportMode, Arc<dyn TransportConnector>)>,
|
||||
) -> Result<()> {
|
||||
config.validate()?;
|
||||
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;
|
||||
});
|
||||
control_state.enable_reconnect();
|
||||
let control = ControlServer::bind(control_path, control_state.clone()).await?;
|
||||
let mut control_task = tokio::spawn(control.run());
|
||||
let stats = Arc::new(Stats::default());
|
||||
control_state
|
||||
.set_status(format!("starting; server={}", config.client.server))
|
||||
.await;
|
||||
|
||||
let result = run_loop(
|
||||
&config,
|
||||
&local,
|
||||
&control_state,
|
||||
&stats,
|
||||
connector_override
|
||||
.as_ref()
|
||||
.map(|(mode, connector)| (*mode, connector.as_ref())),
|
||||
)
|
||||
.await;
|
||||
control_state.set_status("stopping").await;
|
||||
control_state.shutdown.cancel();
|
||||
// Control handlers are local but untrusted; bound the join in case a
|
||||
// client stalls while writing a request.
|
||||
if time::timeout(Duration::from_secs(2), &mut control_task)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
control_task.abort();
|
||||
}
|
||||
control_state.set_status("stopped").await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_loop(
|
||||
config: &ClientConfig,
|
||||
local: &UdpSocket,
|
||||
control: &ControlState,
|
||||
stats: &Stats,
|
||||
connector_override: Option<(TransportMode, &dyn TransportConnector)>,
|
||||
) -> Result<()> {
|
||||
let mut latest_peer = None;
|
||||
let mut backoff = Duration::from_millis(250);
|
||||
let mut backoff = ReconnectBackoff::default();
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
control
|
||||
.set_status(format!("probing; server={}", config.client.server))
|
||||
.await;
|
||||
let connected = tokio::select! {
|
||||
_ = &mut signal => return Ok(()),
|
||||
_ = control_state.shutdown.cancelled() => return Ok(()),
|
||||
result = connect_for_mode(&config) => result,
|
||||
_ = control.shutdown.cancelled() => return Ok(()),
|
||||
_ = control.reconnect.notified() => {
|
||||
backoff.reset();
|
||||
continue;
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
publish_client_stats(control, stats, config, "probing", None, None, None).await;
|
||||
continue;
|
||||
}
|
||||
result = connect_for_mode(config, connector_override) => result,
|
||||
};
|
||||
let (session, mode) = match connected {
|
||||
Ok(value) => {
|
||||
backoff = Duration::from_millis(250);
|
||||
backoff.reset();
|
||||
value
|
||||
}
|
||||
Err(TransportError::AuthenticationRejected(reason)) => {
|
||||
stats
|
||||
.authentication_failures
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
stats
|
||||
.datagrams_dropped
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
publish_client_stats(
|
||||
control,
|
||||
stats,
|
||||
config,
|
||||
"authentication_rejected",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
warn!(?reason, "server rejected client authentication");
|
||||
return Err(anyhow::anyhow!(TransportError::AuthenticationRejected(
|
||||
reason
|
||||
@@ -48,87 +157,335 @@ pub async fn run(config: ClientConfig) -> Result<()> {
|
||||
}
|
||||
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));
|
||||
let delay = backoff.next_delay();
|
||||
warn!(error=%error, retry_in_ms=delay.as_millis(), "configured transports unavailable");
|
||||
control
|
||||
.set_status(format!(
|
||||
"reconnecting; server={}; error={error}; retry_ms={}",
|
||||
config.client.server,
|
||||
delay.as_millis()
|
||||
))
|
||||
.await;
|
||||
publish_client_stats(control, stats, config, "reconnecting", None, None, None)
|
||||
.await;
|
||||
tokio::select! {
|
||||
_ = &mut signal => return Ok(()),
|
||||
_ = control.shutdown.cancelled() => return Ok(()),
|
||||
_ = control.reconnect.notified() => backoff.reset(),
|
||||
_ = time::sleep(delay) => {},
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
info!(?mode, "client transport connected");
|
||||
let outcome =
|
||||
run_connected(&local, session.as_ref(), &mut latest_peer, &control_state).await;
|
||||
let connected_at = tokio::time::Instant::now();
|
||||
control
|
||||
.set_status(format!(
|
||||
"connected; server={}; transport={}",
|
||||
config.client.server,
|
||||
transport_name(mode),
|
||||
))
|
||||
.await;
|
||||
publish_client_stats(
|
||||
control,
|
||||
stats,
|
||||
config,
|
||||
"connected",
|
||||
Some(mode),
|
||||
Some(Duration::ZERO),
|
||||
Some(session.as_ref()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcome = run_connected(
|
||||
local,
|
||||
session.as_ref(),
|
||||
&mut latest_peer,
|
||||
control,
|
||||
stats,
|
||||
config,
|
||||
mode,
|
||||
connected_at,
|
||||
)
|
||||
.await;
|
||||
let _ = session.close().await;
|
||||
if outcome {
|
||||
return Ok(());
|
||||
match outcome {
|
||||
ConnectedOutcome::Shutdown => return Ok(()),
|
||||
ConnectedOutcome::Reconnect => {
|
||||
stats
|
||||
.reconnections
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let delay = backoff.next_delay();
|
||||
control
|
||||
.set_status(format!(
|
||||
"reconnecting; server={}; previous_transport={}; retry_ms={}",
|
||||
config.client.server,
|
||||
transport_name(mode),
|
||||
delay.as_millis()
|
||||
))
|
||||
.await;
|
||||
warn!(?mode, "transport session lost; reconnecting");
|
||||
tokio::select! {
|
||||
_ = &mut signal => return Ok(()),
|
||||
_ = control.shutdown.cancelled() => return Ok(()),
|
||||
_ = control.reconnect.notified() => backoff.reset(),
|
||||
_ = time::sleep(delay) => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!(?mode, "transport session lost; reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_for_mode(
|
||||
config: &ClientConfig,
|
||||
connector_override: Option<(TransportMode, &dyn TransportConnector)>,
|
||||
) -> Result<(Box<dyn TransportSession>, TransportMode), TransportError> {
|
||||
let modes = match config.transport.mode {
|
||||
TransportMode::Auto => config.transport.auto.order.clone(),
|
||||
mode => vec![mode],
|
||||
};
|
||||
let deadline = Duration::from_millis(config.transport.auto.probe_timeout_ms.max(1));
|
||||
let retries = config.transport.auto.retries.max(1);
|
||||
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;
|
||||
for _attempt in 0..retries {
|
||||
let result =
|
||||
time::timeout(deadline, connect_one(config, mode, connector_override)).await;
|
||||
let result = match result {
|
||||
Ok(value) => value,
|
||||
Err(_) => Err(TransportError::HandshakeTimeout),
|
||||
};
|
||||
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 connect_one(
|
||||
config: &ClientConfig,
|
||||
mode: TransportMode,
|
||||
connector_override: Option<(TransportMode, &dyn TransportConnector)>,
|
||||
) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
if let Some((override_mode, connector)) = connector_override {
|
||||
if override_mode == mode {
|
||||
return connector.connect().await;
|
||||
}
|
||||
}
|
||||
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 => Http3ClientConnector::new(config.clone()).connect().await,
|
||||
TransportMode::Auto => Err(TransportError::Unsupported(
|
||||
"nested auto transport mode".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ConnectedOutcome {
|
||||
Shutdown,
|
||||
Reconnect,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_connected(
|
||||
local: &UdpSocket,
|
||||
session: &dyn TransportSession,
|
||||
latest_peer: &mut Option<std::net::SocketAddr>,
|
||||
control: &ControlState,
|
||||
) -> bool {
|
||||
stats: &Stats,
|
||||
config: &ClientConfig,
|
||||
mode: TransportMode,
|
||||
connected_at: tokio::time::Instant,
|
||||
) -> ConnectedOutcome {
|
||||
let mut local_buf = vec![0u8; session.max_datagram_size().saturating_add(1)];
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
let mut keepalive = session
|
||||
.authenticated_keepalive_interval()
|
||||
.filter(|interval| !interval.is_zero())
|
||||
.map(|interval| time::interval_at(time::Instant::now() + interval, interval));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut signal => return true,
|
||||
_ = control.shutdown.cancelled() => return true,
|
||||
_ = control.reconnect.notified() => return false,
|
||||
_ = control.reload.notified() => return false,
|
||||
_ = &mut signal => return ConnectedOutcome::Shutdown,
|
||||
_ = control.shutdown.cancelled() => return ConnectedOutcome::Shutdown,
|
||||
_ = control.reconnect.notified() => return ConnectedOutcome::Reconnect,
|
||||
_ = refresh.tick() => {
|
||||
let duration = connected_at.elapsed();
|
||||
publish_client_stats(
|
||||
control,
|
||||
stats,
|
||||
config,
|
||||
"connected",
|
||||
Some(mode),
|
||||
Some(duration),
|
||||
Some(session),
|
||||
).await;
|
||||
}
|
||||
_ = async {
|
||||
match keepalive.as_mut() {
|
||||
Some(interval) => interval.tick().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => {
|
||||
// Once enqueued, the TLS/TCP writer actor completes the full
|
||||
// control frame even if this caller's wait is cancelled.
|
||||
match time::timeout(Duration::from_secs(2), session.send_keepalive()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
debug!(%error, "authenticated transport keepalive failed");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("authenticated transport keepalive timed out");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
}
|
||||
}
|
||||
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"); }
|
||||
let (len, peer) = match received {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
warn!(%error, "local UDP receive failed");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
};
|
||||
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; }
|
||||
if len > session.max_datagram_size() {
|
||||
stats.oversized_datagrams.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
warn!(len, maximum=session.max_datagram_size(), "dropping oversized local datagram");
|
||||
continue;
|
||||
}
|
||||
// Do not cancel a partially written framed datagram merely
|
||||
// because a control request arrives. Let it finish, but cap
|
||||
// the transport operation so shutdown remains bounded.
|
||||
let sent = time::timeout(
|
||||
Duration::from_secs(2),
|
||||
session.send_datagram(Bytes::copy_from_slice(&local_buf[..len])),
|
||||
)
|
||||
.await;
|
||||
match sent {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
debug!(%error, "transport send failed");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
Err(_) => {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
debug!("transport send timed out");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
}
|
||||
stats.add_sent(len);
|
||||
}
|
||||
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; } }
|
||||
let payload = match received {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
debug!(%error, "transport receive failed");
|
||||
return ConnectedOutcome::Reconnect;
|
||||
}
|
||||
};
|
||||
if payload.len() > session.max_datagram_size() {
|
||||
stats.oversized_datagrams.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
if let Some(peer) = *latest_peer {
|
||||
match local.send_to(&payload, peer).await {
|
||||
Ok(_) => stats.add_received(payload.len()),
|
||||
Err(error) => {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
debug!(%error, "local UDP delivery failed");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stats.datagrams_dropped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_client_stats(
|
||||
control: &ControlState,
|
||||
stats: &Stats,
|
||||
config: &ClientConfig,
|
||||
state: &str,
|
||||
mode: Option<TransportMode>,
|
||||
duration: Option<Duration>,
|
||||
session: Option<&dyn TransportSession>,
|
||||
) {
|
||||
let s = stats.snapshot();
|
||||
let transport = mode
|
||||
.map(|v| format!("\"{}\"", transport_name(v)))
|
||||
.unwrap_or_else(|| "null".into());
|
||||
let duration_ms = duration.map(|v| v.as_millis()).unwrap_or(0);
|
||||
let remote_addr = session
|
||||
.and_then(TransportSession::remote_addr)
|
||||
.map(|value| format!("\"{}\"", json_escape(&value.to_string())))
|
||||
.unwrap_or_else(|| "null".into());
|
||||
let rtt_ms = session
|
||||
.and_then(TransportSession::rtt)
|
||||
.map(|value| value.as_millis().to_string())
|
||||
.unwrap_or_else(|| "null".into());
|
||||
let keepalive_interval_secs = session
|
||||
.and_then(TransportSession::authenticated_keepalive_interval)
|
||||
.map(|value| value.as_secs().to_string())
|
||||
.unwrap_or_else(|| "null".into());
|
||||
control
|
||||
.set_stats_json(format!(
|
||||
"{{\"state\":\"{state}\",\"server\":\"{}\",\"transport\":{transport},\"connection_duration_ms\":{duration_ms},\"remote_address\":{remote_addr},\"rtt_ms\":{rtt_ms},\"authenticated_keepalive_interval_secs\":{keepalive_interval_secs},\"max_datagram_size\":{},\"bytes_sent\":{},\"bytes_received\":{},\"datagrams_sent\":{},\"datagrams_received\":{},\"datagrams_dropped\":{},\"oversized_datagrams\":{},\"authentication_failures\":{},\"reconnections\":{}}}",
|
||||
json_escape(&config.client.server),
|
||||
config.network.max_datagram_size,
|
||||
s.bytes_sent,
|
||||
s.bytes_received,
|
||||
s.datagrams_sent,
|
||||
s.datagrams_received,
|
||||
s.datagrams_dropped,
|
||||
s.oversized_datagrams,
|
||||
s.authentication_failures,
|
||||
s.reconnections,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn transport_name(mode: TransportMode) -> &'static str {
|
||||
match mode {
|
||||
TransportMode::Auto => "auto",
|
||||
TransportMode::DirectUdp => "direct_udp",
|
||||
TransportMode::Quic => "quic",
|
||||
TransportMode::Http3 => "http3",
|
||||
TransportMode::TlsTcp => "tls_tcp",
|
||||
}
|
||||
}
|
||||
|
||||
fn json_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
+29
-12
@@ -70,7 +70,32 @@ pub struct TransportSupervisor<P> {
|
||||
status: Arc<Mutex<SupervisorStatus>>,
|
||||
reconnect: Arc<Notify>,
|
||||
shutdown: CancellationToken,
|
||||
backoff_step: u32,
|
||||
backoff: ReconnectBackoff,
|
||||
}
|
||||
|
||||
/// Exponential reconnect delay with bounded downward jitter. The first retry
|
||||
/// is 250 ms; later retries grow to at most 30 seconds. A successful
|
||||
/// connection resets the sequence.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ReconnectBackoff {
|
||||
step: u32,
|
||||
}
|
||||
impl ReconnectBackoff {
|
||||
pub fn next_delay(&mut self) -> Duration {
|
||||
let n = self.step.min(17);
|
||||
let base = 250u64.saturating_mul(1u64 << n).min(30_000);
|
||||
let jitter = if base <= 250 {
|
||||
0
|
||||
} else {
|
||||
rand::random::<u64>() % (base / 4).max(1)
|
||||
};
|
||||
self.step = self.step.saturating_add(1);
|
||||
Duration::from_millis(base.saturating_sub(jitter).clamp(250, 30_000))
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.step = 0;
|
||||
}
|
||||
}
|
||||
impl<P: TransportProbe> TransportSupervisor<P> {
|
||||
pub fn new(config: ClientConfig, probe: P) -> Self {
|
||||
@@ -80,7 +105,7 @@ impl<P: TransportProbe> TransportSupervisor<P> {
|
||||
status: Arc::new(Mutex::new(SupervisorStatus::default())),
|
||||
reconnect: Arc::new(Notify::new()),
|
||||
shutdown: CancellationToken::new(),
|
||||
backoff_step: 0,
|
||||
backoff: ReconnectBackoff::default(),
|
||||
}
|
||||
}
|
||||
pub fn shutdown_token(&self) -> CancellationToken {
|
||||
@@ -138,18 +163,10 @@ impl<P: TransportProbe> TransportSupervisor<P> {
|
||||
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::<u64>() % (base / 4).max(1)
|
||||
};
|
||||
self.backoff_step = self.backoff_step.saturating_add(1);
|
||||
Duration::from_millis(base.saturating_sub(jitter).clamp(250, 30_000))
|
||||
self.backoff.next_delay()
|
||||
}
|
||||
pub fn reset_backoff(&mut self) {
|
||||
self.backoff_step = 0;
|
||||
self.backoff.reset();
|
||||
}
|
||||
pub async fn run(mut self) -> Result<(), TransportError> {
|
||||
loop {
|
||||
|
||||
+522
-19
@@ -13,6 +13,11 @@ use tracing::{info, warn};
|
||||
|
||||
const DEFAULT_MAX_DATAGRAM_SIZE: u16 = 1200;
|
||||
const MIN_SECRET_BYTES: usize = 32;
|
||||
const MAX_DATAGRAM_SIZE: u16 = u16::MAX - 32;
|
||||
const MAX_KEEPALIVE_SECS: u64 = 86_400;
|
||||
const MAX_IDLE_TIMEOUT_SECS: u64 = 86_400;
|
||||
const MAX_PROBE_TIMEOUT_MS: u64 = 300_000;
|
||||
const MAX_AUTO_RETRIES: u8 = 20;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
@@ -120,14 +125,11 @@ pub struct ClientConfig {
|
||||
|
||||
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(),
|
||||
));
|
||||
}
|
||||
validate_host(&self.client.server, "client.server")?;
|
||||
if self.client.id.trim().is_empty() {
|
||||
return Err(ConfigError::Invalid("client.id must not be empty".into()));
|
||||
}
|
||||
validate_port(self.local.listen.port(), "local.listen")?;
|
||||
validate_secret_source(&self.client.secret, "client")?;
|
||||
validate_max_datagram(self.network.max_datagram_size)?;
|
||||
self.transport.validate()
|
||||
@@ -200,11 +202,61 @@ impl ClientTransportConfig {
|
||||
"transport.auto.probe_timeout_ms must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if self.auto.probe_timeout_ms > MAX_PROBE_TIMEOUT_MS {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"transport.auto.probe_timeout_ms must be <= {MAX_PROBE_TIMEOUT_MS}"
|
||||
)));
|
||||
}
|
||||
if self.auto.retries == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.auto.retries must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if self.auto.retries > MAX_AUTO_RETRIES {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"transport.auto.retries must be <= {MAX_AUTO_RETRIES}"
|
||||
)));
|
||||
}
|
||||
for (index, mode) in self.auto.order.iter().enumerate() {
|
||||
if *mode == TransportMode::Auto {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.auto.order must contain concrete transports, not 'auto'".into(),
|
||||
));
|
||||
}
|
||||
if self.auto.order[..index].contains(mode) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"transport.auto.order contains duplicate transport {mode:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the effective attempt list according to mode: an explicit
|
||||
// selection is a singleton and never falls back through auto.order.
|
||||
let active_modes = if self.mode == TransportMode::Auto {
|
||||
self.auto.order.as_slice()
|
||||
} else {
|
||||
std::slice::from_ref(&self.mode)
|
||||
};
|
||||
if active_modes.is_empty() || active_modes.contains(&TransportMode::Auto) {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.mode must resolve to one or more concrete transports".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_port(self.direct_udp.port, "transport.direct_udp.port")?;
|
||||
validate_port(self.quic.port, "transport.quic.port")?;
|
||||
validate_port(self.http3.port, "transport.http3.port")?;
|
||||
validate_port(self.tls_tcp.port, "transport.tls_tcp.port")?;
|
||||
validate_keepalive(
|
||||
self.direct_udp.keepalive_secs,
|
||||
"transport.direct_udp.keepalive_secs",
|
||||
)?;
|
||||
validate_keepalive(self.quic.keepalive_secs, "transport.quic.keepalive_secs")?;
|
||||
validate_keepalive(
|
||||
self.tls_tcp.keepalive_secs,
|
||||
"transport.tls_tcp.keepalive_secs",
|
||||
)?;
|
||||
validate_http3_path(&self.http3.path, "transport.http3.path")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -292,11 +344,15 @@ impl Default for ClientHttp3Config {
|
||||
pub struct ClientTlsTcpConfig {
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
/// Seconds between authenticated TLS/TCP control keepalives. Zero disables them.
|
||||
#[serde(default = "default_keepalive")]
|
||||
pub keepalive_secs: u64,
|
||||
}
|
||||
impl Default for ClientTlsTcpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_tls_port(),
|
||||
keepalive_secs: default_keepalive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,31 +389,74 @@ pub struct ServerConfig {
|
||||
|
||||
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::<SocketAddr>()
|
||||
.map_err(|_| ConfigError::Invalid("backend.target must be a socket address".into()))?;
|
||||
validate_host(&self.server.listen, "server.listen")?;
|
||||
let backend =
|
||||
self.backend.target.parse::<SocketAddr>().map_err(|_| {
|
||||
ConfigError::Invalid("backend.target must be a socket address".into())
|
||||
})?;
|
||||
validate_port(backend.port(), "backend.target")?;
|
||||
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.session.idle_timeout_secs == 0
|
||||
|| self.session.idle_timeout_secs > MAX_IDLE_TIMEOUT_SECS
|
||||
{
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"session.idle_timeout_secs must be between 1 and {MAX_IDLE_TIMEOUT_SECS}"
|
||||
)));
|
||||
}
|
||||
if self.limits.max_sessions == 0
|
||||
|| self.limits.max_sessions_per_ip == 0
|
||||
|| self.limits.max_pending_handshakes == 0
|
||||
|| self.limits.max_auth_attempts_per_ip == 0
|
||||
|| self.limits.auth_attempt_window_secs == 0
|
||||
|| self.limits.handshake_timeout_ms == 0
|
||||
{
|
||||
return Err(ConfigError::Invalid("session limits must be > 0".into()));
|
||||
return Err(ConfigError::Invalid(
|
||||
"session and authentication limits must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if self.limits.max_sessions > 1_000_000
|
||||
|| self.limits.max_sessions_per_ip > 1_000_000
|
||||
|| self.limits.max_pending_handshakes > 100_000
|
||||
|| self.limits.max_auth_attempts_per_ip > 100_000
|
||||
|| self.limits.auth_attempt_window_secs > 86_400
|
||||
|| self.limits.handshake_timeout_ms > 300_000
|
||||
{
|
||||
return Err(ConfigError::Invalid(
|
||||
"session and authentication limits exceed supported bounds".into(),
|
||||
));
|
||||
}
|
||||
if self.limits.max_sessions_per_ip > self.limits.max_sessions {
|
||||
return Err(ConfigError::Invalid(
|
||||
"limits.max_sessions_per_ip must be <= limits.max_sessions".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_port(
|
||||
self.transports.direct_udp.port,
|
||||
"transports.direct_udp.port",
|
||||
)?;
|
||||
validate_port(self.transports.quic.port, "transports.quic.port")?;
|
||||
validate_port(self.transports.http3.port, "transports.http3.port")?;
|
||||
validate_port(self.transports.tls_tcp.port, "transports.tls_tcp.port")?;
|
||||
validate_http3_path(&self.transports.http3.path, "transports.http3.path")?;
|
||||
|
||||
let enabled_transports = [
|
||||
self.transports.direct_udp.enabled,
|
||||
self.transports.quic.enabled,
|
||||
self.transports.http3.enabled,
|
||||
self.transports.tls_tcp.enabled,
|
||||
];
|
||||
if !enabled_transports.into_iter().any(|enabled| enabled) {
|
||||
return Err(ConfigError::Invalid(
|
||||
"at least one server transport must be enabled".into(),
|
||||
));
|
||||
}
|
||||
validate_udp_listener_ports(self)?;
|
||||
|
||||
let mut client_ids = HashSet::with_capacity(self.auth.clients.len());
|
||||
for client in &self.auth.clients {
|
||||
if client.id.trim().is_empty() {
|
||||
@@ -485,6 +584,19 @@ impl TlsConfig {
|
||||
"tls.certificate and tls.private_key must be provided together".into(),
|
||||
));
|
||||
}
|
||||
if self
|
||||
.certificate
|
||||
.as_ref()
|
||||
.is_some_and(|path| path.as_os_str().is_empty())
|
||||
|| self
|
||||
.private_key
|
||||
.as_ref()
|
||||
.is_some_and(|path| path.as_os_str().is_empty())
|
||||
{
|
||||
return Err(ConfigError::Invalid(
|
||||
"tls.certificate and tls.private_key paths must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -510,6 +622,10 @@ pub struct LimitsConfig {
|
||||
pub max_sessions_per_ip: usize,
|
||||
#[serde(default = "default_max_pending_handshakes")]
|
||||
pub max_pending_handshakes: usize,
|
||||
#[serde(default = "default_max_auth_attempts_per_ip")]
|
||||
pub max_auth_attempts_per_ip: usize,
|
||||
#[serde(default = "default_auth_attempt_window_secs")]
|
||||
pub auth_attempt_window_secs: u64,
|
||||
#[serde(default = "default_handshake_timeout_ms")]
|
||||
pub handshake_timeout_ms: u64,
|
||||
}
|
||||
@@ -519,6 +635,8 @@ impl Default for LimitsConfig {
|
||||
max_sessions: default_max_sessions(),
|
||||
max_sessions_per_ip: default_max_sessions_per_ip(),
|
||||
max_pending_handshakes: default_max_pending_handshakes(),
|
||||
max_auth_attempts_per_ip: default_max_auth_attempts_per_ip(),
|
||||
auth_attempt_window_secs: default_auth_attempt_window_secs(),
|
||||
handshake_timeout_ms: default_handshake_timeout_ms(),
|
||||
}
|
||||
}
|
||||
@@ -728,17 +846,138 @@ fn default_max_sessions_per_ip() -> usize {
|
||||
fn default_max_pending_handshakes() -> usize {
|
||||
256
|
||||
}
|
||||
fn default_max_auth_attempts_per_ip() -> usize {
|
||||
20
|
||||
}
|
||||
fn default_auth_attempt_window_secs() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_handshake_timeout_ms() -> u64 {
|
||||
3000
|
||||
}
|
||||
fn default_log_level() -> String {
|
||||
"info".to_owned()
|
||||
}
|
||||
|
||||
fn validate_host(value: &str, label: &str) -> Result<(), ConfigError> {
|
||||
let invalid = || {
|
||||
ConfigError::Invalid(format!(
|
||||
"{label} must be a hostname or IP address without a port"
|
||||
))
|
||||
};
|
||||
if value.is_empty() || value != value.trim() {
|
||||
return Err(invalid());
|
||||
}
|
||||
if value.parse::<std::net::IpAddr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
if value.starts_with('[') && value.ends_with(']') {
|
||||
let ipv6 = &value[1..value.len() - 1];
|
||||
if ipv6.parse::<std::net::Ipv6Addr>().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if !value.is_ascii() || value.contains([':', '/', '[', ']', '@']) {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
let hostname = value.strip_suffix('.').unwrap_or(value);
|
||||
if hostname.is_empty() || hostname.len() > 253 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let labels: Vec<&str> = hostname.split('.').collect();
|
||||
if labels.iter().any(|part| {
|
||||
part.is_empty()
|
||||
|| part.len() > 63
|
||||
|| part.starts_with('-')
|
||||
|| part.ends_with('-')
|
||||
|| !part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||
}) {
|
||||
return Err(invalid());
|
||||
}
|
||||
// Avoid treating malformed dotted-quad literals as DNS names.
|
||||
if labels.len() == 4
|
||||
&& labels
|
||||
.iter()
|
||||
.all(|part| part.bytes().all(|b| b.is_ascii_digit()))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_port(port: u16, label: &str) -> Result<(), ConfigError> {
|
||||
if port == 0 {
|
||||
Err(ConfigError::Invalid(format!(
|
||||
"{label} must be between 1 and 65535"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_keepalive(seconds: u64, label: &str) -> Result<(), ConfigError> {
|
||||
if seconds > MAX_KEEPALIVE_SECS {
|
||||
Err(ConfigError::Invalid(format!(
|
||||
"{label} must be 0 (disabled) or <= {MAX_KEEPALIVE_SECS} seconds"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_http3_path(path: &str, label: &str) -> Result<(), ConfigError> {
|
||||
if !path.starts_with('/') {
|
||||
Err(ConfigError::Invalid(format!("{label} must begin with '/'")))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_udp_listener_ports(config: &ServerConfig) -> Result<(), ConfigError> {
|
||||
let udp_listeners = [
|
||||
(
|
||||
"direct_udp",
|
||||
config.transports.direct_udp.enabled,
|
||||
config.transports.direct_udp.port,
|
||||
),
|
||||
(
|
||||
"quic",
|
||||
config.transports.quic.enabled,
|
||||
config.transports.quic.port,
|
||||
),
|
||||
(
|
||||
"http3",
|
||||
config.transports.http3.enabled,
|
||||
config.transports.http3.port,
|
||||
),
|
||||
];
|
||||
for (index, (left_name, left_enabled, left_port)) in udp_listeners.iter().enumerate() {
|
||||
if !left_enabled {
|
||||
continue;
|
||||
}
|
||||
for (right_name, right_enabled, right_port) in udp_listeners.iter().skip(index + 1) {
|
||||
if *right_enabled && left_port == right_port {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"UDP listeners {left_name} and {right_name} conflict on port {left_port}; configure distinct UDP ports"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_max_datagram(value: u16) -> Result<(), ConfigError> {
|
||||
if value == 0 {
|
||||
Err(ConfigError::Invalid(
|
||||
"network.max_datagram_size must be > 0".into(),
|
||||
))
|
||||
} else if value > MAX_DATAGRAM_SIZE {
|
||||
Err(ConfigError::Invalid(format!(
|
||||
"network.max_datagram_size must be <= {MAX_DATAGRAM_SIZE}"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -752,6 +991,40 @@ mod tests {
|
||||
|
||||
const SECRET: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
|
||||
fn client_text(server: &str, extra: &str) -> String {
|
||||
format!("[client]\nserver='{server}'\nid='laptop'\nsecret='{SECRET}'\n{extra}")
|
||||
}
|
||||
|
||||
fn server_text(listen: &str, extra: &str) -> String {
|
||||
format!(
|
||||
"[server]\nlisten='{listen}'\n[backend]\ntarget='127.0.0.1:51820'\n{extra}\n[[auth.clients]]\nid='laptop'\nsecret='{SECRET}'\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn validation_error(text: &str) -> String {
|
||||
Config::from_str(text)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn with_test_secrets(example: &str) -> String {
|
||||
example
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(indent) = line.strip_suffix(trimmed) {
|
||||
if trimmed.starts_with("secret_file =") {
|
||||
return format!("{indent}secret = \"{SECRET}\"");
|
||||
}
|
||||
}
|
||||
line.to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_defaults_and_validation_work() {
|
||||
let text = format!("[client]\nserver='vpn.example.com'\nid='laptop'\nsecret='{SECRET}'\n");
|
||||
@@ -761,6 +1034,16 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(client.transport.tls_tcp.keepalive_secs, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_tcp_keepalive_can_be_disabled_without_breaking_old_configs() {
|
||||
let text = format!(
|
||||
"[client]\nserver='vpn.example.com'\nid='laptop'\nsecret='{SECRET}'\n[transport.tls_tcp]\nkeepalive_secs=0\n"
|
||||
);
|
||||
let client = Config::from_str(&text).unwrap().into_client().unwrap();
|
||||
assert_eq!(client.transport.tls_tcp.keepalive_secs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -809,6 +1092,226 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_hostnames_and_ip_literals_are_accepted_but_ports_and_bad_syntax_are_not() {
|
||||
for host in [
|
||||
"vpn.example.com",
|
||||
"vpn.example.com.",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
] {
|
||||
Config::from_str(&client_text(host, ""))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap_or_else(|error| panic!("expected {host:?} to validate: {error}"));
|
||||
}
|
||||
for host in [
|
||||
"",
|
||||
"vpn.example.com:443",
|
||||
"bad..host",
|
||||
"-bad.example",
|
||||
"bad host",
|
||||
"256.1.1.1",
|
||||
] {
|
||||
let error = validation_error(&client_text(host, ""));
|
||||
assert!(error.contains("client.server"), "{host:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_transport_order_mode_ports_paths_sizes_and_timeouts_are_validated() {
|
||||
for (extra, expected) in [
|
||||
("[transport.auto]\norder=[]\n", "must not be empty"),
|
||||
("[transport.auto]\norder=['auto']\n", "concrete transports"),
|
||||
(
|
||||
"[transport.auto]\norder=['quic','quic']\n",
|
||||
"duplicate transport",
|
||||
),
|
||||
("[transport.auto]\nprobe_timeout_ms=0\n", "probe_timeout_ms"),
|
||||
(
|
||||
"[transport.auto]\nprobe_timeout_ms=300001\n",
|
||||
"probe_timeout_ms",
|
||||
),
|
||||
("[transport.auto]\nretries=0\n", "retries"),
|
||||
("[transport.auto]\nretries=21\n", "retries"),
|
||||
("[local]\nlisten='127.0.0.1:0'\n", "local.listen"),
|
||||
(
|
||||
"[transport.direct_udp]\nport=0\n",
|
||||
"transport.direct_udp.port",
|
||||
),
|
||||
("[transport.quic]\nport=0\n", "transport.quic.port"),
|
||||
("[transport.http3]\nport=0\n", "transport.http3.port"),
|
||||
("[transport.tls_tcp]\nport=0\n", "transport.tls_tcp.port"),
|
||||
("[transport.http3]\npath='tunnel'\n", "transport.http3.path"),
|
||||
(
|
||||
"[transport.direct_udp]\nkeepalive_secs=86401\n",
|
||||
"keepalive_secs",
|
||||
),
|
||||
("[transport.quic]\nkeepalive_secs=86401\n", "keepalive_secs"),
|
||||
(
|
||||
"[transport.tls_tcp]\nkeepalive_secs=86401\n",
|
||||
"keepalive_secs",
|
||||
),
|
||||
("[network]\nmax_datagram_size=0\n", "max_datagram_size"),
|
||||
("[network]\nmax_datagram_size=65504\n", "max_datagram_size"),
|
||||
] {
|
||||
let error = validation_error(&client_text("vpn.example.com", extra));
|
||||
assert!(error.contains(expected), "expected {expected:?}: {error}");
|
||||
}
|
||||
|
||||
// A concrete mode selects exactly itself; auto.order remains validated
|
||||
// but does not cause fallback when mode is explicit.
|
||||
Config::from_str(&client_text(
|
||||
"vpn.example.com",
|
||||
"[transport]\nmode='quic'\n[transport.auto]\norder=['direct_udp']\n",
|
||||
))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap();
|
||||
Config::from_str(&client_text(
|
||||
"vpn.example.com",
|
||||
"[network]\nmax_datagram_size=65503\n",
|
||||
))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.expect("the direct packet payload ceiling is a valid limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_backend_enabled_transports_ports_paths_and_tls_are_validated() {
|
||||
for host in ["vpn.example.com", "127.0.0.1", "::1", "[::1]"] {
|
||||
Config::from_str(&server_text(host, ""))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap_or_else(|error| panic!("expected {host:?} to validate: {error}"));
|
||||
}
|
||||
for host in [
|
||||
"",
|
||||
"127.0.0.1:443",
|
||||
"[::1]:443",
|
||||
"[not-ip]",
|
||||
"bad..host",
|
||||
"bad host",
|
||||
] {
|
||||
let error = validation_error(&server_text(host, ""));
|
||||
assert!(error.contains("server.listen"), "{host:?}: {error}");
|
||||
}
|
||||
|
||||
for target in ["not-an-address", "127.0.0.1:0"] {
|
||||
let text = server_text("127.0.0.1", "")
|
||||
.replace("target='127.0.0.1:51820'", &format!("target='{target}'"));
|
||||
let error = validation_error(&text);
|
||||
assert!(error.contains("backend.target"), "{target:?}: {error}");
|
||||
}
|
||||
|
||||
for (extra, expected) in [
|
||||
(
|
||||
"[transports.direct_udp]\nenabled=false\n[transports.quic]\nenabled=false\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=false\n",
|
||||
"at least one server transport",
|
||||
),
|
||||
(
|
||||
"[transports.direct_udp]\nport=0\n",
|
||||
"transports.direct_udp.port",
|
||||
),
|
||||
("[transports.quic]\nport=0\n", "transports.quic.port"),
|
||||
("[transports.http3]\nport=0\n", "transports.http3.port"),
|
||||
("[transports.tls_tcp]\nport=0\n", "transports.tls_tcp.port"),
|
||||
(
|
||||
"[transports.http3]\npath='tunnel'\n",
|
||||
"transports.http3.path",
|
||||
),
|
||||
(
|
||||
"[transports.quic]\nenabled=true\n",
|
||||
"tls.certificate and tls.private_key are required",
|
||||
),
|
||||
(
|
||||
"[transports.http3]\nenabled=true\n",
|
||||
"tls.certificate and tls.private_key are required",
|
||||
),
|
||||
(
|
||||
"[transports.tls_tcp]\nenabled=true\n",
|
||||
"tls.certificate and tls.private_key are required",
|
||||
),
|
||||
(
|
||||
"[transports.direct_udp]\nport=51830\n[transports.quic]\nenabled=true\nport=51830\n",
|
||||
"UDP listeners direct_udp and quic",
|
||||
),
|
||||
(
|
||||
"[transports.quic]\nenabled=true\nport=443\n[transports.http3]\nenabled=true\nport=443\n",
|
||||
"UDP listeners quic and http3",
|
||||
),
|
||||
(
|
||||
"[transports.direct_udp]\nport=51830\n[transports.http3]\nenabled=true\nport=51830\n",
|
||||
"UDP listeners direct_udp and http3",
|
||||
),
|
||||
(
|
||||
"[limits]\nmax_sessions=10\nmax_sessions_per_ip=11\n",
|
||||
"max_sessions_per_ip must be <= limits.max_sessions",
|
||||
),
|
||||
("[limits]\nmax_sessions=0\n", "must be > 0"),
|
||||
(
|
||||
"[limits]\nmax_sessions_per_ip=1000001\n",
|
||||
"supported bounds",
|
||||
),
|
||||
("[limits]\nmax_pending_handshakes=100001\n", "supported bounds"),
|
||||
("[limits]\nmax_auth_attempts_per_ip=0\n", "must be > 0"),
|
||||
(
|
||||
"[limits]\nmax_auth_attempts_per_ip=100001\n",
|
||||
"supported bounds",
|
||||
),
|
||||
(
|
||||
"[limits]\nauth_attempt_window_secs=86401\n",
|
||||
"supported bounds",
|
||||
),
|
||||
(
|
||||
"[limits]\nhandshake_timeout_ms=300001\n",
|
||||
"supported bounds",
|
||||
),
|
||||
("[session]\nidle_timeout_secs=0\n", "idle_timeout_secs"),
|
||||
("[session]\nidle_timeout_secs=86401\n", "idle_timeout_secs"),
|
||||
(
|
||||
"[transports.quic]\nenabled=true\n[tls]\ncertificate='/tmp/cert.pem'\n",
|
||||
"must be provided together",
|
||||
),
|
||||
] {
|
||||
let error = validation_error(&server_text("127.0.0.1", extra));
|
||||
assert!(error.contains(expected), "expected {expected:?}: {error}");
|
||||
}
|
||||
|
||||
Config::from_str(&server_text(
|
||||
"127.0.0.1",
|
||||
"[transports.quic]\nenabled=true\nport=443\n[transports.tls_tcp]\nenabled=true\nport=443\n[tls]\ncertificate='/tmp/cert.pem'\nprivate_key='/tmp/key.pem'\n",
|
||||
))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.expect("TCP and UDP may share a numeric port");
|
||||
|
||||
Config::from_str(&server_text(
|
||||
"127.0.0.1",
|
||||
"[limits]\nmax_sessions=10\nmax_sessions_per_ip=10\n",
|
||||
))
|
||||
.unwrap()
|
||||
.validate()
|
||||
.expect("per-IP limit may equal total session limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shipped_examples_parse_and_satisfy_configuration_contract() {
|
||||
for example in [
|
||||
include_str!("../examples/client.minimal.toml"),
|
||||
include_str!("../examples/client.advanced.toml"),
|
||||
include_str!("../examples/server.minimal.toml"),
|
||||
include_str!("../examples/server.advanced.toml"),
|
||||
] {
|
||||
let normalized = with_test_secrets(example);
|
||||
Config::from_str(&normalized)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.unwrap_or_else(|error| panic!("example did not validate: {error}\n{example}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_file_must_be_private() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
|
||||
+334
-15
@@ -1,11 +1,17 @@
|
||||
//! Bounded newline-delimited JSON control socket for local administration.
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
|
||||
@@ -19,18 +25,27 @@ use tokio_util::sync::CancellationToken;
|
||||
pub struct ControlState {
|
||||
status: Arc<Mutex<String>>,
|
||||
stats: Arc<Mutex<String>>,
|
||||
transport_stats: Arc<Mutex<BTreeMap<String, TransportStats>>>,
|
||||
pub reconnect: Arc<Notify>,
|
||||
pub reload: Arc<Notify>,
|
||||
pub shutdown: CancellationToken,
|
||||
reconnect_supported: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TransportStats {
|
||||
active_sessions: usize,
|
||||
counters: StatsSnapshot,
|
||||
active_sessions_by_client_id: BTreeMap<String, usize>,
|
||||
}
|
||||
impl Default for ControlState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new("starting".into())),
|
||||
stats: Arc::new(Mutex::new("{}".into())),
|
||||
transport_stats: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
reconnect: Arc::new(Notify::new()),
|
||||
reload: Arc::new(Notify::new()),
|
||||
shutdown: CancellationToken::new(),
|
||||
reconnect_supported: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,16 +56,173 @@ impl ControlState {
|
||||
pub async fn set_stats_json(&self, value: impl Into<String>) {
|
||||
*self.stats.lock().await = value.into();
|
||||
}
|
||||
|
||||
/// Publish one listener's latest counters. The control-plane view retains
|
||||
/// every enabled transport and emits a deterministic aggregate snapshot.
|
||||
pub async fn set_transport_stats(
|
||||
&self,
|
||||
transport: impl Into<String>,
|
||||
active_sessions: usize,
|
||||
snapshot: StatsSnapshot,
|
||||
) {
|
||||
self.update_transport_stats(transport.into(), active_sessions, snapshot, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Publish listener counters together with its currently authenticated
|
||||
/// sessions by configured client identity.
|
||||
pub async fn set_transport_stats_by_client_id(
|
||||
&self,
|
||||
transport: impl Into<String>,
|
||||
active_sessions: usize,
|
||||
snapshot: StatsSnapshot,
|
||||
active_sessions_by_client_id: BTreeMap<String, usize>,
|
||||
) {
|
||||
self.update_transport_stats(
|
||||
transport.into(),
|
||||
active_sessions,
|
||||
snapshot,
|
||||
Some(active_sessions_by_client_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn update_transport_stats(
|
||||
&self,
|
||||
transport: String,
|
||||
active_sessions: usize,
|
||||
snapshot: StatsSnapshot,
|
||||
client_sessions: Option<BTreeMap<String, usize>>,
|
||||
) {
|
||||
let mut transports = self.transport_stats.lock().await;
|
||||
let entry = transports.entry(transport).or_default();
|
||||
entry.active_sessions = active_sessions;
|
||||
entry.counters = snapshot;
|
||||
if let Some(client_sessions) = client_sessions {
|
||||
entry.active_sessions_by_client_id = client_sessions;
|
||||
}
|
||||
let mut aggregate = StatsSnapshot::default();
|
||||
let mut active_total = 0usize;
|
||||
let mut by_transport = Vec::with_capacity(transports.len());
|
||||
let mut per_transport = Vec::with_capacity(transports.len());
|
||||
let mut by_client_id = BTreeMap::<String, usize>::new();
|
||||
let mut by_transport_and_client = Vec::with_capacity(transports.len());
|
||||
for (name, stats) in transports.iter() {
|
||||
active_total = active_total.saturating_add(stats.active_sessions);
|
||||
aggregate = aggregate.saturating_add(stats.counters);
|
||||
by_transport.push(format!("{}:{}", json_string(name), stats.active_sessions));
|
||||
per_transport.push(format!(
|
||||
"{}:{{{}}}",
|
||||
json_string(name),
|
||||
snapshot_fields(stats.counters)
|
||||
));
|
||||
let client_entries = stats
|
||||
.active_sessions_by_client_id
|
||||
.iter()
|
||||
.map(|(client_id, count)| {
|
||||
let total = by_client_id.entry(client_id.clone()).or_default();
|
||||
*total = total.saturating_add(*count);
|
||||
format!("{}:{count}", json_string(client_id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
by_transport_and_client.push(format!(
|
||||
"{}:{{{}}}",
|
||||
json_string(name),
|
||||
client_entries.join(",")
|
||||
));
|
||||
}
|
||||
let by_client_id = by_client_id
|
||||
.iter()
|
||||
.map(|(client_id, count)| format!("{}:{count}", json_string(client_id)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let label = if transports.len() == 1 {
|
||||
transports
|
||||
.keys()
|
||||
.next()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("unknown")
|
||||
} else {
|
||||
"multi"
|
||||
};
|
||||
let json = format!(
|
||||
"{{\"transport\":\"{label}\",\"active_sessions\":{active_total},\"active_sessions_by_transport\":{{{}}},\"active_sessions_by_client_id\":{{{by_client_id}}},\"active_sessions_by_transport_and_client_id\":{{{}}},\"transports\":{{{}}},{}}}",
|
||||
by_transport.join(","),
|
||||
by_transport_and_client.join(","),
|
||||
per_transport.join(","),
|
||||
snapshot_fields(aggregate),
|
||||
);
|
||||
*self.stats.lock().await = json;
|
||||
}
|
||||
|
||||
async fn status_with_activity(&self) -> String {
|
||||
let status = self.status.lock().await.clone();
|
||||
if !status.starts_with("listening;") {
|
||||
return status;
|
||||
}
|
||||
let transports = self.transport_stats.lock().await;
|
||||
if transports.is_empty() {
|
||||
return status;
|
||||
}
|
||||
let active_total = transports
|
||||
.values()
|
||||
.map(|stats| stats.active_sessions)
|
||||
.sum::<usize>();
|
||||
let by_transport = transports
|
||||
.iter()
|
||||
.map(|(name, stats)| format!("{name}:{}", stats.active_sessions))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
"{status}; active_sessions={active_total}; active_sessions_by_transport={by_transport}"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn enable_reconnect(&self) {
|
||||
self.reconnect_supported.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
format!(
|
||||
"\"{}\"",
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot_fields(snapshot: StatsSnapshot) -> String {
|
||||
format!(
|
||||
"\"bytes_sent\":{},\"bytes_received\":{},\"datagrams_sent\":{},\"datagrams_received\":{},\"datagrams_dropped\":{},\"oversized_datagrams\":{},\"authentication_failures\":{},\"reconnections\":{},\"expired_sessions\":{},\"handshake_attempts\":{},\"authentication_rejections\":{},\"rate_limited_attempts\":{},\"backend_errors\":{}",
|
||||
snapshot.bytes_sent,
|
||||
snapshot.bytes_received,
|
||||
snapshot.datagrams_sent,
|
||||
snapshot.datagrams_received,
|
||||
snapshot.datagrams_dropped,
|
||||
snapshot.oversized_datagrams,
|
||||
snapshot.authentication_failures,
|
||||
snapshot.reconnections,
|
||||
snapshot.expired_sessions,
|
||||
snapshot.handshake_attempts,
|
||||
snapshot.authentication_rejections,
|
||||
snapshot.rate_limited_attempts,
|
||||
snapshot.backend_errors,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct ControlServer {
|
||||
listener: UnixListener,
|
||||
path: PathBuf,
|
||||
socket_identity: (u64, u64),
|
||||
state: ControlState,
|
||||
}
|
||||
impl ControlServer {
|
||||
pub async fn bind(path: impl Into<PathBuf>, state: ControlState) -> io::Result<Self> {
|
||||
let path = path.into();
|
||||
prepare_socket_parent(&path)?;
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&path) {
|
||||
if !metadata.file_type().is_socket() {
|
||||
return Err(io::Error::new(
|
||||
@@ -58,17 +230,57 @@ impl ControlServer {
|
||||
"control path is not a socket",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
match tokio::time::timeout(Duration::from_millis(250), UnixStream::connect(&path)).await
|
||||
{
|
||||
Ok(Ok(_)) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
"control socket is already active",
|
||||
));
|
||||
}
|
||||
Ok(Err(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound
|
||||
) =>
|
||||
{
|
||||
let current = std::fs::symlink_metadata(&path)?;
|
||||
if !current.file_type().is_socket()
|
||||
|| current.dev() != metadata.dev()
|
||||
|| current.ino() != metadata.ino()
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AddrInUse,
|
||||
"control path changed while checking for a stale socket",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(Err(error)) => return Err(error),
|
||||
Err(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"timed out checking whether the control socket is active",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
if let Err(error) =
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
{
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let metadata = std::fs::symlink_metadata(&path)?;
|
||||
Ok(Self {
|
||||
listener,
|
||||
path,
|
||||
socket_identity: (metadata.dev(), metadata.ino()),
|
||||
state,
|
||||
})
|
||||
}
|
||||
@@ -77,16 +289,84 @@ impl ControlServer {
|
||||
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; }); } }
|
||||
}
|
||||
// A client may connect and then never finish writing a command. Do not
|
||||
// let that local connection pin daemon shutdown indefinitely.
|
||||
tasks.abort_all();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for ControlServer {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&self.path) {
|
||||
if metadata.file_type().is_socket()
|
||||
&& (metadata.dev(), metadata.ino()) == self.socket_identity
|
||||
{
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_socket_parent(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let existed = match std::fs::symlink_metadata(parent) {
|
||||
Ok(_) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
false
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let metadata = std::fs::symlink_metadata(parent)?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"control socket parent must be a real directory",
|
||||
));
|
||||
}
|
||||
if metadata.uid() != current_uid()? {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"control socket parent must be owned by the current user",
|
||||
));
|
||||
}
|
||||
if existed && metadata.mode() & 0o777 != 0o700 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"control socket parent must have mode 0700",
|
||||
));
|
||||
}
|
||||
if !existed {
|
||||
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
let verified = std::fs::symlink_metadata(parent)?;
|
||||
if !verified.is_dir() || verified.uid() != current_uid()? || verified.mode() & 0o777 != 0o700 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"control socket parent could not be made private",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn current_uid() -> io::Result<u32> {
|
||||
std::fs::metadata("/proc/self")
|
||||
.map(|metadata| metadata.uid())
|
||||
.or_else(|_| {
|
||||
std::env::var("UID")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "cannot determine uid"))
|
||||
})
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -97,17 +377,23 @@ async fn handle_connection(stream: UnixStream, state: ControlState) -> io::Resul
|
||||
} else {
|
||||
match command_name(&line) {
|
||||
Some("status") => {
|
||||
let value = state.status.lock().await.clone();
|
||||
let value = state.status_with_activity().await;
|
||||
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()
|
||||
if state.reconnect_supported.load(Ordering::Acquire) {
|
||||
state.reconnect.notify_one();
|
||||
"{\"ok\":true,\"command\":\"reconnect\"}\n".into()
|
||||
} else {
|
||||
"{\"ok\":false,\"error\":\"reconnect is not supported by this daemon\"}\n"
|
||||
.into()
|
||||
}
|
||||
}
|
||||
// Reload is deliberately rejected until there is a file-backed
|
||||
// runtime configuration source and an atomic apply path.
|
||||
Some("reload") => {
|
||||
state.reload.notify_one();
|
||||
"{\"ok\":true,\"command\":\"reload\"}\n".into()
|
||||
"{\"ok\":false,\"error\":\"configuration reload is not supported\"}\n".into()
|
||||
}
|
||||
Some("shutdown") => {
|
||||
state.shutdown.cancel();
|
||||
@@ -154,10 +440,43 @@ fn json_escape(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
let uid = current_uid().unwrap_or(0);
|
||||
if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("wgcloak.sock")
|
||||
{
|
||||
if is_private_runtime_dir(&runtime_dir, uid) {
|
||||
return runtime_dir.join("wgcloak.sock");
|
||||
}
|
||||
}
|
||||
let systemd_runtime = PathBuf::from(format!("/run/user/{uid}"));
|
||||
if is_private_runtime_dir(&systemd_runtime, uid) {
|
||||
return systemd_runtime.join("wgcloak/wgcloak.sock");
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME").filter(|path| !path.is_empty()) {
|
||||
let home = PathBuf::from(home);
|
||||
let in_shared_tmp = std::fs::canonicalize(&home)
|
||||
.ok()
|
||||
.zip(std::fs::canonicalize(std::env::temp_dir()).ok())
|
||||
.map(|(home, temporary)| home.starts_with(temporary))
|
||||
.unwrap_or(false);
|
||||
if !in_shared_tmp {
|
||||
return home.join(".local/run/wgcloak/wgcloak.sock");
|
||||
}
|
||||
}
|
||||
systemd_runtime.join("wgcloak/wgcloak.sock")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_private_runtime_dir(path: &Path, uid: u32) -> bool {
|
||||
std::fs::symlink_metadata(path)
|
||||
.map(|metadata| {
|
||||
metadata.is_dir()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& metadata.uid() == uid
|
||||
&& metadata.mode() & 0o777 == 0o700
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub async fn send_command(path: impl AsRef<Path>, command: &str) -> io::Result<String> {
|
||||
let mut stream = UnixStream::connect(path).await?;
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
//! Reusable library components for the `wgcloak` binary.
|
||||
|
||||
pub mod cli;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod datagram;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
pub mod transport;
|
||||
+8
-14
@@ -1,19 +1,11 @@
|
||||
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;
|
||||
use wgcloak::{
|
||||
cli::{Cli, Command},
|
||||
client,
|
||||
config::{self, Config},
|
||||
control, server,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -22,12 +14,14 @@ async fn main() -> Result<()> {
|
||||
match cli.command {
|
||||
Command::Client { config } => {
|
||||
let parsed = Config::from_file(&config)?;
|
||||
parsed.validate().map_err(anyhow::Error::msg)?;
|
||||
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)?;
|
||||
parsed.validate().map_err(anyhow::Error::msg)?;
|
||||
let server = parsed.into_server().map_err(anyhow::Error::msg)?;
|
||||
config::init_tracing(&server.logging.level)?;
|
||||
server::run(server).await
|
||||
|
||||
@@ -186,6 +186,10 @@ pub fn data_auth_tag(
|
||||
tag
|
||||
}
|
||||
|
||||
// Each argument is an explicit field in the authenticated wire input. Keep
|
||||
// this helper aligned with `data_auth_tag` so callers cannot accidentally
|
||||
// verify a different field set.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn verify_data_auth_tag(
|
||||
session_key: &[u8],
|
||||
version: u8,
|
||||
|
||||
+78
-4
@@ -81,6 +81,8 @@ pub enum ProtocolError {
|
||||
Truncated { needed: usize, actual: usize },
|
||||
#[error("packet exceeds maximum size of {maximum} bytes")]
|
||||
Oversized { maximum: usize },
|
||||
#[error("DATA payload is {actual} bytes, maximum is {maximum}")]
|
||||
OversizedDataPayload { actual: usize, maximum: usize },
|
||||
#[error("unsupported protocol version {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("unknown packet type 0x{0:02x}")]
|
||||
@@ -635,9 +637,27 @@ fn array<const N: usize>(input: &[u8]) -> [u8; N] {
|
||||
output
|
||||
}
|
||||
|
||||
/// Parse one complete direct UDP datagram. All structural checks occur before
|
||||
/// the only potentially large allocation (copying a valid DATA payload).
|
||||
/// Parse one complete direct UDP datagram without imposing a transport payload
|
||||
/// limit. All structural checks occur before copying a valid DATA payload.
|
||||
pub fn parse_packet(input: &[u8]) -> Result<Packet, ProtocolError> {
|
||||
parse_packet_inner(input, None)
|
||||
}
|
||||
|
||||
/// Parse one packet while bounding DATA payload size before copying it into a
|
||||
/// `Bytes` value. Public listeners should use this with their negotiated or
|
||||
/// configured maximum so unauthenticated oversized packets do not allocate a
|
||||
/// second large buffer.
|
||||
pub fn parse_packet_bounded(
|
||||
input: &[u8],
|
||||
maximum_data_payload: usize,
|
||||
) -> Result<Packet, ProtocolError> {
|
||||
parse_packet_inner(input, Some(maximum_data_payload))
|
||||
}
|
||||
|
||||
fn parse_packet_inner(
|
||||
input: &[u8],
|
||||
maximum_data_payload: Option<usize>,
|
||||
) -> Result<Packet, ProtocolError> {
|
||||
if input.len() > MAX_PACKET_SIZE {
|
||||
return Err(ProtocolError::Oversized {
|
||||
maximum: MAX_PACKET_SIZE,
|
||||
@@ -778,8 +798,15 @@ pub fn parse_packet(input: &[u8]) -> Result<Packet, ProtocolError> {
|
||||
}
|
||||
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_len = input.len() - at;
|
||||
if let Some(maximum) = maximum_data_payload {
|
||||
if payload_len > maximum {
|
||||
return Err(ProtocolError::OversizedDataPayload {
|
||||
actual: payload_len,
|
||||
maximum,
|
||||
});
|
||||
}
|
||||
}
|
||||
let payload = Bytes::copy_from_slice(&input[at..]);
|
||||
Ok(Packet::Data(DataPacket {
|
||||
version,
|
||||
@@ -919,6 +946,53 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_parser_rejects_large_data_before_payload_copy() {
|
||||
let key = auth::derive_session_key(b"secret", &[1; 16], &[2; 16], 7);
|
||||
let payload_len = MAX_PACKET_SIZE - DATA_HEADER_LEN;
|
||||
let packet = DataPacket::authenticated(&key, 7, 1, vec![0u8; payload_len]);
|
||||
let encoded = packet.encode().unwrap();
|
||||
assert_eq!(
|
||||
parse_packet_bounded(&encoded, 1200),
|
||||
Err(ProtocolError::OversizedDataPayload {
|
||||
actual: payload_len,
|
||||
maximum: 1200,
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_packet_bounded(&encoded, payload_len),
|
||||
Ok(Packet::Data(data)) if data.payload.len() == payload_len
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_flags_are_part_of_data_family_authentication() {
|
||||
let key = auth::derive_session_key(b"secret", &[1; 16], &[2; 16], 7);
|
||||
let data = DataPacket::authenticated(&key, 7, 1, Bytes::from_static(b"data"));
|
||||
let mut encoded = data.encode().unwrap();
|
||||
encoded[2] = 1;
|
||||
let Packet::Data(parsed) = parse_packet(&encoded).unwrap() else {
|
||||
panic!("expected DATA packet");
|
||||
};
|
||||
assert!(!parsed.verify_authenticator(&key));
|
||||
|
||||
let keepalive = SessionPacket::authenticated(PacketType::Keepalive, &key, 7, 2);
|
||||
let mut encoded = keepalive.encode().unwrap();
|
||||
encoded[2] = 1;
|
||||
let Packet::Keepalive(parsed) = parse_packet(&encoded).unwrap() else {
|
||||
panic!("expected KEEPALIVE packet");
|
||||
};
|
||||
assert!(!parsed.verify_authenticator(&key));
|
||||
|
||||
let close = SessionPacket::authenticated(PacketType::Close, &key, 7, 3);
|
||||
let mut encoded = close.encode().unwrap();
|
||||
encoded[2] = 1;
|
||||
let Packet::Close(parsed) = parse_packet(&encoded).unwrap() else {
|
||||
panic!("expected CLOSE packet");
|
||||
};
|
||||
assert!(!parsed.verify_authenticator(&key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_header_length_bound_is_enforced() {
|
||||
assert!(HelloPacket::new(vec![0; MAX_CLIENT_ID_LEN], 0, [0; 16], [0; 32]).is_ok());
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Server-facing names for the shared transport admission permits.
|
||||
|
||||
pub use crate::transport::{AdmissionController, AdmissionError, PendingHandshake, SessionPermit};
|
||||
+556
-47
@@ -1,93 +1,602 @@
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::net::UdpSocket;
|
||||
use bytes::Bytes;
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
task::JoinSet,
|
||||
time::{self, timeout},
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
config::ServerConfig,
|
||||
control::{ControlServer, ControlState},
|
||||
stats::Stats,
|
||||
transport::{
|
||||
quic::QuicServer, DirectUdpServer, TlsTcpServer, TransportError, TransportSession,
|
||||
quic::QuicServer, AdmissionController, DirectUdpServer, Http3Server, 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 mod limits;
|
||||
|
||||
/// Run one configured server listener. Each accepted authenticated session is
|
||||
/// owned by a JoinSet so listener shutdown can cancel and reap it deterministically.
|
||||
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")
|
||||
run_with_control_path(config, crate::control::default_socket_path()).await
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
/// Runtime entrypoint with an explicit local control path. Keeping the path
|
||||
/// injectable also allows lifecycle integration tests to use an isolated
|
||||
/// temporary socket rather than the user's default daemon socket.
|
||||
pub async fn run_with_control_path(
|
||||
config: ServerConfig,
|
||||
control_path: impl Into<std::path::PathBuf>,
|
||||
) -> Result<()> {
|
||||
config.validate()?;
|
||||
let state = ControlState::default();
|
||||
let control = ControlServer::bind(control_path, state.clone()).await?;
|
||||
let mut control_task = tokio::spawn(control.run());
|
||||
|
||||
let result = run_listener(config, state.clone()).await;
|
||||
state.set_status("stopping").await;
|
||||
state.shutdown.cancel();
|
||||
if timeout(Duration::from_secs(2), &mut control_task)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
control_task.abort();
|
||||
}
|
||||
state.set_status("stopped").await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_listener(config: ServerConfig, state: ControlState) -> Result<()> {
|
||||
validate_udp_listener_conflicts(&config)?;
|
||||
let backend = config.backend.target.parse::<SocketAddr>()?;
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut transports = Vec::new();
|
||||
let direct_active = Arc::new(AtomicUsize::new(0));
|
||||
let quic_active = Arc::new(AtomicUsize::new(0));
|
||||
let http3_active = Arc::new(AtomicUsize::new(0));
|
||||
let tls_active = Arc::new(AtomicUsize::new(0));
|
||||
let admission = AdmissionController::new(&config.limits);
|
||||
|
||||
if config.transports.direct_udp.enabled {
|
||||
let listener = DirectUdpServer::bind(config.clone()).await?;
|
||||
transports.push("direct_udp");
|
||||
let state = state.clone();
|
||||
let active = direct_active.clone();
|
||||
tasks.spawn(async move { run_direct(listener, state, active).await });
|
||||
}
|
||||
if config.transports.quic.enabled {
|
||||
let listener = Arc::new(QuicServer::bind_with_admission(&config, admission.clone()).await?);
|
||||
transports.push("quic");
|
||||
let state = state.clone();
|
||||
let active = quic_active.clone();
|
||||
tasks.spawn(async move { run_quic(listener, backend, state, active).await });
|
||||
}
|
||||
if config.transports.http3.enabled {
|
||||
let listener =
|
||||
Arc::new(Http3Server::bind_with_admission(&config, admission.clone()).await?);
|
||||
transports.push("http3");
|
||||
let state = state.clone();
|
||||
let active = http3_active.clone();
|
||||
tasks.spawn(async move { run_http3(listener, backend, state, active).await });
|
||||
}
|
||||
if config.transports.tls_tcp.enabled {
|
||||
let listener =
|
||||
Arc::new(TlsTcpServer::bind_with_admission(&config, admission.clone()).await?);
|
||||
transports.push("tls_tcp");
|
||||
let state = state.clone();
|
||||
let active = tls_active.clone();
|
||||
tasks.spawn(async move { run_tls(listener, backend, state, active).await });
|
||||
}
|
||||
if tasks.is_empty() {
|
||||
anyhow::bail!("no server transport enabled")
|
||||
}
|
||||
|
||||
let transport_names = transports.join(",");
|
||||
state
|
||||
.set_status(format!("listening; transports={transport_names}"))
|
||||
.await;
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
let first_result = tokio::select! {
|
||||
_ = state.shutdown.cancelled() => Ok(()),
|
||||
_ = &mut signal => Ok(()),
|
||||
result = tasks.join_next() => match result {
|
||||
Some(Ok(result)) => result,
|
||||
Some(Err(error)) => Err(anyhow::anyhow!("transport listener task failed: {error}")),
|
||||
None => Ok(()),
|
||||
},
|
||||
};
|
||||
// Each carrier observes this shared token and performs its own bounded
|
||||
// session drain before the final abort fallback below.
|
||||
state.shutdown.cancel();
|
||||
let drain = async {
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => warn!(%error, "transport listener stopped with an error"),
|
||||
Err(error) => warn!(%error, "transport listener task did not join cleanly"),
|
||||
}
|
||||
}
|
||||
};
|
||||
if timeout(Duration::from_secs(4), drain).await.is_err() {
|
||||
tasks.abort_all();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
}
|
||||
first_result
|
||||
}
|
||||
|
||||
fn validate_udp_listener_conflicts(config: &ServerConfig) -> Result<()> {
|
||||
let udp = [
|
||||
(
|
||||
"direct_udp",
|
||||
config.transports.direct_udp.enabled,
|
||||
config.transports.direct_udp.port,
|
||||
),
|
||||
(
|
||||
"quic",
|
||||
config.transports.quic.enabled,
|
||||
config.transports.quic.port,
|
||||
),
|
||||
(
|
||||
"http3",
|
||||
config.transports.http3.enabled,
|
||||
config.transports.http3.port,
|
||||
),
|
||||
];
|
||||
for (index, (left_name, left_enabled, left_port)) in udp.iter().enumerate() {
|
||||
if !*left_enabled {
|
||||
continue;
|
||||
}
|
||||
for (right_name, right_enabled, right_port) in udp.iter().skip(index + 1) {
|
||||
if *right_enabled && left_port == right_port {
|
||||
anyhow::bail!(
|
||||
"UDP listeners {left_name} and {right_name} both bind {}:{left_port}; configure distinct UDP ports",
|
||||
config.server.listen
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_quic(config: ServerConfig, state: ControlState) -> Result<()> {
|
||||
let listener = Arc::new(QuicServer::bind(&config).await?);
|
||||
let backend = config.backend.target.parse()?;
|
||||
async fn run_direct(
|
||||
listener: DirectUdpServer,
|
||||
state: ControlState,
|
||||
active_count: Arc<AtomicUsize>,
|
||||
) -> Result<()> {
|
||||
let observer = listener.handle();
|
||||
let stats = observer.stats();
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
let run = listener.run_with_shutdown(state.shutdown.clone());
|
||||
tokio::pin!(run);
|
||||
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"),
|
||||
result = &mut run => {
|
||||
let active = observer.active_sessions().await;
|
||||
active_count.store(active, Ordering::Relaxed);
|
||||
publish_server_stats_with_client_ids(
|
||||
&state,
|
||||
&stats,
|
||||
"direct_udp",
|
||||
active,
|
||||
observer.active_sessions_by_client_id().await,
|
||||
)
|
||||
.await;
|
||||
return result;
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
let active = observer.active_sessions().await;
|
||||
active_count.store(active, Ordering::Relaxed);
|
||||
publish_server_stats_with_client_ids(
|
||||
&state,
|
||||
&stats,
|
||||
"direct_udp",
|
||||
active,
|
||||
observer.active_sessions_by_client_id().await,
|
||||
).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tls(config: ServerConfig, state: ControlState) -> Result<()> {
|
||||
let listener = Arc::new(TlsTcpServer::bind(&config).await?);
|
||||
let backend = config.backend.target.parse()?;
|
||||
async fn run_http3(
|
||||
listener: Arc<Http3Server>,
|
||||
backend: SocketAddr,
|
||||
state: ControlState,
|
||||
active: Arc<AtomicUsize>,
|
||||
) -> Result<()> {
|
||||
let stats = Arc::new(Stats::default());
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut handshakes: JoinSet<
|
||||
Result<crate::transport::http3::Http3TransportSession, TransportError>,
|
||||
> = JoinSet::new();
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
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"),
|
||||
_ = state.shutdown.cancelled() => {
|
||||
listener.close();
|
||||
break;
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
publish_server_stats(&state, &stats, "http3", Some(active.load(Ordering::Relaxed))).await;
|
||||
}
|
||||
Some(result) = tasks.join_next(), if !tasks.is_empty() => {
|
||||
if let Err(error) = result { warn!(%error, "HTTP/3 session task failed"); }
|
||||
}
|
||||
Some(result) = handshakes.join_next(), if !handshakes.is_empty() => {
|
||||
match result {
|
||||
Ok(Ok(session)) => {
|
||||
let client_session = session
|
||||
.client_id()
|
||||
.map(|client_id| stats.track_client_session(client_id.to_owned()));
|
||||
let state = state.clone();
|
||||
let stats = stats.clone();
|
||||
let active = active.clone();
|
||||
active.fetch_add(1, Ordering::Relaxed);
|
||||
tasks.spawn(async move {
|
||||
let _guard = ActiveSessionGuard(active);
|
||||
let _client_session = client_session;
|
||||
if let Err(error) = relay_session(Box::new(session), backend, state, stats.clone()).await {
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(%error, "HTTP/3 session relay ended with an error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Err(TransportError::AuthenticationRejected(_))) => {
|
||||
stats.authentication_rejections.fetch_add(1, Ordering::Relaxed);
|
||||
stats.authentication_failures.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("HTTP/3 client authentication rejected");
|
||||
}
|
||||
Ok(Err(error)) => log_admission_failure("HTTP/3", error),
|
||||
Err(error) => warn!(%error, "HTTP/3 accept task failed"),
|
||||
}
|
||||
}
|
||||
pending = listener.accept_pending() => {
|
||||
stats.handshake_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
match pending {
|
||||
Ok(pending) => {
|
||||
let listener = listener.clone();
|
||||
handshakes.spawn(async move { listener.authenticate_pending(pending).await });
|
||||
}
|
||||
Err(error) => log_admission_failure("HTTP/3", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handshakes.abort_all();
|
||||
while handshakes.join_next().await.is_some() {}
|
||||
drain_sessions(&mut tasks).await;
|
||||
publish_server_stats(
|
||||
&state,
|
||||
&stats,
|
||||
"http3",
|
||||
Some(active.load(Ordering::Relaxed)),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_quic(
|
||||
listener: Arc<QuicServer>,
|
||||
backend: SocketAddr,
|
||||
state: ControlState,
|
||||
active: Arc<AtomicUsize>,
|
||||
) -> Result<()> {
|
||||
let stats = Arc::new(Stats::default());
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut handshakes: JoinSet<
|
||||
Result<crate::transport::quic::QuicDatagramSession, TransportError>,
|
||||
> = JoinSet::new();
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => {
|
||||
listener.close();
|
||||
break;
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
publish_server_stats(&state, &stats, "quic", Some(active.load(Ordering::Relaxed))).await
|
||||
},
|
||||
Some(result) = tasks.join_next(), if !tasks.is_empty() => {
|
||||
if let Err(error) = result { warn!(%error, "QUIC session task failed"); }
|
||||
}
|
||||
Some(result) = handshakes.join_next(), if !handshakes.is_empty() => {
|
||||
match result {
|
||||
Ok(Ok(session)) => {
|
||||
let client_session = session
|
||||
.client_id()
|
||||
.map(|client_id| stats.track_client_session(client_id.to_owned()));
|
||||
let state = state.clone();
|
||||
let stats = stats.clone();
|
||||
let active = active.clone();
|
||||
let _ = active.fetch_add(1, Ordering::Relaxed);
|
||||
tasks.spawn(async move {
|
||||
let _guard = ActiveSessionGuard(active);
|
||||
let _client_session = client_session;
|
||||
if let Err(error) = relay_session(Box::new(session), backend, state, stats.clone()).await {
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(%error, "QUIC session relay ended with an error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Err(TransportError::AuthenticationRejected(_))) => {
|
||||
stats.authentication_rejections.fetch_add(1, Ordering::Relaxed);
|
||||
stats.authentication_failures.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("QUIC client authentication rejected");
|
||||
}
|
||||
Ok(Err(error)) => log_admission_failure("QUIC", error),
|
||||
Err(error) => warn!(%error, "QUIC accept task failed"),
|
||||
}
|
||||
}
|
||||
pending = listener.accept_pending() => {
|
||||
stats.handshake_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
match pending {
|
||||
Ok(pending) => {
|
||||
let listener = listener.clone();
|
||||
handshakes.spawn(async move { listener.authenticate_pending(pending).await });
|
||||
}
|
||||
Err(error) => log_admission_failure("QUIC", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handshakes.abort_all();
|
||||
while handshakes.join_next().await.is_some() {}
|
||||
drain_sessions(&mut tasks).await;
|
||||
publish_server_stats(&state, &stats, "quic", Some(active.load(Ordering::Relaxed))).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_tls(
|
||||
listener: Arc<TlsTcpServer>,
|
||||
backend: SocketAddr,
|
||||
state: ControlState,
|
||||
active: Arc<AtomicUsize>,
|
||||
) -> Result<()> {
|
||||
let stats = Arc::new(Stats::default());
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut handshakes: JoinSet<Result<Box<dyn TransportSession>, TransportError>> = JoinSet::new();
|
||||
let mut refresh = time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => break,
|
||||
_ = refresh.tick() => {
|
||||
publish_server_stats(&state, &stats, "tls_tcp", Some(active.load(Ordering::Relaxed))).await
|
||||
},
|
||||
Some(result) = tasks.join_next(), if !tasks.is_empty() => {
|
||||
if let Err(error) = result { warn!(%error, "TLS/TCP session task failed"); }
|
||||
}
|
||||
Some(result) = handshakes.join_next(), if !handshakes.is_empty() => {
|
||||
match result {
|
||||
Ok(Ok(session)) => {
|
||||
let client_session = session
|
||||
.client_id()
|
||||
.map(|client_id| stats.track_client_session(client_id.to_owned()));
|
||||
let state = state.clone();
|
||||
let stats = stats.clone();
|
||||
let active = active.clone();
|
||||
let _ = active.fetch_add(1, Ordering::Relaxed);
|
||||
tasks.spawn(async move {
|
||||
let _guard = ActiveSessionGuard(active);
|
||||
let _client_session = client_session;
|
||||
if let Err(error) = relay_session(session, backend, state, stats.clone()).await {
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(%error, "TLS/TCP session relay ended with an error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Err(TransportError::AuthenticationRejected(_))) => {
|
||||
stats.authentication_rejections.fetch_add(1, Ordering::Relaxed);
|
||||
stats.authentication_failures.fetch_add(1, Ordering::Relaxed);
|
||||
warn!("TLS/TCP client authentication rejected");
|
||||
}
|
||||
Ok(Err(error)) => log_admission_failure("TLS/TCP", error),
|
||||
Err(error) => warn!(%error, "TLS/TCP accept task failed"),
|
||||
}
|
||||
}
|
||||
pending = listener.accept_pending() => {
|
||||
stats.handshake_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
match pending {
|
||||
Ok(pending) => {
|
||||
let listener = listener.clone();
|
||||
handshakes.spawn(async move { listener.authenticate_pending(pending).await });
|
||||
}
|
||||
Err(error) => log_admission_failure("TLS/TCP", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
handshakes.abort_all();
|
||||
while handshakes.join_next().await.is_some() {}
|
||||
drain_sessions(&mut tasks).await;
|
||||
publish_server_stats(
|
||||
&state,
|
||||
&stats,
|
||||
"tls_tcp",
|
||||
Some(active.load(Ordering::Relaxed)),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ActiveSessionGuard(Arc<AtomicUsize>);
|
||||
impl Drop for ActiveSessionGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn log_admission_failure(transport: &str, error: TransportError) {
|
||||
match error {
|
||||
TransportError::Busy => warn!(transport, "encrypted transport admission capacity reached"),
|
||||
TransportError::RateLimited => {
|
||||
warn!(
|
||||
transport,
|
||||
"encrypted transport source is temporarily rate limited"
|
||||
)
|
||||
}
|
||||
TransportError::Closed => {}
|
||||
error => warn!(transport, %error, "encrypted transport session failed"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_sessions(tasks: &mut JoinSet<()>) {
|
||||
let drain = async { while tasks.join_next().await.is_some() {} };
|
||||
if timeout(Duration::from_secs(2), drain).await.is_err() {
|
||||
tasks.abort_all();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn relay_session(
|
||||
session: Box<dyn TransportSession>,
|
||||
backend_target: std::net::SocketAddr,
|
||||
backend_target: SocketAddr,
|
||||
state: ControlState,
|
||||
stats: Arc<Stats>,
|
||||
) -> Result<()> {
|
||||
let backend = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
backend.connect(backend_target).await?;
|
||||
let backend = Arc::new(backend);
|
||||
let result = relay_session_inner(session.as_ref(), backend_target, &state, &stats).await;
|
||||
// TLS/TCP has a bounded close handshake; cap all carriers uniformly so
|
||||
// session cleanup cannot hold the listener's shutdown path indefinitely.
|
||||
let _ = timeout(Duration::from_secs(2), session.close()).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn relay_session_inner(
|
||||
session: &dyn TransportSession,
|
||||
backend_target: SocketAddr,
|
||||
state: &ControlState,
|
||||
stats: &Stats,
|
||||
) -> Result<()> {
|
||||
let backend = match UdpSocket::bind("0.0.0.0:0").await {
|
||||
Ok(socket) => socket,
|
||||
Err(error) => {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if let Err(error) = backend.connect(backend_target).await {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error.into());
|
||||
}
|
||||
let mut buf = vec![0u8; session.max_datagram_size().saturating_add(1)];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => { let _ = session.close().await; return Ok(()); }
|
||||
_ = state.shutdown.cancelled() => return Ok(()),
|
||||
received = session.recv_datagram() => {
|
||||
let payload = received?;
|
||||
if payload.len() <= session.max_datagram_size() { backend.send(&payload).await?; }
|
||||
if payload.len() > session.max_datagram_size() {
|
||||
stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
match timeout(Duration::from_secs(2), backend.send(&payload)).await {
|
||||
Ok(Ok(_)) => stats.add_received(payload.len()),
|
||||
Ok(Err(error)) => {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error.into());
|
||||
}
|
||||
Err(_) => {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(anyhow::anyhow!("backend UDP send timed out"));
|
||||
}
|
||||
}
|
||||
}
|
||||
received = backend.recv(&mut buf) => {
|
||||
let len = received?;
|
||||
session.send_datagram(bytes::Bytes::copy_from_slice(&buf[..len])).await?;
|
||||
let len = match received {
|
||||
Ok(len) => len,
|
||||
Err(error) => {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
if len > session.max_datagram_size() {
|
||||
stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
match timeout(
|
||||
Duration::from_secs(2),
|
||||
session.send_datagram(Bytes::copy_from_slice(&buf[..len])),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(error.into());
|
||||
}
|
||||
Err(_) => {
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Err(anyhow::anyhow!("transport datagram send timed out"));
|
||||
}
|
||||
}
|
||||
stats.add_sent(len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_server_stats(
|
||||
state: &ControlState,
|
||||
stats: &Stats,
|
||||
transport: &str,
|
||||
active_sessions: Option<usize>,
|
||||
) {
|
||||
publish_server_stats_with_client_ids(
|
||||
state,
|
||||
stats,
|
||||
transport,
|
||||
active_sessions.unwrap_or(0),
|
||||
stats.active_sessions_by_client_id(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn publish_server_stats_with_client_ids(
|
||||
state: &ControlState,
|
||||
stats: &Stats,
|
||||
transport: &str,
|
||||
active_sessions: usize,
|
||||
active_sessions_by_client_id: std::collections::BTreeMap<String, usize>,
|
||||
) {
|
||||
state
|
||||
.set_transport_stats_by_client_id(
|
||||
transport,
|
||||
active_sessions,
|
||||
stats.snapshot(),
|
||||
active_sessions_by_client_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+86
-1
@@ -1,4 +1,10 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
/// Counters shared by a session or runtime. Values are monotonic.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -16,9 +22,32 @@ pub struct Stats {
|
||||
pub authentication_rejections: AtomicU64,
|
||||
pub rate_limited_attempts: AtomicU64,
|
||||
pub backend_errors: AtomicU64,
|
||||
active_sessions_by_client_id: Arc<Mutex<BTreeMap<String, usize>>>,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
/// Track one authenticated session until its guard is dropped. Counts are
|
||||
/// deterministic and safe to snapshot while session tasks are exiting.
|
||||
pub fn track_client_session(&self, client_id: impl Into<String>) -> ActiveClientSession {
|
||||
let client_id = client_id.into();
|
||||
let mut active = self
|
||||
.active_sessions_by_client_id
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*active.entry(client_id.clone()).or_default() += 1;
|
||||
ActiveClientSession {
|
||||
active_sessions: self.active_sessions_by_client_id.clone(),
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_sessions_by_client_id(&self) -> BTreeMap<String, usize> {
|
||||
self.active_sessions_by_client_id
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -49,6 +78,26 @@ impl Stats {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActiveClientSession {
|
||||
active_sessions: Arc<Mutex<BTreeMap<String, usize>>>,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ActiveClientSession {
|
||||
fn drop(&mut self) {
|
||||
let mut active = self
|
||||
.active_sessions
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(count) = active.get_mut(&self.client_id) {
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
active.remove(&self.client_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct StatsSnapshot {
|
||||
pub bytes_sent: u64,
|
||||
@@ -65,3 +114,39 @@ pub struct StatsSnapshot {
|
||||
pub rate_limited_attempts: u64,
|
||||
pub backend_errors: u64,
|
||||
}
|
||||
|
||||
impl StatsSnapshot {
|
||||
/// Add counters from independent transport listeners without losing any
|
||||
/// per-listener accounting during a multi-transport server run.
|
||||
pub fn saturating_add(self, other: Self) -> Self {
|
||||
Self {
|
||||
bytes_sent: self.bytes_sent.saturating_add(other.bytes_sent),
|
||||
bytes_received: self.bytes_received.saturating_add(other.bytes_received),
|
||||
datagrams_sent: self.datagrams_sent.saturating_add(other.datagrams_sent),
|
||||
datagrams_received: self
|
||||
.datagrams_received
|
||||
.saturating_add(other.datagrams_received),
|
||||
datagrams_dropped: self
|
||||
.datagrams_dropped
|
||||
.saturating_add(other.datagrams_dropped),
|
||||
oversized_datagrams: self
|
||||
.oversized_datagrams
|
||||
.saturating_add(other.oversized_datagrams),
|
||||
authentication_failures: self
|
||||
.authentication_failures
|
||||
.saturating_add(other.authentication_failures),
|
||||
reconnections: self.reconnections.saturating_add(other.reconnections),
|
||||
expired_sessions: self.expired_sessions.saturating_add(other.expired_sessions),
|
||||
handshake_attempts: self
|
||||
.handshake_attempts
|
||||
.saturating_add(other.handshake_attempts),
|
||||
authentication_rejections: self
|
||||
.authentication_rejections
|
||||
.saturating_add(other.authentication_rejections),
|
||||
rate_limited_attempts: self
|
||||
.rate_limited_attempts
|
||||
.saturating_add(other.rate_limited_attempts),
|
||||
backend_errors: self.backend_errors.saturating_add(other.backend_errors),
|
||||
}
|
||||
}
|
||||
}
|
||||
+373
-67
@@ -30,7 +30,7 @@ use crate::{
|
||||
AcceptPacket, DataPacket, HelloPacket, Packet, PacketType, RejectPacket, RejectReason,
|
||||
SessionPacket,
|
||||
},
|
||||
replay::{NonceReplayCache, ReplayWindow},
|
||||
replay::ReplayWindow,
|
||||
},
|
||||
stats::Stats,
|
||||
};
|
||||
@@ -39,6 +39,8 @@ use super::{TransportConnector, TransportError, TransportFuture, TransportSessio
|
||||
|
||||
const DEFAULT_MAX_DATAGRAM: usize = 1200;
|
||||
const MAX_UDP_DATAGRAM: usize = u16::MAX as usize;
|
||||
const HELLO_REPLAY_WINDOW: Duration = Duration::from_secs(120);
|
||||
const MAX_HELLO_REPLAY_ENTRIES: usize = 4096;
|
||||
|
||||
fn unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
@@ -71,10 +73,12 @@ pub struct DirectUdpSession {
|
||||
socket: Arc<UdpSocket>,
|
||||
peer: Mutex<SocketAddr>,
|
||||
session_id: u64,
|
||||
client_id: String,
|
||||
session_key: [u8; auth::SESSION_KEY_LEN],
|
||||
send_sequence: AtomicU32,
|
||||
receive_window: Mutex<ReplayWindow>,
|
||||
max_datagram: usize,
|
||||
keepalive_interval: Option<Duration>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -147,10 +151,16 @@ impl DirectUdpSession {
|
||||
socket,
|
||||
peer: Mutex::new(source),
|
||||
session_id: accept.session_id,
|
||||
client_id: config.client.id.clone(),
|
||||
session_key,
|
||||
send_sequence: AtomicU32::new(auth::generate_sequence_number()),
|
||||
receive_window: Mutex::new(ReplayWindow::new()),
|
||||
max_datagram,
|
||||
keepalive_interval: (config.transport.direct_udp.keepalive_secs
|
||||
> 0)
|
||||
.then(|| {
|
||||
Duration::from_secs(config.transport.direct_udp.keepalive_secs)
|
||||
}),
|
||||
closed: CancellationToken::new(),
|
||||
});
|
||||
}
|
||||
@@ -193,13 +203,14 @@ impl DirectUdpSession {
|
||||
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 packet =
|
||||
match protocol::direct::parse_packet_bounded(&buf[..len], self.max_datagram) {
|
||||
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)
|
||||
@@ -275,6 +286,18 @@ impl TransportSession for DirectUdpSession {
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
|
||||
fn client_id(&self) -> Option<&str> {
|
||||
Some(&self.client_id)
|
||||
}
|
||||
|
||||
fn send_keepalive<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move { DirectUdpSession::send_keepalive(self).await })
|
||||
}
|
||||
|
||||
fn authenticated_keepalive_interval(&self) -> Option<Duration> {
|
||||
self.keepalive_interval
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DirectUdpConnector {
|
||||
@@ -297,6 +320,7 @@ impl TransportConnector for DirectUdpConnector {
|
||||
struct ServerSession {
|
||||
id: u64,
|
||||
client_id: String,
|
||||
hello_key: HelloCacheKey,
|
||||
key: [u8; auth::SESSION_KEY_LEN],
|
||||
peer: Mutex<SocketAddr>,
|
||||
backend: Arc<UdpSocket>,
|
||||
@@ -307,6 +331,110 @@ struct ServerSession {
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct HelloCacheKey {
|
||||
client_id: Vec<u8>,
|
||||
client_nonce: [u8; 16],
|
||||
}
|
||||
|
||||
impl HelloCacheKey {
|
||||
fn new(hello: &HelloPacket) -> Self {
|
||||
Self {
|
||||
client_id: hello.client_id.to_vec(),
|
||||
client_nonce: hello.client_nonce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HelloCacheEntry {
|
||||
hello: HelloPacket,
|
||||
peer: SocketAddr,
|
||||
session_id: Option<u64>,
|
||||
accept_bytes: Option<Vec<u8>>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
enum HelloCacheLookup {
|
||||
New,
|
||||
Retry {
|
||||
session_id: u64,
|
||||
accept_bytes: Vec<u8>,
|
||||
},
|
||||
Replay,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Bounded replay cache for accepted HELLOs. Active entries retain the original
|
||||
/// ACCEPT so a retransmission can recover a lost response; closed/expired
|
||||
/// sessions become nonce tombstones until the replay window elapses.
|
||||
#[derive(Default)]
|
||||
struct HelloReplayCache {
|
||||
entries: HashMap<HelloCacheKey, HelloCacheEntry>,
|
||||
}
|
||||
|
||||
impl HelloReplayCache {
|
||||
fn purge_expired(&mut self, now: Instant) {
|
||||
self.entries.retain(|_, entry| entry.expires_at > now);
|
||||
}
|
||||
|
||||
fn lookup(&mut self, hello: &HelloPacket, peer: SocketAddr, now: Instant) -> HelloCacheLookup {
|
||||
self.purge_expired(now);
|
||||
let key = HelloCacheKey::new(hello);
|
||||
if let Some(entry) = self.entries.get(&key) {
|
||||
if entry.hello != *hello || entry.peer != peer {
|
||||
return HelloCacheLookup::Replay;
|
||||
}
|
||||
return match (entry.session_id, entry.accept_bytes.as_ref()) {
|
||||
(Some(session_id), Some(accept_bytes)) => HelloCacheLookup::Retry {
|
||||
session_id,
|
||||
accept_bytes: accept_bytes.clone(),
|
||||
},
|
||||
_ => HelloCacheLookup::Replay,
|
||||
};
|
||||
}
|
||||
if self.entries.len() >= MAX_HELLO_REPLAY_ENTRIES {
|
||||
HelloCacheLookup::Full
|
||||
} else {
|
||||
HelloCacheLookup::New
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(
|
||||
&mut self,
|
||||
hello: HelloPacket,
|
||||
peer: SocketAddr,
|
||||
session_id: u64,
|
||||
accept_bytes: Vec<u8>,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
self.purge_expired(now);
|
||||
let key = HelloCacheKey::new(&hello);
|
||||
if self.entries.contains_key(&key) || self.entries.len() >= MAX_HELLO_REPLAY_ENTRIES {
|
||||
return false;
|
||||
}
|
||||
self.entries.insert(
|
||||
key,
|
||||
HelloCacheEntry {
|
||||
hello,
|
||||
peer,
|
||||
session_id: Some(session_id),
|
||||
accept_bytes: Some(accept_bytes),
|
||||
expires_at: now + HELLO_REPLAY_WINDOW,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, key: &HelloCacheKey, session_id: u64) {
|
||||
if let Some(entry) = self.entries.get_mut(key) {
|
||||
if entry.session_id == Some(session_id) {
|
||||
entry.session_id = None;
|
||||
entry.accept_bytes = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-client direct UDP server. Each authenticated session gets its own
|
||||
/// connected backend socket and therefore natural response demultiplexing.
|
||||
pub struct DirectUdpServer {
|
||||
@@ -314,12 +442,40 @@ pub struct DirectUdpServer {
|
||||
backend_target: SocketAddr,
|
||||
clients: HashMap<String, Vec<u8>>,
|
||||
sessions: Arc<Mutex<HashMap<u64, Arc<ServerSession>>>>,
|
||||
nonces: Arc<Mutex<NonceReplayCache>>,
|
||||
hello_cache: Mutex<HelloReplayCache>,
|
||||
pending: Arc<Mutex<HashMap<SocketAddr, Instant>>>,
|
||||
config: ServerConfig,
|
||||
stats: Arc<Stats>,
|
||||
}
|
||||
|
||||
/// Read-only observation handle that remains usable after `run(self)` takes
|
||||
/// ownership of the listener. The session registry stays owned by the server;
|
||||
/// observers only share its lock and aggregate counters.
|
||||
#[derive(Clone)]
|
||||
pub struct DirectUdpServerHandle {
|
||||
sessions: Arc<Mutex<HashMap<u64, Arc<ServerSession>>>>,
|
||||
stats: Arc<Stats>,
|
||||
}
|
||||
|
||||
impl DirectUdpServerHandle {
|
||||
pub async fn active_sessions(&self) -> usize {
|
||||
self.sessions.lock().await.len()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> Arc<Stats> {
|
||||
self.stats.clone()
|
||||
}
|
||||
|
||||
pub async fn active_sessions_by_client_id(&self) -> std::collections::BTreeMap<String, usize> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let mut active = std::collections::BTreeMap::new();
|
||||
for session in sessions.values() {
|
||||
*active.entry(session.client_id.clone()).or_default() += 1;
|
||||
}
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
impl DirectUdpServer {
|
||||
pub async fn bind(config: ServerConfig) -> Result<Self> {
|
||||
let bind = direct_bind_addr(&config.server.listen, config.transports.direct_udp.port)?;
|
||||
@@ -347,7 +503,7 @@ impl DirectUdpServer {
|
||||
backend_target,
|
||||
clients,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
nonces: Arc::new(Mutex::new(NonceReplayCache::new(Duration::from_secs(120)))),
|
||||
hello_cache: Mutex::new(HelloReplayCache::default()),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
config,
|
||||
stats: Arc::new(Stats::default()),
|
||||
@@ -359,6 +515,14 @@ impl DirectUdpServer {
|
||||
pub fn stats(&self) -> Arc<Stats> {
|
||||
self.stats.clone()
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> DirectUdpServerHandle {
|
||||
DirectUdpServerHandle {
|
||||
sessions: self.sessions.clone(),
|
||||
stats: self.stats.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn active_sessions(&self) -> usize {
|
||||
self.sessions.lock().await.len()
|
||||
}
|
||||
@@ -400,26 +564,6 @@ impl DirectUdpServer {
|
||||
}
|
||||
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(""))
|
||||
@@ -429,9 +573,7 @@ impl DirectUdpServer {
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
if !hello.is_fresh(SystemTime::now(), Duration::from_secs(60))
|
||||
|| !hello.verify_authenticator(secret)
|
||||
{
|
||||
if !hello.verify_authenticator(secret) {
|
||||
self.stats
|
||||
.authentication_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -440,22 +582,89 @@ impl DirectUdpServer {
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.nonces.lock().await.check_and_record(
|
||||
&hello.client_id,
|
||||
hello.client_nonce,
|
||||
Instant::now(),
|
||||
) {
|
||||
|
||||
let now = Instant::now();
|
||||
let cache_lookup = self.hello_cache.lock().await.lookup(&hello, peer, now);
|
||||
match cache_lookup {
|
||||
HelloCacheLookup::Retry {
|
||||
session_id,
|
||||
accept_bytes,
|
||||
} => {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
let active = self.sessions.lock().await.contains_key(&session_id);
|
||||
if !active {
|
||||
self.hello_cache
|
||||
.lock()
|
||||
.await
|
||||
.deactivate(&HelloCacheKey::new(&hello), session_id);
|
||||
self.send_reject(peer, RejectReason::ReplayDetected).await;
|
||||
return Ok(None);
|
||||
}
|
||||
match self.socket.send_to(&accept_bytes, peer).await {
|
||||
Ok(_) => self.stats.add_sent(accept_bytes.len()),
|
||||
Err(error) => {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(remote_addr=%peer, %error, "failed to retransmit cached direct UDP ACCEPT")
|
||||
}
|
||||
}
|
||||
// The session task already exists; a retry only resends the
|
||||
// original response and must not start another backend loop.
|
||||
return Ok(None);
|
||||
}
|
||||
HelloCacheLookup::Replay => {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::ReplayDetected).await;
|
||||
return Ok(None);
|
||||
}
|
||||
HelloCacheLookup::Full => {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.stats
|
||||
.rate_limited_attempts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.send_reject(peer, RejectReason::RateLimited).await;
|
||||
return Ok(None);
|
||||
}
|
||||
HelloCacheLookup::New => {}
|
||||
}
|
||||
|
||||
// Exact authenticated retries reuse the original response for the
|
||||
// entire replay-cache window, even after the initial timestamp skew
|
||||
// window has elapsed. A new nonce still must be fresh before allocation.
|
||||
if !hello.is_fresh(SystemTime::now(), Duration::from_secs(60)) {
|
||||
self.stats
|
||||
.authentication_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::ReplayDetected).await;
|
||||
self.send_reject(peer, RejectReason::AuthenticationFailed)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let source_ip = peer.ip();
|
||||
let active = self.sessions.lock().await;
|
||||
let mut sessions_for_ip = 0;
|
||||
for session in active.values() {
|
||||
if session.peer.lock().await.ip() == source_ip {
|
||||
sessions_for_ip += 1;
|
||||
}
|
||||
}
|
||||
let at_limit = active.len() >= self.config.limits.max_sessions
|
||||
|| sessions_for_ip >= self.config.limits.max_sessions_per_ip;
|
||||
drop(active);
|
||||
if at_limit {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::ServerBusy).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;
|
||||
let sessions = self.sessions.lock().await;
|
||||
while sessions.contains_key(&session_id) {
|
||||
session_id = auth::generate_session_id();
|
||||
}
|
||||
drop(sessions);
|
||||
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;
|
||||
@@ -464,6 +673,7 @@ impl DirectUdpServer {
|
||||
let session = Arc::new(ServerSession {
|
||||
id: session_id,
|
||||
client_id: String::from_utf8_lossy(&hello.client_id).into_owned(),
|
||||
hello_key: HelloCacheKey::new(&hello),
|
||||
key,
|
||||
peer: Mutex::new(peer),
|
||||
backend,
|
||||
@@ -473,9 +683,6 @@ impl DirectUdpServer {
|
||||
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,
|
||||
@@ -485,8 +692,29 @@ impl DirectUdpServer {
|
||||
max_datagram,
|
||||
);
|
||||
let accept_bytes = accept.encode()?;
|
||||
self.socket.send_to(&accept_bytes, peer).await?;
|
||||
self.stats.add_sent(accept_bytes.len());
|
||||
if !self
|
||||
.hello_cache
|
||||
.lock()
|
||||
.await
|
||||
.insert(hello, peer, session_id, accept_bytes.clone(), now)
|
||||
{
|
||||
self.send_reject(peer, RejectReason::RateLimited).await;
|
||||
return Ok(None);
|
||||
}
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id, session.clone());
|
||||
self.pending.lock().await.remove(&peer);
|
||||
// Keep and run the authenticated session even if this send fails. The
|
||||
// client retries the exact HELLO, which can now recover this ACCEPT.
|
||||
match self.socket.send_to(&accept_bytes, peer).await {
|
||||
Ok(_) => self.stats.add_sent(accept_bytes.len()),
|
||||
Err(error) => {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(session_id, remote_addr=%peer, %error, "failed to send direct UDP ACCEPT; retaining session for retry")
|
||||
}
|
||||
}
|
||||
info!(session_id, client_id=%session.client_id, remote_addr=%peer, "direct UDP client authenticated");
|
||||
Ok(Some(session))
|
||||
}
|
||||
@@ -494,13 +722,28 @@ impl DirectUdpServer {
|
||||
async fn remove_session(&self, id: u64, expired: bool) {
|
||||
if let Some(session) = self.sessions.lock().await.remove(&id) {
|
||||
session.cancel.cancel();
|
||||
self.hello_cache
|
||||
.lock()
|
||||
.await
|
||||
.deactivate(&session.hello_key, id);
|
||||
if expired {
|
||||
self.stats.expired_sessions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run until SIGINT/SIGTERM, then cancel sessions and await their cleanup.
|
||||
pub async fn run(self) -> Result<()> {
|
||||
self.run_inner(None).await
|
||||
}
|
||||
|
||||
/// Runtime-owned cancellation path used by the server supervisor. Both
|
||||
/// this and [`Self::run`] use the same explicit session cleanup sequence.
|
||||
pub async fn run_with_shutdown(self, shutdown: CancellationToken) -> Result<()> {
|
||||
self.run_inner(Some(shutdown)).await
|
||||
}
|
||||
|
||||
async fn run_inner(self, shutdown: Option<CancellationToken>) -> Result<()> {
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
|
||||
let mut cleanup = time::interval(Duration::from_secs(1));
|
||||
@@ -509,16 +752,36 @@ impl DirectUdpServer {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut signal => { info!("direct UDP server shutting down"); break; }
|
||||
_ = async {
|
||||
match shutdown.as_ref() {
|
||||
Some(token) => token.cancelled().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
} => { info!("direct UDP server cancellation requested"); break; }
|
||||
Some(done) = tasks.join_next() => { if let Ok(id) = done { self.remove_session(id, false).await; } }
|
||||
_ = cleanup.tick() => {
|
||||
let now = Instant::now();
|
||||
self.hello_cache.lock().await.purge_expired(now);
|
||||
let expired: Vec<u64> = self.sessions.lock().await.iter().filter_map(|(&id,s)| async_ready_expired(s, now, Duration::from_secs(self.config.session.idle_timeout_secs)).map(|_| id)).collect();
|
||||
for id in expired { self.remove_session(id, true).await; }
|
||||
}
|
||||
received = self.socket.recv_from(&mut buf) => {
|
||||
let (len, peer) = match received { Ok(v) => v, Err(e) => { warn!(error=%e, "direct UDP receive failed"); continue; } };
|
||||
self.stats.add_received(len);
|
||||
let packet = match protocol::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; } };
|
||||
let packet = match protocol::direct::parse_packet_bounded(&buf[..len], DEFAULT_MAX_DATAGRAM) {
|
||||
Ok(p) => p,
|
||||
Err(protocol::direct::ProtocolError::OversizedDataPayload { actual, maximum }) => {
|
||||
self.stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
debug!(remote_addr=%peer, actual, maximum, "dropping oversized direct UDP DATA before allocation");
|
||||
continue;
|
||||
}
|
||||
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)) => {
|
||||
@@ -528,10 +791,33 @@ impl DirectUdpServer {
|
||||
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; },
|
||||
_ => {}
|
||||
Packet::Data(data) => {
|
||||
let session_id = data.session_id;
|
||||
if let Err(error) = self.handle_session_packet(Packet::Data(data), peer).await {
|
||||
self.stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(session_id, error=%error, "backend forwarding failed");
|
||||
}
|
||||
}
|
||||
Packet::Keepalive(packet) => {
|
||||
let session_id = packet.session_id;
|
||||
if let Err(error) = self.handle_session_packet(Packet::Keepalive(packet), peer).await {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(session_id, error=%error, "keepalive handling failed");
|
||||
}
|
||||
}
|
||||
Packet::Close(packet) => {
|
||||
let session_id = packet.session_id;
|
||||
match self.handle_session_packet(Packet::Close(packet), peer).await {
|
||||
Ok(true) => self.remove_session(session_id, false).await,
|
||||
Ok(false) => {},
|
||||
Err(error) => {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
warn!(session_id, error=%error, "close handling failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => { self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -540,24 +826,44 @@ impl DirectUdpServer {
|
||||
for id in ids {
|
||||
self.remove_session(id, false).await;
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
let drain = async { while tasks.join_next().await.is_some() {} };
|
||||
if time::timeout(Duration::from_secs(2), drain).await.is_err() {
|
||||
tasks.abort_all();
|
||||
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<()> {
|
||||
async fn handle_session_packet(&self, packet: Packet, peer: SocketAddr) -> Result<bool> {
|
||||
let (packet_type, version, flags, id, sequence, tag, payload) = match packet {
|
||||
Packet::Data(data) => (
|
||||
PacketType::Data,
|
||||
data.version,
|
||||
data.flags,
|
||||
data.session_id,
|
||||
data.sequence,
|
||||
data.auth_tag,
|
||||
data.payload,
|
||||
),
|
||||
Packet::Keepalive(packet) | Packet::Close(packet) => (
|
||||
packet.packet_type,
|
||||
packet.version,
|
||||
packet.flags,
|
||||
packet.session_id,
|
||||
packet.sequence,
|
||||
packet.auth_tag,
|
||||
Bytes::new(),
|
||||
),
|
||||
_ => {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
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(());
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
if payload.len() > session.max_datagram {
|
||||
@@ -565,13 +871,13 @@ impl DirectUdpServer {
|
||||
.oversized_datagrams
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !auth::verify_data_auth_tag(
|
||||
&session.key,
|
||||
protocol::PROTOCOL_VERSION,
|
||||
version,
|
||||
packet_type,
|
||||
0,
|
||||
flags,
|
||||
id,
|
||||
sequence,
|
||||
&payload,
|
||||
@@ -581,11 +887,11 @@ impl DirectUdpServer {
|
||||
.authentication_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !session.receive_window.lock().await.accept(sequence) {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
*session.peer.lock().await = peer;
|
||||
*session.last_activity.lock().await = Instant::now();
|
||||
@@ -597,7 +903,7 @@ impl DirectUdpServer {
|
||||
PacketType::Keepalive => {}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1186
-22
File diff suppressed because it is too large.
Load diff
+576
-61
@@ -1,7 +1,13 @@
|
||||
//! Common transport boundary and the Milestone 1 direct UDP transport.
|
||||
|
||||
use std::{
|
||||
collections::HashMap, future::Future, net::SocketAddr, pin::Pin, sync::Arc, time::Duration,
|
||||
collections::{HashMap, VecDeque},
|
||||
future::Future,
|
||||
io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
pin::Pin,
|
||||
sync::{Arc, Mutex as StdMutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
@@ -9,7 +15,7 @@ use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{split, AsyncRead, AsyncWrite},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::Mutex,
|
||||
sync::{mpsc, oneshot, Mutex},
|
||||
};
|
||||
|
||||
pub mod direct_udp;
|
||||
@@ -18,6 +24,7 @@ pub mod quic;
|
||||
pub mod tls_tcp;
|
||||
|
||||
pub use direct_udp::{DirectUdpConnector, DirectUdpServer, DirectUdpSession};
|
||||
pub use http3::{Http3ClientConnector, Http3Server, Http3TransportSession};
|
||||
|
||||
pub type TransportFuture<'a, T> =
|
||||
Pin<Box<dyn Future<Output = Result<T, TransportError>> + Send + 'a>>;
|
||||
@@ -38,16 +45,258 @@ pub enum TransportError {
|
||||
Closed,
|
||||
#[error("transport handshake timed out")]
|
||||
HandshakeTimeout,
|
||||
#[error("transport server is busy")]
|
||||
Busy,
|
||||
#[error("transport authentication attempts are rate limited")]
|
||||
RateLimited,
|
||||
#[error("transport is not available: {0}")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
const MAX_TRACKED_SOURCE_IPS: usize = 4096;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum AdmissionError {
|
||||
Busy,
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AdmissionLimits {
|
||||
max_sessions: usize,
|
||||
max_sessions_per_ip: usize,
|
||||
max_pending_handshakes: usize,
|
||||
max_auth_attempts_per_ip: usize,
|
||||
auth_attempt_window: Duration,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AdmissionState {
|
||||
active: usize,
|
||||
pending: usize,
|
||||
active_by_ip: HashMap<IpAddr, usize>,
|
||||
pending_by_ip: HashMap<IpAddr, usize>,
|
||||
attempts_by_ip: HashMap<IpAddr, AttemptWindow>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AttemptWindow {
|
||||
attempts: VecDeque<Instant>,
|
||||
last_seen: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Shared admission controller for encrypted server listeners.
|
||||
pub struct AdmissionController {
|
||||
limits: AdmissionLimits,
|
||||
state: StdMutex<AdmissionState>,
|
||||
}
|
||||
|
||||
impl AdmissionController {
|
||||
pub fn new(config: &crate::config::LimitsConfig) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
limits: AdmissionLimits {
|
||||
max_sessions: config.max_sessions,
|
||||
max_sessions_per_ip: config.max_sessions_per_ip,
|
||||
max_pending_handshakes: config.max_pending_handshakes,
|
||||
max_auth_attempts_per_ip: config.max_auth_attempts_per_ip,
|
||||
auth_attempt_window: Duration::from_secs(config.auth_attempt_window_secs.max(1)),
|
||||
},
|
||||
state: StdMutex::new(AdmissionState::default()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn begin(self: &Arc<Self>, remote: SocketAddr) -> Result<PendingHandshake, AdmissionError> {
|
||||
let ip = match remote.ip() {
|
||||
IpAddr::V6(address) => address
|
||||
.to_ipv4_mapped()
|
||||
.map(IpAddr::V4)
|
||||
.unwrap_or(IpAddr::V6(address)),
|
||||
address => address,
|
||||
};
|
||||
let now = Instant::now();
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.attempts_by_ip.retain(|_, window| {
|
||||
window.last_seen.is_some_and(|last_seen| {
|
||||
now.duration_since(last_seen) < self.limits.auth_attempt_window
|
||||
})
|
||||
});
|
||||
|
||||
if state.pending >= self.limits.max_pending_handshakes
|
||||
|| state.active + state.pending >= self.limits.max_sessions
|
||||
|| state.active_by_ip.get(&ip).copied().unwrap_or(0)
|
||||
+ state.pending_by_ip.get(&ip).copied().unwrap_or(0)
|
||||
>= self.limits.max_sessions_per_ip
|
||||
{
|
||||
return Err(AdmissionError::Busy);
|
||||
}
|
||||
if !state.attempts_by_ip.contains_key(&ip)
|
||||
&& state.attempts_by_ip.len() >= MAX_TRACKED_SOURCE_IPS
|
||||
{
|
||||
return Err(AdmissionError::Busy);
|
||||
}
|
||||
let window = state.attempts_by_ip.entry(ip).or_default();
|
||||
while window
|
||||
.attempts
|
||||
.front()
|
||||
.is_some_and(|attempt| now.duration_since(*attempt) >= self.limits.auth_attempt_window)
|
||||
{
|
||||
window.attempts.pop_front();
|
||||
}
|
||||
if window.attempts.len() >= self.limits.max_auth_attempts_per_ip {
|
||||
window.last_seen = Some(now);
|
||||
return Err(AdmissionError::RateLimited);
|
||||
}
|
||||
window.attempts.push_back(now);
|
||||
window.last_seen = Some(now);
|
||||
state.pending += 1;
|
||||
*state.pending_by_ip.entry(ip).or_default() += 1;
|
||||
Ok(PendingHandshake {
|
||||
controller: self.clone(),
|
||||
ip,
|
||||
pending: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn active_sessions(&self) -> usize {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.active
|
||||
}
|
||||
|
||||
pub fn pending_handshakes(&self) -> usize {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.pending
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PendingHandshake {
|
||||
controller: Arc<AdmissionController>,
|
||||
ip: IpAddr,
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl PendingHandshake {
|
||||
pub fn promote(mut self) -> SessionPermit {
|
||||
let mut state = self
|
||||
.controller
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
decrement_pending(&mut state, self.ip);
|
||||
state.active += 1;
|
||||
*state.active_by_ip.entry(self.ip).or_default() += 1;
|
||||
self.pending = false;
|
||||
SessionPermit {
|
||||
controller: self.controller.clone(),
|
||||
ip: self.ip,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingHandshake {
|
||||
fn drop(&mut self) {
|
||||
if self.pending {
|
||||
let mut state = self
|
||||
.controller
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
decrement_pending(&mut state, self.ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionPermit {
|
||||
controller: Arc<AdmissionController>,
|
||||
ip: IpAddr,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl Drop for SessionPermit {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
let mut state = self
|
||||
.controller
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
decrement_active(&mut state, self.ip);
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_pending(state: &mut AdmissionState, ip: IpAddr) {
|
||||
state.pending = state.pending.saturating_sub(1);
|
||||
decrement_ip(&mut state.pending_by_ip, ip);
|
||||
}
|
||||
|
||||
fn decrement_active(state: &mut AdmissionState, ip: IpAddr) {
|
||||
state.active = state.active.saturating_sub(1);
|
||||
decrement_ip(&mut state.active_by_ip, ip);
|
||||
}
|
||||
|
||||
fn decrement_ip(by_ip: &mut HashMap<IpAddr, usize>, ip: IpAddr) {
|
||||
if let Some(count) = by_ip.get_mut(&ip) {
|
||||
*count = count.saturating_sub(1);
|
||||
if *count == 0 {
|
||||
by_ip.remove(&ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn admission_error(error: AdmissionError) -> TransportError {
|
||||
match error {
|
||||
AdmissionError::Busy => TransportError::Busy,
|
||||
AdmissionError::RateLimited => TransportError::RateLimited,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Authenticated client identity, when the carrier exposes it to the
|
||||
/// server runtime. Client-side sessions may also return their configured
|
||||
/// identity for diagnostics.
|
||||
fn client_id(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Send an authenticated transport control keepalive, if supported.
|
||||
fn send_keepalive<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async {
|
||||
Err(TransportError::Unsupported(
|
||||
"authenticated keepalive is not supported by this transport".into(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Authenticated application-control keepalive cadence. Native transport
|
||||
/// keepalives should return `None` so the client sends no redundant frame.
|
||||
fn authenticated_keepalive_interval(&self) -> Option<Duration> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The currently resolved peer endpoint, when the transport exposes it.
|
||||
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Most recent transport RTT measurement, if available.
|
||||
fn rtt(&self) -> Option<Duration> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection establishment is separate from an established session.
|
||||
@@ -61,33 +310,141 @@ trait AsyncIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncIo for T {}
|
||||
type BoxIo = Box<dyn AsyncIo>;
|
||||
|
||||
fn tls_auth_error(error: tls_tcp::AuthError) -> TransportError {
|
||||
match error {
|
||||
tls_tcp::AuthError::Rejected | tls_tcp::AuthError::InvalidCredentials => {
|
||||
TransportError::AuthenticationRejected(
|
||||
crate::protocol::RejectReason::AuthenticationFailed,
|
||||
)
|
||||
}
|
||||
tls_tcp::AuthError::UnknownClient => {
|
||||
TransportError::AuthenticationRejected(crate::protocol::RejectReason::UnknownClient)
|
||||
}
|
||||
tls_tcp::AuthError::Frame(tls_tcp::FrameError::Io(message))
|
||||
if message == "authentication timeout" =>
|
||||
{
|
||||
TransportError::HandshakeTimeout
|
||||
}
|
||||
tls_tcp::AuthError::Frame(tls_tcp::FrameError::Io(message)) => {
|
||||
TransportError::Io(io::Error::new(io::ErrorKind::ConnectionAborted, message))
|
||||
}
|
||||
tls_tcp::AuthError::Frame(error) => TransportError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
error.to_string(),
|
||||
)),
|
||||
tls_tcp::AuthError::InvalidMessage => TransportError::Io(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid TLS/TCP authentication message",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
struct TlsTcpSession {
|
||||
reader: Mutex<Option<tls_tcp::FramedReader<TlsRead>>>,
|
||||
writer: Mutex<Option<tls_tcp::FramedWriter<TlsWrite>>>,
|
||||
writer: mpsc::Sender<TlsWriterCommand>,
|
||||
max_datagram: usize,
|
||||
closed: tokio_util::sync::CancellationToken,
|
||||
keepalive_interval: Option<Duration>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
client_id: Option<String>,
|
||||
admission: StdMutex<Option<SessionPermit>>,
|
||||
}
|
||||
|
||||
enum TlsWriterCommand {
|
||||
Datagram(Bytes, oneshot::Sender<Result<(), tls_tcp::SessionError>>),
|
||||
Keepalive(oneshot::Sender<Result<(), tls_tcp::SessionError>>),
|
||||
Close(oneshot::Sender<Result<(), tls_tcp::SessionError>>),
|
||||
}
|
||||
|
||||
struct TlsSessionOptions {
|
||||
max_datagram: usize,
|
||||
idle_timeout: Duration,
|
||||
keepalive_interval: Option<Duration>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
client_id: Option<String>,
|
||||
admission: Option<SessionPermit>,
|
||||
}
|
||||
|
||||
impl TlsTcpSession {
|
||||
fn from_halves(
|
||||
reader: TlsRead,
|
||||
writer: TlsWrite,
|
||||
max_datagram: usize,
|
||||
idle_timeout: Duration,
|
||||
) -> Self {
|
||||
fn from_halves(reader: TlsRead, writer: TlsWrite, options: TlsSessionOptions) -> Self {
|
||||
let TlsSessionOptions {
|
||||
max_datagram,
|
||||
idle_timeout,
|
||||
keepalive_interval,
|
||||
remote_addr,
|
||||
client_id,
|
||||
admission,
|
||||
} = options;
|
||||
// A dedicated writer task owns each framed stream write to completion.
|
||||
// Dropping a caller while it awaits the reply cannot leave a partial
|
||||
// frame on the TCP stream.
|
||||
let (writer_tx, mut writer_rx) = mpsc::channel(16);
|
||||
tokio::spawn(async move {
|
||||
let mut writer = tls_tcp::FramedWriter::new(writer, max_datagram);
|
||||
while let Some(command) = writer_rx.recv().await {
|
||||
match command {
|
||||
TlsWriterCommand::Datagram(data, reply) => {
|
||||
let result = writer.send_datagram(&data).await;
|
||||
let failed = result.is_err();
|
||||
let _ = reply.send(result);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
TlsWriterCommand::Keepalive(reply) => {
|
||||
let result = writer.send_keepalive().await;
|
||||
let failed = result.is_err();
|
||||
let _ = reply.send(result);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
TlsWriterCommand::Close(reply) => {
|
||||
let result = match writer.send_close().await {
|
||||
Ok(()) => writer.shutdown(Duration::from_secs(2)).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let _ = reply.send(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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))),
|
||||
writer: writer_tx,
|
||||
max_datagram,
|
||||
closed: tokio_util::sync::CancellationToken::new(),
|
||||
keepalive_interval,
|
||||
remote_addr,
|
||||
client_id,
|
||||
admission: StdMutex::new(admission),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tls_session_error(error: tls_tcp::SessionError) -> TransportError {
|
||||
match error {
|
||||
tls_tcp::SessionError::Closed | tls_tcp::SessionError::IdleTimeout => {
|
||||
TransportError::Closed
|
||||
}
|
||||
tls_tcp::SessionError::Frame(tls_tcp::FrameError::Oversized { maximum, .. }) => {
|
||||
TransportError::Oversized { maximum }
|
||||
}
|
||||
tls_tcp::SessionError::Frame(error) => {
|
||||
TransportError::Io(std::io::Error::other(error.to_string()))
|
||||
}
|
||||
other => TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
other.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportSession for TlsTcpSession {
|
||||
fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
@@ -96,24 +453,18 @@ impl TransportSession for TlsTcpSession {
|
||||
maximum: self.max_datagram,
|
||||
});
|
||||
}
|
||||
let mut guard = self.writer.lock().await;
|
||||
let Some(writer) = guard.as_mut() else {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(TransportError::Closed);
|
||||
};
|
||||
writer
|
||||
.send_datagram(&data)
|
||||
}
|
||||
let (reply, result) = oneshot::channel();
|
||||
self.writer
|
||||
.send(TlsWriterCommand::Datagram(data, reply))
|
||||
.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(),
|
||||
)),
|
||||
})
|
||||
.map_err(|_| TransportError::Closed)?;
|
||||
result
|
||||
.await
|
||||
.map_err(|_| TransportError::Closed)?
|
||||
.map_err(tls_session_error)
|
||||
})
|
||||
}
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
|
||||
@@ -133,10 +484,7 @@ impl TransportSession for TlsTcpSession {
|
||||
..
|
||||
}) => TransportError::Oversized { maximum },
|
||||
tls_tcp::SessionError::IdleTimeout => TransportError::Closed,
|
||||
other => TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
other.to_string(),
|
||||
)),
|
||||
other => TransportError::Io(std::io::Error::other(other.to_string())),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -146,25 +494,64 @@ impl TransportSession for TlsTcpSession {
|
||||
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))
|
||||
self.admission
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let (reply, result) = oneshot::channel();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
if self
|
||||
.writer
|
||||
.send(TlsWriterCommand::Close(reply))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
error.to_string(),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let result = result.await.map_err(|_| TransportError::Closed)?;
|
||||
result.map_err(tls_session_error)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"TLS/TCP close timed out",
|
||||
))
|
||||
})?
|
||||
})
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
|
||||
fn client_id(&self) -> Option<&str> {
|
||||
self.client_id.as_deref()
|
||||
}
|
||||
|
||||
fn send_keepalive<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
let (reply, result) = oneshot::channel();
|
||||
self.writer
|
||||
.send(TlsWriterCommand::Keepalive(reply))
|
||||
.await
|
||||
.map_err(|_| TransportError::Closed)?;
|
||||
result
|
||||
.await
|
||||
.map_err(|_| TransportError::Closed)?
|
||||
.map_err(tls_session_error)
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticated_keepalive_interval(&self) -> Option<Duration> {
|
||||
self.keepalive_interval
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||
self.remote_addr
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TlsTcpConnector {
|
||||
@@ -190,6 +577,7 @@ impl TlsTcpConnector {
|
||||
async fn connect_session(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let host = self.config.client.server.trim();
|
||||
let stream = TcpStream::connect((host, self.config.transport.tls_tcp.port)).await?;
|
||||
let remote_addr = stream.peer_addr().ok();
|
||||
let connector_config = match &self.client_config {
|
||||
Some(config) => config.clone(),
|
||||
None => {
|
||||
@@ -248,13 +636,21 @@ impl TlsTcpConnector {
|
||||
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));
|
||||
.map_err(tls_auth_error)?;
|
||||
let keepalive_secs = self.config.transport.tls_tcp.keepalive_secs;
|
||||
let keepalive_interval = (keepalive_secs > 0).then(|| Duration::from_secs(keepalive_secs));
|
||||
let session = TlsTcpSession::from_halves(
|
||||
reader,
|
||||
writer,
|
||||
TlsSessionOptions {
|
||||
max_datagram: max,
|
||||
idle_timeout: Duration::from_secs(120),
|
||||
keepalive_interval,
|
||||
remote_addr,
|
||||
client_id: Some(self.config.client.id.clone()),
|
||||
admission: None,
|
||||
},
|
||||
);
|
||||
Ok(Box::new(session))
|
||||
}
|
||||
}
|
||||
@@ -269,9 +665,25 @@ pub struct TlsTcpServer {
|
||||
tls: Arc<rustls::ServerConfig>,
|
||||
clients: Arc<HashMap<String, Vec<u8>>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
admission: Arc<AdmissionController>,
|
||||
}
|
||||
|
||||
pub struct PendingTlsHandshake {
|
||||
stream: TcpStream,
|
||||
remote_addr: SocketAddr,
|
||||
admission: PendingHandshake,
|
||||
}
|
||||
|
||||
impl TlsTcpServer {
|
||||
pub async fn bind(config: &crate::config::ServerConfig) -> Result<Self, TransportError> {
|
||||
Self::bind_with_admission(config, AdmissionController::new(&config.limits)).await
|
||||
}
|
||||
|
||||
pub async fn bind_with_admission(
|
||||
config: &crate::config::ServerConfig,
|
||||
admission: Arc<AdmissionController>,
|
||||
) -> Result<Self, TransportError> {
|
||||
let listen = if config.server.listen.parse::<SocketAddr>().is_ok() {
|
||||
config.server.listen.clone()
|
||||
} else {
|
||||
@@ -314,13 +726,51 @@ impl TlsTcpServer {
|
||||
tls,
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: 1200,
|
||||
handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)),
|
||||
admission,
|
||||
})
|
||||
}
|
||||
pub fn local_addr(&self) -> Result<SocketAddr, TransportError> {
|
||||
Ok(self.listener.local_addr()?)
|
||||
}
|
||||
pub async fn accept_pending(&self) -> Result<PendingTlsHandshake, TransportError> {
|
||||
let (stream, remote_addr) = self.listener.accept().await?;
|
||||
let pending = self.admission.begin(remote_addr).map_err(admission_error)?;
|
||||
Ok(PendingTlsHandshake {
|
||||
stream,
|
||||
remote_addr,
|
||||
admission: pending,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn authenticate_pending(
|
||||
&self,
|
||||
pending: PendingTlsHandshake,
|
||||
) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let PendingTlsHandshake {
|
||||
stream,
|
||||
remote_addr,
|
||||
admission,
|
||||
} = pending;
|
||||
tokio::time::timeout(
|
||||
self.handshake_timeout,
|
||||
self.authenticate_peer(stream, remote_addr, admission),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
}
|
||||
|
||||
pub async fn accept_session(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let (stream, _) = self.listener.accept().await?;
|
||||
let pending = self.accept_pending().await?;
|
||||
self.authenticate_pending(pending).await
|
||||
}
|
||||
|
||||
async fn authenticate_peer(
|
||||
&self,
|
||||
stream: TcpStream,
|
||||
remote_addr: SocketAddr,
|
||||
pending: PendingHandshake,
|
||||
) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(self.tls.clone());
|
||||
let tls = acceptor.accept(stream).await.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
@@ -329,25 +779,90 @@ impl TlsTcpServer {
|
||||
))
|
||||
})?;
|
||||
let (mut reader, mut writer) = split(Box::new(tls) as BoxIo);
|
||||
tls_tcp::server_authenticate(
|
||||
let authenticated = tls_tcp::server_authenticate(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&self.clients,
|
||||
self.max_datagram.saturating_add(512),
|
||||
Duration::from_secs(3),
|
||||
self.handshake_timeout,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
.map_err(tls_auth_error)?;
|
||||
let admission = pending.promote();
|
||||
Ok(Box::new(TlsTcpSession::from_halves(
|
||||
reader,
|
||||
writer,
|
||||
self.max_datagram,
|
||||
Duration::from_secs(120),
|
||||
TlsSessionOptions {
|
||||
max_datagram: self.max_datagram,
|
||||
idle_timeout: Duration::from_secs(120),
|
||||
keepalive_interval: None,
|
||||
remote_addr: Some(remote_addr),
|
||||
client_id: Some(authenticated.id),
|
||||
admission: Some(admission),
|
||||
},
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{duplex, AsyncReadExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_keepalive_wait_does_not_cancel_partial_frame_write() {
|
||||
let (client, peer) = duplex(1);
|
||||
let (client_reader, client_writer) = split(Box::new(client) as BoxIo);
|
||||
let (mut peer_reader, _peer_writer) = split(Box::new(peer) as BoxIo);
|
||||
let session = Arc::new(TlsTcpSession::from_halves(
|
||||
client_reader,
|
||||
client_writer,
|
||||
TlsSessionOptions {
|
||||
max_datagram: 1200,
|
||||
idle_timeout: Duration::from_secs(5),
|
||||
keepalive_interval: Some(Duration::from_secs(1)),
|
||||
remote_addr: None,
|
||||
client_id: None,
|
||||
admission: None,
|
||||
},
|
||||
));
|
||||
|
||||
let send_session = session.clone();
|
||||
let send = tokio::spawn(async move { send_session.send_keepalive().await });
|
||||
let mut first_header_byte = [0; 1];
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
peer_reader.read_exact(&mut first_header_byte),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(first_header_byte, [0]);
|
||||
|
||||
// The writer is now blocked partway through its frame. Cancelling the
|
||||
// awaiting caller must not cancel the dedicated stream writer.
|
||||
send.abort();
|
||||
let _ = send.await;
|
||||
let mut rest = [0; 2];
|
||||
tokio::time::timeout(Duration::from_secs(1), peer_reader.read_exact(&mut rest))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rest, [1, 0x02]);
|
||||
|
||||
let send_session = session.clone();
|
||||
let data_send =
|
||||
tokio::spawn(
|
||||
async move { send_session.send_datagram(Bytes::from_static(b"ok")).await },
|
||||
);
|
||||
let next_frame = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
tls_tcp::read_frame(&mut peer_reader, 1201),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(next_frame, [0x01, b'o', b'k']);
|
||||
data_send.await.unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
+128
-14
@@ -30,7 +30,10 @@ use crate::{
|
||||
protocol::{auth, RejectReason, PROTOCOL_VERSION},
|
||||
};
|
||||
|
||||
use super::{TransportConnector, TransportError, TransportFuture, TransportSession};
|
||||
use super::{
|
||||
admission_error, AdmissionController, PendingHandshake, SessionPermit, TransportConnector,
|
||||
TransportError, TransportFuture, TransportSession,
|
||||
};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"WGCQ";
|
||||
const CONTROL_VERSION: u8 = PROTOCOL_VERSION;
|
||||
@@ -44,7 +47,7 @@ const MAX_CLIENT_ID: usize = 255;
|
||||
const DEFAULT_MAX_DATAGRAM: usize = 1200;
|
||||
|
||||
fn io_other(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::Other, message.into())
|
||||
io::Error::other(message.into())
|
||||
}
|
||||
|
||||
fn io_invalid(message: impl Into<String>) -> io::Error {
|
||||
@@ -251,16 +254,26 @@ pub struct QuicDatagramSession {
|
||||
connection: Connection,
|
||||
endpoint: Endpoint,
|
||||
max_datagram: usize,
|
||||
client_id: Option<String>,
|
||||
closed: CancellationToken,
|
||||
admission: std::sync::Mutex<Option<SessionPermit>>,
|
||||
}
|
||||
|
||||
impl QuicDatagramSession {
|
||||
fn new(connection: Connection, endpoint: Endpoint, max_datagram: usize) -> Self {
|
||||
fn new(
|
||||
connection: Connection,
|
||||
endpoint: Endpoint,
|
||||
max_datagram: usize,
|
||||
client_id: Option<String>,
|
||||
admission: Option<SessionPermit>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
endpoint,
|
||||
max_datagram,
|
||||
client_id,
|
||||
closed: CancellationToken::new(),
|
||||
admission: std::sync::Mutex::new(admission),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +333,10 @@ impl TransportSession for QuicDatagramSession {
|
||||
if !self.closed.is_cancelled() {
|
||||
self.closed.cancel();
|
||||
self.connection.close(0u32.into(), b"closed");
|
||||
self.admission
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
// Dropping the endpoint handle is intentionally deferred to
|
||||
// session drop; this keeps the connection driver alive while
|
||||
// the close frame is emitted.
|
||||
@@ -332,6 +349,18 @@ impl TransportSession for QuicDatagramSession {
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
|
||||
fn client_id(&self) -> Option<&str> {
|
||||
self.client_id.as_deref()
|
||||
}
|
||||
|
||||
fn remote_addr(&self) -> Option<SocketAddr> {
|
||||
Some(self.connection.remote_address())
|
||||
}
|
||||
|
||||
fn rtt(&self) -> Option<Duration> {
|
||||
Some(self.connection.rtt())
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side QUIC connector.
|
||||
@@ -407,7 +436,13 @@ impl QuicClientConnector {
|
||||
)));
|
||||
}
|
||||
info!(remote_addr=%addr, max_datagram, "QUIC session authenticated");
|
||||
Ok(QuicDatagramSession::new(connection, endpoint, max_datagram))
|
||||
Ok(QuicDatagramSession::new(
|
||||
connection,
|
||||
endpoint,
|
||||
max_datagram,
|
||||
Some(self.config.client.id.clone()),
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn connect(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
@@ -429,6 +464,12 @@ pub struct QuicServer {
|
||||
clients: Arc<HashMap<String, Vec<u8>>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
admission: Arc<AdmissionController>,
|
||||
}
|
||||
|
||||
pub struct PendingQuicHandshake {
|
||||
incoming: quinn::Incoming,
|
||||
admission: PendingHandshake,
|
||||
}
|
||||
|
||||
/// Alias matching the listener terminology used in the specification.
|
||||
@@ -436,7 +477,16 @@ pub type QuicListener = QuicServer;
|
||||
|
||||
impl QuicServer {
|
||||
pub async fn bind(config: &ServerConfig) -> Result<Self, TransportError> {
|
||||
Self::bind_with_admission(config, AdmissionController::new(&config.limits)).await
|
||||
}
|
||||
|
||||
pub async fn bind_with_admission(
|
||||
config: &ServerConfig,
|
||||
admission: Arc<AdmissionController>,
|
||||
) -> Result<Self, TransportError> {
|
||||
let addr = resolve_listen(&config.server.listen, config.transports.quic.port)?;
|
||||
// ServerQuicConfig currently has no keepalive setting; keep the
|
||||
// listener's idle-path probe at the documented 15-second default.
|
||||
let tls = server_config(config, 15);
|
||||
let endpoint = Endpoint::server(tls?, addr).map_err(TransportError::Io)?;
|
||||
let mut clients = HashMap::new();
|
||||
@@ -452,6 +502,7 @@ impl QuicServer {
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: DEFAULT_MAX_DATAGRAM,
|
||||
handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)),
|
||||
admission,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -462,12 +513,29 @@ impl QuicServer {
|
||||
clients: HashMap<String, Vec<u8>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
) -> Self {
|
||||
Self::from_endpoint_with_admission(
|
||||
endpoint,
|
||||
clients,
|
||||
max_datagram,
|
||||
handshake_timeout,
|
||||
AdmissionController::new(&crate::config::LimitsConfig::default()),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_endpoint_with_admission(
|
||||
endpoint: Endpoint,
|
||||
clients: HashMap<String, Vec<u8>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
admission: Arc<AdmissionController>,
|
||||
) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: max_datagram.max(1),
|
||||
handshake_timeout,
|
||||
admission,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,8 +547,40 @@ impl QuicServer {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub async fn accept_session(&self) -> Result<QuicDatagramSession, TransportError> {
|
||||
pub async fn accept_pending(&self) -> Result<PendingQuicHandshake, TransportError> {
|
||||
let incoming = self.endpoint.accept().await.ok_or(TransportError::Closed)?;
|
||||
let pending = self
|
||||
.admission
|
||||
.begin(incoming.remote_address())
|
||||
.map_err(admission_error)?;
|
||||
Ok(PendingQuicHandshake {
|
||||
incoming,
|
||||
admission: pending,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn authenticate_pending(
|
||||
&self,
|
||||
pending: PendingQuicHandshake,
|
||||
) -> Result<QuicDatagramSession, TransportError> {
|
||||
timeout(
|
||||
self.handshake_timeout,
|
||||
self.finish_handshake(pending.incoming, pending.admission),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
}
|
||||
|
||||
pub async fn accept_session(&self) -> Result<QuicDatagramSession, TransportError> {
|
||||
let pending = self.accept_pending().await?;
|
||||
self.authenticate_pending(pending).await
|
||||
}
|
||||
|
||||
async fn finish_handshake(
|
||||
&self,
|
||||
incoming: quinn::Incoming,
|
||||
pending: PendingHandshake,
|
||||
) -> Result<QuicDatagramSession, TransportError> {
|
||||
let connection = timeout(self.handshake_timeout, incoming)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
@@ -533,14 +633,17 @@ impl QuicServer {
|
||||
)));
|
||||
}
|
||||
let id_bytes = &response[8..8 + id_len];
|
||||
let id = std::str::from_utf8(id_bytes).map_err(|_| {
|
||||
TransportError::AuthenticationRejected(RejectReason::AuthenticationFailed)
|
||||
})?;
|
||||
let id = match std::str::from_utf8(id_bytes) {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
reject_authentication(&mut send, self.handshake_timeout).await;
|
||||
return 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;
|
||||
reject_authentication(&mut send, self.handshake_timeout).await;
|
||||
return Err(TransportError::AuthenticationRejected(
|
||||
RejectReason::AuthenticationFailed,
|
||||
));
|
||||
@@ -554,8 +657,7 @@ impl QuicServer {
|
||||
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;
|
||||
reject_authentication(&mut send, self.handshake_timeout).await;
|
||||
return Err(TransportError::AuthenticationRejected(
|
||||
RejectReason::AuthenticationFailed,
|
||||
));
|
||||
@@ -564,12 +666,15 @@ impl QuicServer {
|
||||
.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);
|
||||
let admission = pending.promote();
|
||||
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,
|
||||
Some(id.to_owned()),
|
||||
Some(admission),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -598,3 +703,12 @@ async fn send_result(
|
||||
.map_err(quinn_io)?;
|
||||
send.finish().map_err(quinn_io)
|
||||
}
|
||||
|
||||
async fn reject_authentication(send: &mut quinn::SendStream, duration: Duration) {
|
||||
// Wait for the client to consume the rejection (or stop reading) so the
|
||||
// connection is not dropped before the reliable control result is sent.
|
||||
// The wait is bounded for peers that abandon the handshake.
|
||||
if send_result(send, 1, 0, duration).await.is_ok() {
|
||||
let _ = timeout(duration, send.stopped()).await;
|
||||
}
|
||||
}
|
||||
+64
-9
@@ -1,26 +1,38 @@
|
||||
#[path = "../src/control.rs"]
|
||||
mod control;
|
||||
use wgcloak::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 runtime_dir = dir.path().join("runtime");
|
||||
let path = runtime_dir.join("control.sock");
|
||||
let state = control::ControlState::default();
|
||||
state.enable_reconnect();
|
||||
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(&runtime_dir)
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let task = tokio::spawn(server.run());
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
let second_bind = control::ControlServer::bind(&path, control::ControlState::default()).await;
|
||||
assert!(matches!(
|
||||
second_bind,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse
|
||||
));
|
||||
assert!(control::send_command(&path, "status")
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -33,13 +45,56 @@ async fn control_commands_and_socket_permissions() {
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
assert!(control::send_command(&path, "reload")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
let reload = control::send_command(&path, "reload").await.unwrap();
|
||||
assert!(reload.contains("\"ok\":false"));
|
||||
assert!(reload.contains("not supported"));
|
||||
assert!(control::send_command(&path, "shutdown")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
task.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaces_only_a_verified_stale_socket() {
|
||||
let dir = tempdir().unwrap();
|
||||
let private_dir = dir.path().join("runtime");
|
||||
std::fs::create_dir(&private_dir).unwrap();
|
||||
std::fs::set_permissions(&private_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let path = private_dir.join("stale.sock");
|
||||
let stale = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
drop(stale);
|
||||
|
||||
let server = control::ControlServer::bind(&path, control::ControlState::default())
|
||||
.await
|
||||
.expect("verified stale socket should be replaced");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
drop(server);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refuses_to_replace_a_non_socket_path() {
|
||||
let dir = tempdir().unwrap();
|
||||
let private_dir = dir.path().join("runtime");
|
||||
std::fs::create_dir(&private_dir).unwrap();
|
||||
std::fs::set_permissions(&private_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let path = private_dir.join("control.sock");
|
||||
std::fs::write(&path, b"keep this file").unwrap();
|
||||
let result = control::ControlServer::bind(&path, control::ControlState::default()).await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists
|
||||
));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"keep this file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_socket_path_never_targets_the_shared_tmp_socket() {
|
||||
let path = control::default_socket_path();
|
||||
assert_eq!(path.file_name().unwrap(), "wgcloak.sock");
|
||||
assert_ne!(path.parent().unwrap(), std::path::Path::new("/tmp"));
|
||||
}
|
||||
+322
-13
@@ -1,13 +1,4 @@
|
||||
#[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 wgcloak::{config, protocol, transport};
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -16,7 +7,7 @@ use bytes::Bytes;
|
||||
use config::Config;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use transport::{
|
||||
direct_udp::{DirectUdpServer, DirectUdpSession},
|
||||
direct_udp::{DirectUdpServer, DirectUdpServerHandle, DirectUdpSession},
|
||||
TransportError, TransportSession,
|
||||
};
|
||||
|
||||
@@ -51,6 +42,19 @@ fn multi_configs(
|
||||
(server, client("one"), client("two"))
|
||||
}
|
||||
|
||||
async fn wait_for_active_sessions(server: &DirectUdpServerHandle, expected: usize) {
|
||||
timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if server.active_sessions().await == expected {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("active session count did not become {expected}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_round_trip_and_auth_rejection() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -66,6 +70,11 @@ async fn direct_udp_round_trip_and_auth_rejection() {
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
});
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
assert_eq!(
|
||||
session.authenticated_keepalive_interval(),
|
||||
Some(Duration::from_secs(15))
|
||||
);
|
||||
session.send_keepalive().await.unwrap();
|
||||
session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
@@ -99,19 +108,42 @@ async fn direct_udp_enforces_payload_limit_and_expires_idle_session() {
|
||||
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 observer = server.handle();
|
||||
let stats = observer.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
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);
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
session.close().await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_observer_tracks_authenticated_session_close() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, client_config) = configs(45787, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let stats = observer.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 0);
|
||||
|
||||
session.close().await.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 0);
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_clients_use_independent_backend_sockets() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -178,6 +210,14 @@ async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
|
||||
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 oversized = protocol::DataPacket::new(1, 1, vec![0u8; 1201], [0; 16])
|
||||
.encode()
|
||||
.unwrap();
|
||||
raw.send_to(&oversized, "127.0.0.1:45784").await.unwrap();
|
||||
let unexpected = protocol::RejectPacket::new(protocol::RejectReason::AuthenticationFailed)
|
||||
.encode()
|
||||
.unwrap();
|
||||
raw.send_to(&unexpected, "127.0.0.1:45784").await.unwrap();
|
||||
let mut wrong = client_config.clone();
|
||||
wrong.client.secret.secret = Some("B".repeat(64));
|
||||
assert!(matches!(
|
||||
@@ -201,7 +241,8 @@ async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"still-alive")
|
||||
);
|
||||
assert!(stats.snapshot().datagrams_dropped >= 3);
|
||||
assert!(stats.snapshot().datagrams_dropped >= 5);
|
||||
assert!(stats.snapshot().oversized_datagrams >= 1);
|
||||
session.close().await.unwrap();
|
||||
backend_task.await.unwrap();
|
||||
server_task.abort();
|
||||
@@ -275,6 +316,274 @@ async fn authenticated_source_port_change_updates_return_peer() {
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_hello_retransmission_recovers_original_accept_and_session() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, _) = configs(45788, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let client = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let other_source = 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] = 44;
|
||||
let hello = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
let hello_bytes = hello.encode().unwrap();
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let first_accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
other => panic!("unexpected handshake response: {other:?}"),
|
||||
};
|
||||
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let retry_accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
other => panic!("identical HELLO should recover ACCEPT, got {other:?}"),
|
||||
};
|
||||
assert_eq!(retry_accept, first_accept);
|
||||
|
||||
wait_for_active_sessions(&observer, 1).await;
|
||||
other_source
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(
|
||||
Duration::from_secs(2),
|
||||
other_source.recv_from(&mut response),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
|
||||
let incompatible = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
hello.timestamp + 1,
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&incompatible.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
|
||||
let session_key = protocol::derive_session_key(
|
||||
&secret,
|
||||
&nonce,
|
||||
&first_accept.server_nonce,
|
||||
first_accept.session_id,
|
||||
);
|
||||
let (peer_tx, mut peer_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut buffer = [0u8; 2048];
|
||||
for _ in 0..2 {
|
||||
let (len, peer) = backend.recv_from(&mut buffer).await.unwrap();
|
||||
peer_tx.send(peer).unwrap();
|
||||
backend.send_to(&buffer[..len], peer).await.unwrap();
|
||||
}
|
||||
});
|
||||
for (sequence, payload) in [(1, b"one".as_slice()), (2, b"two".as_slice())] {
|
||||
let packet = protocol::DataPacket::authenticated(
|
||||
&session_key,
|
||||
first_accept.session_id,
|
||||
sequence,
|
||||
Bytes::copy_from_slice(payload),
|
||||
);
|
||||
client
|
||||
.send_to(&packet.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Data(_))
|
||||
));
|
||||
}
|
||||
backend_task.await.unwrap();
|
||||
assert_eq!(peer_rx.recv().await, peer_rx.recv().await);
|
||||
assert_eq!(observer.active_sessions().await, 1);
|
||||
|
||||
let close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&session_key,
|
||||
first_accept.session_id,
|
||||
3,
|
||||
);
|
||||
client
|
||||
.send_to(&close.encode().unwrap(), "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
client
|
||||
.send_to(&hello_bytes, "127.0.0.1:45788")
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
protocol::parse_packet(&response[..len]),
|
||||
Ok(protocol::Packet::Reject(protocol::RejectPacket {
|
||||
reason: protocol::RejectReason::ReplayDetected,
|
||||
..
|
||||
}))
|
||||
));
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flags_are_authenticated_and_invalid_close_cannot_remove_session() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, _) = configs(45789, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let observer = server.handle();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let client = 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] = 45;
|
||||
let hello = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.send_to(&hello.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 2048];
|
||||
let (len, _) = timeout(Duration::from_secs(2), client.recv_from(&mut response))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let accept = match protocol::parse_packet(&response[..len]).unwrap() {
|
||||
protocol::Packet::Accept(accept) => accept,
|
||||
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, b"valid".as_slice());
|
||||
let mut mutated_flags = data.encode().unwrap();
|
||||
mutated_flags[2] = 1;
|
||||
client
|
||||
.send_to(&mutated_flags, "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(timeout(
|
||||
Duration::from_millis(100),
|
||||
backend.recv_from(&mut [0u8; 32])
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let invalid_close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&key,
|
||||
accept.session_id,
|
||||
1,
|
||||
);
|
||||
let mut mutated_close_flags = invalid_close.encode().unwrap();
|
||||
mutated_close_flags[2] = 1;
|
||||
client
|
||||
.send_to(&mutated_close_flags, "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert_eq!(observer.active_sessions().await, 1);
|
||||
|
||||
let valid_data = protocol::DataPacket::authenticated(
|
||||
&key,
|
||||
accept.session_id,
|
||||
1,
|
||||
Bytes::from_static(b"still-open"),
|
||||
);
|
||||
client
|
||||
.send_to(&valid_data.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut backend_buffer = [0u8; 64];
|
||||
let (len, _) = timeout(
|
||||
Duration::from_secs(2),
|
||||
backend.recv_from(&mut backend_buffer),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(&backend_buffer[..len], b"still-open");
|
||||
|
||||
let valid_close = protocol::SessionPacket::authenticated(
|
||||
protocol::PacketType::Close,
|
||||
&key,
|
||||
accept.session_id,
|
||||
2,
|
||||
);
|
||||
client
|
||||
.send_to(&valid_close.encode().unwrap(), "127.0.0.1:45789")
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_active_sessions(&observer, 0).await;
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_backend_does_not_terminate_listener() {
|
||||
let (server_config, client_config) = configs(45786, 9);
|
||||
|
||||
+32
-7
@@ -1,12 +1,10 @@
|
||||
#[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,
|
||||
use wgcloak::transport::http3;
|
||||
use wgcloak::transport::http3::{
|
||||
decode_auth_challenge, 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]
|
||||
@@ -78,6 +76,10 @@ fn authentication_is_completed_before_session_activation() {
|
||||
let nonce = [5u8; 16];
|
||||
let secret = [7u8; 32];
|
||||
let challenge_capsule = encode_auth_challenge(&challenge).unwrap();
|
||||
assert_eq!(
|
||||
decode_auth_challenge(&challenge_capsule).unwrap(),
|
||||
challenge
|
||||
);
|
||||
assert_eq!(
|
||||
decode_capsule(&challenge_capsule).unwrap().0,
|
||||
http3::AUTH_CHALLENGE_CAPSULE
|
||||
@@ -94,3 +96,26 @@ fn authentication_is_completed_before_session_activation() {
|
||||
assert!(http3::is_auth_accept(&http3::encode_auth_accept().unwrap()).unwrap());
|
||||
assert!(!http3::is_auth_accept(&http3::encode_auth_reject().unwrap()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datagram_limit_includes_http_context_and_request_stream_overhead() {
|
||||
use h3::quic::StreamId;
|
||||
|
||||
let limit = http3::maximum_application_datagram(
|
||||
1200,
|
||||
StreamId::try_from(0).unwrap(),
|
||||
CONNECT_UDP_CONTEXT_ID,
|
||||
1200,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(limit, 1198);
|
||||
assert!(matches!(
|
||||
http3::maximum_application_datagram(
|
||||
2,
|
||||
StreamId::try_from(0).unwrap(),
|
||||
CONNECT_UDP_CONTEXT_ID,
|
||||
1200
|
||||
),
|
||||
Ok(0)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use std::{collections::HashMap, fs, process::Command, sync::Arc, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
|
||||
use tokio::time::timeout;
|
||||
use wgcloak::config;
|
||||
use wgcloak::transport::{
|
||||
http3::{Http3ClientConnection, Http3Error, Http3Server, Http3ServerConnection},
|
||||
AdmissionController, TransportSession,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const WRONG_SECRET: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
|
||||
fn test_endpoints() -> (
|
||||
tempfile::TempDir,
|
||||
Endpoint,
|
||||
ClientConfig,
|
||||
std::net::SocketAddr,
|
||||
) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let certificate = directory.path().join("localhost-cert.pem");
|
||||
let private_key = directory.path().join("localhost-key.pem");
|
||||
let result = Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
private_key.to_str().unwrap(),
|
||||
"-out",
|
||||
certificate.to_str().unwrap(),
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-addext",
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
"-addext",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
"-days",
|
||||
"1",
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"openssl failed: {}",
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
|
||||
let cert_pem = fs::read(&certificate).unwrap();
|
||||
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let key_pem = fs::read(&private_key).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_slice(&key_pem).unwrap();
|
||||
let mut server_config = ServerConfig::with_single_cert(certs.clone(), key).unwrap();
|
||||
let mut server_transport = quinn::TransportConfig::default();
|
||||
server_transport.datagram_send_buffer_size(1024 * 1024);
|
||||
server_config.transport_config(Arc::new(server_transport));
|
||||
let server = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let server_address = server.local_addr().unwrap();
|
||||
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let mut client_config = ClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
|
||||
let mut client_transport = quinn::TransportConfig::default();
|
||||
client_transport.datagram_send_buffer_size(1024 * 1024);
|
||||
client_config.transport_config(Arc::new(client_transport));
|
||||
(directory, server, client_config, server_address)
|
||||
}
|
||||
|
||||
async fn connect_client(address: std::net::SocketAddr, config: ClientConfig) -> quinn::Connection {
|
||||
let mut endpoint = Endpoint::client("[::]:0".parse().unwrap()).unwrap();
|
||||
endpoint.set_default_client_config(config);
|
||||
endpoint
|
||||
.connect(address, "localhost")
|
||||
.unwrap()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_udp_authenticates_then_exchanges_contextual_http_datagrams() {
|
||||
let (_certificate_dir, server_endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server_endpoint.accept().await.unwrap();
|
||||
let quic_connection = incoming.await.unwrap();
|
||||
Http3ServerConnection::new(quic_connection)
|
||||
.await
|
||||
.unwrap()
|
||||
.accept_connect_udp("/tunnel", &clients, 1200, Duration::from_secs(3))
|
||||
.await
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_session = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_session = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
client_session.max_datagram_size(),
|
||||
server_session.max_datagram_size()
|
||||
);
|
||||
assert!(client_session.max_datagram_size() < 1200);
|
||||
client_session
|
||||
.send_datagram(b"opaque-upstream")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), server_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque-upstream")
|
||||
);
|
||||
server_session
|
||||
.send_datagram(b"opaque-downstream")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), client_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque-downstream")
|
||||
);
|
||||
let too_large = vec![0; client_session.max_datagram_size() + 1];
|
||||
assert!(matches!(
|
||||
client_session.send_datagram(&too_large).await,
|
||||
Err(Http3Error::Oversized { .. })
|
||||
));
|
||||
client_session.close(Duration::from_secs(2)).await.unwrap();
|
||||
assert!(matches!(
|
||||
client_session.send_datagram(b"after-close").await,
|
||||
Err(Http3Error::Closed)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_udp_rejects_bad_secret_before_returning_session() {
|
||||
let (_certificate_dir, server_endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let incoming = server_endpoint.accept().await.unwrap();
|
||||
let quic_connection = incoming.await.unwrap();
|
||||
Http3ServerConnection::new(quic_connection)
|
||||
.await
|
||||
.unwrap()
|
||||
.accept_connect_udp("/tunnel", &clients, 1200, Duration::from_secs(3))
|
||||
.await
|
||||
});
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_result = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(WRONG_SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(client_result, Err(Http3Error::AuthenticationRejected)),
|
||||
"unexpected client error: {:?}",
|
||||
client_result.as_ref().err()
|
||||
);
|
||||
let server_result = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(server_result, Err(Http3Error::AuthenticationRejected)),
|
||||
"unexpected server error: {:?}",
|
||||
server_result.as_ref().err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http3_server_admission_permit_tracks_authenticated_session_lifetime() {
|
||||
let (_certificate_dir, endpoint, client_tls, address) = test_endpoints();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"laptop".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let limits = config::LimitsConfig {
|
||||
max_sessions: 1,
|
||||
max_sessions_per_ip: 1,
|
||||
..config::LimitsConfig::default()
|
||||
};
|
||||
let admission = AdmissionController::new(&limits);
|
||||
let server = Arc::new(Http3Server::from_endpoint_with_admission(
|
||||
endpoint,
|
||||
"/tunnel",
|
||||
clients,
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
admission.clone(),
|
||||
));
|
||||
let accept_server = server.clone();
|
||||
let server_task = tokio::spawn(async move { accept_server.accept_session().await.unwrap() });
|
||||
|
||||
let quic_connection = connect_client(address, client_tls).await;
|
||||
let client = Http3ClientConnection::new(quic_connection).await.unwrap();
|
||||
let client_session = client
|
||||
.connect_udp(
|
||||
&format!("localhost:{}", address.port()),
|
||||
"/tunnel",
|
||||
"laptop",
|
||||
&base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
1200,
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_session = timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert_eq!(admission.pending_handshakes(), 0);
|
||||
server_session.close().await.unwrap();
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
client_session.close(Duration::from_secs(2)).await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::{
|
||||
fs, net::SocketAddr, os::unix::fs::PermissionsExt, path::Path, process::Command, sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
use tempfile::TempDir;
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
sync::mpsc,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use wgcloak::{
|
||||
client,
|
||||
config::{self, Config},
|
||||
control, server, stats, transport,
|
||||
transport::{Http3ClientConnector, TransportConnector, TransportSession},
|
||||
};
|
||||
|
||||
const SECRET_ONE: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
const SECRET_TWO: &str = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
const WRONG_SECRET: &str = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";
|
||||
|
||||
fn free_udp_port() -> u16 {
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn private_runtime_dir(root: &Path) -> std::path::PathBuf {
|
||||
let path = root.join("runtime");
|
||||
fs::create_dir(&path).unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn server_config(
|
||||
directory: &TempDir,
|
||||
backend: SocketAddr,
|
||||
direct_port: u16,
|
||||
http3_port: u16,
|
||||
) -> config::ServerConfig {
|
||||
let certificate = directory.path().join("server-cert.pem");
|
||||
let private_key = directory.path().join("server-key.pem");
|
||||
let generated = Command::new("openssl")
|
||||
.args(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout"])
|
||||
.arg(&private_key)
|
||||
.args(["-out"])
|
||||
.arg(&certificate)
|
||||
.args([
|
||||
"-subj",
|
||||
"/CN=127.0.0.1",
|
||||
"-addext",
|
||||
"subjectAltName=IP:127.0.0.1",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-addext",
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
"-addext",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
"-days",
|
||||
"1",
|
||||
])
|
||||
.output()
|
||||
.expect("openssl is required for the HTTP/3 runtime integration test");
|
||||
assert!(
|
||||
generated.status.success(),
|
||||
"openssl certificate generation failed: {}",
|
||||
String::from_utf8_lossy(&generated.stderr)
|
||||
);
|
||||
|
||||
Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='{backend}'\n[transports.direct_udp]\nenabled=true\nport={direct_port}\n[transports.quic]\nenabled=false\nport={}\n[transports.http3]\nenabled=true\nport={http3_port}\npath='/tunnel'\n[transports.tls_tcp]\nenabled=true\nport={direct_port}\n[tls]\ncertificate='{}'\nprivate_key='{}'\n[[auth.clients]]\nid='one'\nsecret='{SECRET_ONE}'\n[[auth.clients]]\nid='two'\nsecret='{SECRET_TWO}'\n[limits]\nhandshake_timeout_ms=3000\n",
|
||||
free_udp_port(),
|
||||
certificate.display(),
|
||||
private_key.display()
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn client_config(
|
||||
id: &str,
|
||||
secret: &str,
|
||||
http3_port: u16,
|
||||
direct_probe_port: u16,
|
||||
local_port: u16,
|
||||
) -> config::ClientConfig {
|
||||
Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='{id}'\nsecret='{secret}'\n[local]\nlisten='127.0.0.1:{local_port}'\n[transport]\nmode='auto'\n[transport.auto]\norder=['direct_udp','http3']\nprobe_timeout_ms=700\nretries=1\n[transport.direct_udp]\nport={direct_probe_port}\n[transport.http3]\nport={http3_port}\npath='/tunnel'\n[network]\nmax_datagram_size=1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_client_tls(certificate: &[u8]) -> quinn::ClientConfig {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(certificate)
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let mut tls = rustls::ClientConfig::builder_with_provider(Arc::new(
|
||||
rustls::crypto::ring::default_provider(),
|
||||
))
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
tls.alpn_protocols = vec![b"h3".to_vec()];
|
||||
let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).unwrap();
|
||||
quinn::ClientConfig::new(Arc::new(crypto))
|
||||
}
|
||||
|
||||
async fn wait_control_contains(path: &std::path::Path, command: &str, needle: &str) {
|
||||
let mut last_response = None;
|
||||
let result = timeout(Duration::from_secs(6), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, command).await {
|
||||
if response.contains(needle) {
|
||||
return;
|
||||
}
|
||||
last_response = Some(response);
|
||||
}
|
||||
sleep(Duration::from_millis(40)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"control {command} did not contain {needle:?}; latest response: {last_response:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_http3_runtime_relays_isolated_clients_and_rejects_bad_secret() {
|
||||
if Command::new("openssl").arg("version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let runtime_dir = private_runtime_dir(directory.path());
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (peer_tx, mut peer_rx) = mpsc::unbounded_channel();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut packet = vec![0u8; u16::MAX as usize];
|
||||
while let Ok((len, peer)) = backend.recv_from(&mut packet).await {
|
||||
let _ = peer_tx.send(peer);
|
||||
let _ = backend.send_to(&packet[..len], peer).await;
|
||||
}
|
||||
});
|
||||
|
||||
let certificate = directory.path().join("server-cert.pem");
|
||||
let direct_server_port = free_udp_port();
|
||||
let http3_port = free_udp_port();
|
||||
let server = server_config(&directory, backend_addr, direct_server_port, http3_port);
|
||||
let server_control = runtime_dir.join("server.sock");
|
||||
let server_control_for_task = server_control.clone();
|
||||
let server_task = tokio::spawn(server::run_with_control_path(
|
||||
server,
|
||||
server_control_for_task,
|
||||
));
|
||||
wait_control_contains(
|
||||
&server_control,
|
||||
"status",
|
||||
"transports=direct_udp,http3,tls_tcp",
|
||||
)
|
||||
.await;
|
||||
|
||||
let certificate_bytes = fs::read(certificate).unwrap();
|
||||
let client_tls = test_client_tls(&certificate_bytes);
|
||||
let direct_probe_port = free_udp_port();
|
||||
assert_ne!(direct_probe_port, direct_server_port);
|
||||
|
||||
let local_one = free_udp_port();
|
||||
let local_two = free_udp_port();
|
||||
let app_one = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let app_two = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let client_one_config =
|
||||
client_config("one", SECRET_ONE, http3_port, direct_probe_port, local_one);
|
||||
let client_two_config =
|
||||
client_config("two", SECRET_TWO, http3_port, direct_probe_port, local_two);
|
||||
let client_one_control = runtime_dir.join("client-one.sock");
|
||||
let client_two_control = runtime_dir.join("client-two.sock");
|
||||
let client_one_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_one_config.clone(),
|
||||
client_one_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
client_one_config,
|
||||
client_tls.clone(),
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
let client_two_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_two_config.clone(),
|
||||
client_two_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
client_two_config,
|
||||
client_tls.clone(),
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
|
||||
wait_control_contains(&server_control, "status", "active_sessions=2").await;
|
||||
app_one
|
||||
.send_to(b"client-one", ("127.0.0.1", local_one))
|
||||
.await
|
||||
.unwrap();
|
||||
app_two
|
||||
.send_to(b"client-two", ("127.0.0.1", local_two))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut response = [0u8; 64];
|
||||
let (len_one, _) = timeout(Duration::from_secs(4), app_one.recv_from(&mut response))
|
||||
.await
|
||||
.expect("first HTTP/3 echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len_one], b"client-one");
|
||||
let (len_two, _) = timeout(Duration::from_secs(4), app_two.recv_from(&mut response))
|
||||
.await
|
||||
.expect("second HTTP/3 echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len_two], b"client-two");
|
||||
let peer_one = timeout(Duration::from_secs(2), peer_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let peer_two = timeout(Duration::from_secs(2), peer_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
peer_one, peer_two,
|
||||
"each tunnel must own its backend UDP socket"
|
||||
);
|
||||
|
||||
let bad_local = free_udp_port();
|
||||
let bad_client_config = client_config(
|
||||
"one",
|
||||
WRONG_SECRET,
|
||||
http3_port,
|
||||
direct_probe_port,
|
||||
bad_local,
|
||||
);
|
||||
let bad_control = runtime_dir.join("client-bad.sock");
|
||||
let bad_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
bad_client_config.clone(),
|
||||
bad_control.clone(),
|
||||
Some(Arc::new(Http3ClientConnector::with_client_config(
|
||||
bad_client_config,
|
||||
client_tls,
|
||||
)) as Arc<dyn TransportConnector>),
|
||||
));
|
||||
let bad_result = timeout(Duration::from_secs(6), bad_task)
|
||||
.await
|
||||
.expect("bad-secret client should finish its rejected startup")
|
||||
.unwrap();
|
||||
assert!(bad_result.is_err(), "wrong project secret must be rejected");
|
||||
wait_control_contains(&server_control, "stats", "\"authentication_rejections\":1").await;
|
||||
let aggregate = control::send_command(&server_control, "stats")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(aggregate.contains("\"transport\":\"multi\""));
|
||||
assert!(aggregate.contains("\"direct_udp\":{"));
|
||||
assert!(aggregate.contains("\"http3\":{"));
|
||||
assert!(aggregate.contains("\"tls_tcp\":{"));
|
||||
|
||||
for path in [&client_one_control, &client_two_control] {
|
||||
control::send_command(path, "shutdown").await.unwrap();
|
||||
}
|
||||
timeout(Duration::from_secs(4), client_one_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(4), client_two_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(5), server_task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
backend_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conflicting_udp_listeners_are_rejected() {
|
||||
let parsed = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nenabled=true\nport=54001\n[transports.quic]\nenabled=true\nport=54001\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=true\nport=54001\n[tls]\ncertificate='placeholder-cert'\nprivate_key='placeholder-key'\n[[auth.clients]]\nid='one'\nsecret='{SECRET_ONE}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let runtime_dir = private_runtime_dir(directory.path());
|
||||
// Validation happens before binding any listener and reports only the
|
||||
// incompatible same-protocol UDP bind; TCP is allowed to reuse the number.
|
||||
let result = server::run_with_control_path(parsed, runtime_dir.join("conflict.sock")).await;
|
||||
assert!(result.unwrap_err().to_string().contains("UDP listeners"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_stats_and_status_merge_multiple_transport_snapshots() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = private_runtime_dir(directory.path()).join("aggregate.sock");
|
||||
let state = control::ControlState::default();
|
||||
state
|
||||
.set_status("listening; transports=direct_udp,http3")
|
||||
.await;
|
||||
let direct = stats::StatsSnapshot {
|
||||
datagrams_sent: 2,
|
||||
bytes_sent: 40,
|
||||
..stats::StatsSnapshot::default()
|
||||
};
|
||||
let http3 = stats::StatsSnapshot {
|
||||
datagrams_received: 3,
|
||||
authentication_rejections: 1,
|
||||
..stats::StatsSnapshot::default()
|
||||
};
|
||||
state.set_transport_stats("direct_udp", 1, direct).await;
|
||||
state.set_transport_stats("http3", 2, http3).await;
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
let status = control::send_command(&path, "status").await.unwrap();
|
||||
assert!(status.contains("active_sessions=3"));
|
||||
assert!(status.contains("active_sessions_by_transport=direct_udp:1,http3:2"));
|
||||
let response = control::send_command(&path, "stats").await.unwrap();
|
||||
assert!(response.contains("\"datagrams_sent\":2"));
|
||||
assert!(response.contains("\"bytes_sent\":40"));
|
||||
assert!(response.contains("\"datagrams_received\":3"));
|
||||
assert!(response.contains("\"authentication_rejections\":1"));
|
||||
assert!(response.contains("\"direct_udp\":{"));
|
||||
assert!(response.contains("\"http3\":{"));
|
||||
|
||||
state.shutdown.cancel();
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_runtime_cancellation_runs_session_cleanup() {
|
||||
let port = free_udp_port();
|
||||
let server = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nenabled=true\nport={port}\n[transports.quic]\nenabled=false\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=false\n[[auth.clients]]\nid='lifecycle'\nsecret='{SECRET_ONE}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let client = Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='lifecycle'\nsecret='{SECRET_ONE}'\n[transport]\nmode='direct_udp'\n[transport.direct_udp]\nport={port}\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
|
||||
let listener = transport::DirectUdpServer::bind(server).await.unwrap();
|
||||
let observer = listener.handle();
|
||||
let shutdown = tokio_util::sync::CancellationToken::new();
|
||||
let run = tokio::spawn(listener.run_with_shutdown(shutdown.clone()));
|
||||
let session = timeout(
|
||||
Duration::from_secs(3),
|
||||
transport::direct_udp::DirectUdpSession::connect(&client),
|
||||
)
|
||||
.await
|
||||
.expect("direct UDP client authentication timed out")
|
||||
.unwrap();
|
||||
timeout(Duration::from_secs(2), async {
|
||||
while observer.active_sessions().await != 1 {
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("server did not register the authenticated direct session");
|
||||
|
||||
shutdown.cancel();
|
||||
timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("direct listener did not complete graceful cleanup")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(observer.active_sessions().await, 0);
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
#[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};
|
||||
use wgcloak::{
|
||||
config::Config,
|
||||
protocol, transport,
|
||||
transport::{
|
||||
AdmissionController, TlsTcpConnector, TlsTcpServer, TransportConnector, TransportError,
|
||||
},
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
@@ -29,7 +23,7 @@ fn free_port() -> u16 {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
async fn tls_tcp_authenticates_round_trips_and_classifies_failures() {
|
||||
if Command::new("openssl").arg("version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -45,6 +39,10 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost",
|
||||
"-addext",
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"-days",
|
||||
"1",
|
||||
"-keyout",
|
||||
@@ -61,7 +59,7 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
);
|
||||
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",
|
||||
"[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[limits]\nhandshake_timeout_ms = 500\n[tls]\ncertificate = \"{}\"\nprivate_key = \"{}\"\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n",
|
||||
cert.display(),
|
||||
key.display()
|
||||
))
|
||||
@@ -69,23 +67,47 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
.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"
|
||||
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport]\nmode = \"tls_tcp\"\n[transport.tls_tcp]\nport = {port}\nkeepalive_secs = 1\n[network]\nmax_datagram_size = 1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
let mismatch_config = client_config.clone();
|
||||
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 admission = AdmissionController::new(&server_config.limits);
|
||||
let server = Arc::new(
|
||||
TlsTcpServer::bind_with_admission(&server_config, admission.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let slow_server = server.clone();
|
||||
let slow_handshake = tokio::spawn(async move { slow_server.accept_session().await });
|
||||
let slow_peer = tokio::net::TcpStream::connect(server.local_addr().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
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 = TlsTcpConnector::with_client_config(client_config, client_tls.clone());
|
||||
let client_session = client.connect().await.unwrap();
|
||||
let server_session = accept.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
client_session.authenticated_keepalive_interval(),
|
||||
Some(Duration::from_secs(1))
|
||||
);
|
||||
assert_eq!(client_session.rtt(), None);
|
||||
assert_eq!(
|
||||
client_session.remote_addr().unwrap().ip(),
|
||||
"127.0.0.1".parse::<std::net::IpAddr>().unwrap()
|
||||
);
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert_eq!(admission.pending_handshakes(), 1);
|
||||
client_session.send_keepalive().await.unwrap();
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"tls-opaque"))
|
||||
.await
|
||||
@@ -99,4 +121,59 @@ async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
);
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(1), slow_handshake)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
));
|
||||
drop(slow_peer);
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
assert_eq!(admission.pending_handshakes(), 0);
|
||||
|
||||
let mut wrong_credentials = mismatch_config.clone();
|
||||
wrong_credentials.client.secret.secret =
|
||||
Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned());
|
||||
let server_accept = {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move { server.accept_session().await })
|
||||
};
|
||||
let wrong_client = TlsTcpConnector::with_client_config(wrong_credentials, client_tls.clone());
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), wrong_client.connect())
|
||||
.await
|
||||
.unwrap(),
|
||||
Err(TransportError::AuthenticationRejected(
|
||||
protocol::RejectReason::AuthenticationFailed
|
||||
))
|
||||
));
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), server_accept)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Err(TransportError::AuthenticationRejected(
|
||||
protocol::RejectReason::AuthenticationFailed
|
||||
))
|
||||
));
|
||||
|
||||
let server_accept = {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move { server.accept_session().await })
|
||||
};
|
||||
let mut mismatch_config = mismatch_config;
|
||||
mismatch_config.client.server = "127.0.0.1".to_owned();
|
||||
let mismatch_client = TlsTcpConnector::with_client_config(mismatch_config, client_tls);
|
||||
assert!(matches!(
|
||||
timeout(Duration::from_secs(2), mismatch_client.connect())
|
||||
.await
|
||||
.unwrap(),
|
||||
Err(TransportError::Io(_))
|
||||
));
|
||||
assert!(timeout(Duration::from_secs(2), server_accept)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.is_err());
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
use std::{net::SocketAddr, sync::Arc, thread, time::Duration};
|
||||
|
||||
use wgcloak::{
|
||||
config::{self, LimitsConfig},
|
||||
transport::{AdmissionController, AdmissionError},
|
||||
};
|
||||
|
||||
fn limits(
|
||||
max_sessions: usize,
|
||||
max_sessions_per_ip: usize,
|
||||
max_pending_handshakes: usize,
|
||||
max_auth_attempts_per_ip: usize,
|
||||
auth_attempt_window_secs: u64,
|
||||
) -> LimitsConfig {
|
||||
LimitsConfig {
|
||||
max_sessions,
|
||||
max_sessions_per_ip,
|
||||
max_pending_handshakes,
|
||||
max_auth_attempts_per_ip,
|
||||
auth_attempt_window_secs,
|
||||
handshake_timeout_ms: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn address(ip: &str) -> SocketAddr {
|
||||
format!("{ip}:40000").parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_limit_and_failed_handshake_release_reservation() {
|
||||
let controller = AdmissionController::new(&limits(8, 4, 1, 100, 60));
|
||||
let first = controller.begin(address("192.0.2.1")).unwrap();
|
||||
assert_eq!(controller.pending_handshakes(), 1);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.2")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
// Dropping the pending guard models any failed or timed-out handshake.
|
||||
drop(first);
|
||||
assert_eq!(controller.pending_handshakes(), 0);
|
||||
let second = controller.begin(address("192.0.2.2")).unwrap();
|
||||
drop(second);
|
||||
assert_eq!(controller.pending_handshakes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_and_per_ip_limits_release_on_close_or_drop() {
|
||||
let controller = AdmissionController::new(&limits(2, 1, 2, 100, 60));
|
||||
let first = controller.begin(address("192.0.2.1")).unwrap().promote();
|
||||
assert_eq!(controller.active_sessions(), 1);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.1")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
let second = controller.begin(address("192.0.2.2")).unwrap().promote();
|
||||
assert_eq!(controller.active_sessions(), 2);
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.3")),
|
||||
Err(AdmissionError::Busy)
|
||||
));
|
||||
|
||||
drop(first);
|
||||
assert_eq!(controller.active_sessions(), 1);
|
||||
drop(second);
|
||||
assert_eq!(controller.active_sessions(), 0);
|
||||
assert!(controller.begin(address("192.0.2.3")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_admissions_never_exceed_global_or_per_ip_capacity() {
|
||||
let controller = AdmissionController::new(&limits(3, 3, 32, 100, 60));
|
||||
let barrier = Arc::new(std::sync::Barrier::new(33));
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..32 {
|
||||
let controller = controller.clone();
|
||||
let barrier = barrier.clone();
|
||||
let sender = sender.clone();
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
let permit = controller
|
||||
.begin(address("192.0.2.10"))
|
||||
.ok()
|
||||
.map(|pending| pending.promote());
|
||||
barrier.wait();
|
||||
sender.send(permit).unwrap();
|
||||
}));
|
||||
}
|
||||
barrier.wait();
|
||||
barrier.wait();
|
||||
drop(sender);
|
||||
let permits: Vec<_> = receiver.into_iter().flatten().collect();
|
||||
for worker in workers {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
assert_eq!(permits.len(), 3);
|
||||
assert_eq!(controller.active_sessions(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_attempt_limit_is_per_ip_and_expires() {
|
||||
let controller = AdmissionController::new(&limits(8, 4, 8, 2, 1));
|
||||
for _ in 0..2 {
|
||||
drop(controller.begin(address("192.0.2.1")).unwrap());
|
||||
}
|
||||
assert!(matches!(
|
||||
controller.begin(address("192.0.2.1")),
|
||||
Err(AdmissionError::RateLimited)
|
||||
));
|
||||
assert!(controller.begin(address("192.0.2.2")).is_ok());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(1_050)).await;
|
||||
assert!(controller.begin(address("192.0.2.1")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_limits_have_safe_defaults_and_reject_out_of_bounds_values() {
|
||||
let defaults = LimitsConfig::default();
|
||||
assert_eq!(defaults.max_sessions, 1024);
|
||||
assert_eq!(defaults.max_sessions_per_ip, 32);
|
||||
assert_eq!(defaults.max_pending_handshakes, 256);
|
||||
assert_eq!(defaults.max_auth_attempts_per_ip, 20);
|
||||
assert_eq!(defaults.auth_attempt_window_secs, 60);
|
||||
assert_eq!(defaults.handshake_timeout_ms, 3_000);
|
||||
|
||||
let valid = "[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='one'\nsecret='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\n";
|
||||
assert!(config::Config::from_str(valid).unwrap().validate().is_ok());
|
||||
|
||||
let invalid = format!("{valid}\n[limits]\nmax_auth_attempts_per_ip=0\n");
|
||||
assert!(config::Config::from_str(&invalid)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err());
|
||||
let too_long = format!("{valid}\n[limits]\nhandshake_timeout_ms=300001\n");
|
||||
assert!(config::Config::from_str(&too_long)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err());
|
||||
}
|
||||
+32
-16
@@ -1,14 +1,3 @@
|
||||
#[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;
|
||||
@@ -16,9 +5,12 @@ 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,
|
||||
use wgcloak::{
|
||||
config,
|
||||
transport::{
|
||||
quic::{QuicClientConnector, QuicServer},
|
||||
AdmissionController, TransportError, TransportSession,
|
||||
},
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
@@ -71,12 +63,34 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
|
||||
let port = server.local_addr().unwrap().port();
|
||||
let admission = AdmissionController::new(&config::LimitsConfig::default());
|
||||
let server = QuicServer::from_endpoint_with_admission(
|
||||
endpoint,
|
||||
clients,
|
||||
1200,
|
||||
Duration::from_secs(2),
|
||||
admission.clone(),
|
||||
);
|
||||
let server_addr = server.local_addr().unwrap();
|
||||
let port = server_addr.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();
|
||||
let remote_addr = client_session.remote_addr().unwrap();
|
||||
assert_eq!(remote_addr.port(), server_addr.port());
|
||||
assert!(match remote_addr.ip() {
|
||||
std::net::IpAddr::V4(address) => address.is_loopback(),
|
||||
std::net::IpAddr::V6(address) => {
|
||||
address.is_loopback()
|
||||
|| address
|
||||
.to_ipv4_mapped()
|
||||
.map(|mapped| mapped.is_loopback())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
});
|
||||
assert!(client_session.rtt().is_some());
|
||||
assert_eq!(client_session.authenticated_keepalive_interval(), None);
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
@@ -88,6 +102,7 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque")
|
||||
);
|
||||
assert_eq!(admission.active_sessions(), 1);
|
||||
assert!(matches!(
|
||||
client_session
|
||||
.send_datagram(Bytes::from(vec![0; 1201]))
|
||||
@@ -96,6 +111,7 @@ async fn authenticated_datagram_round_trip_and_limit() {
|
||||
));
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
assert_eq!(admission.active_sessions(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use tempfile::tempdir;
|
||||
use tokio::{
|
||||
net::UdpSocket,
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use wgcloak::{
|
||||
client,
|
||||
config::{self, Config},
|
||||
control, server,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn free_udp_port() -> u16 {
|
||||
let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket.local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
fn server_config(port: u16, backend: std::net::SocketAddr) -> config::ServerConfig {
|
||||
Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='{backend}'\n[transports.direct_udp]\nenabled=true\nport={port}\n[transports.quic]\nenabled=false\n[transports.http3]\nenabled=false\n[transports.tls_tcp]\nenabled=false\n[[auth.clients]]\nid='reconnect-test'\nsecret='{SECRET}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn client_config(port: u16, local_port: u16) -> config::ClientConfig {
|
||||
Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='reconnect-test'\nsecret='{SECRET}'\n[local]\nlisten='127.0.0.1:{local_port}'\n[transport]\nmode='direct_udp'\n[transport.direct_udp]\nport={port}\n[transport.auto]\nprobe_timeout_ms=150\nretries=1\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_contains(path: &Path, command: &str, needle: &str) -> String {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, command).await {
|
||||
if response.contains(needle) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("control {command} did not contain {needle:?}"))
|
||||
}
|
||||
|
||||
async fn wait_for_reconnected_stats(path: &Path) -> String {
|
||||
timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, "stats").await {
|
||||
if response.contains("\"state\":\"connected\"")
|
||||
&& response.contains("\"reconnections\":1")
|
||||
{
|
||||
return response;
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("client did not publish its connected reconnect state")
|
||||
}
|
||||
|
||||
async fn wait_for_echo(app: &UdpSocket, local_port: u16, payload: &[u8]) {
|
||||
let mut response = [0u8; 128];
|
||||
app.send_to(payload, ("127.0.0.1", local_port))
|
||||
.await
|
||||
.unwrap();
|
||||
let (len, _) = timeout(Duration::from_secs(5), app.recv_from(&mut response))
|
||||
.await
|
||||
.expect("application echo timed out")
|
||||
.unwrap();
|
||||
assert_eq!(&response[..len], payload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_recovers_through_backoff_after_server_restart() {
|
||||
let directory = tempdir().unwrap();
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut packet = vec![0u8; 2048];
|
||||
while let Ok((len, peer)) = backend.recv_from(&mut packet).await {
|
||||
let _ = backend.send_to(&packet[..len], peer).await;
|
||||
}
|
||||
});
|
||||
|
||||
let port = free_udp_port();
|
||||
let local_port = free_udp_port();
|
||||
let runtime_dir = directory.path().join("runtime");
|
||||
let server_control = runtime_dir.join("server.sock");
|
||||
let client_control = runtime_dir.join("client.sock");
|
||||
let server_task = tokio::spawn(server::run_with_control_path(
|
||||
server_config(port, backend_addr),
|
||||
server_control.clone(),
|
||||
));
|
||||
wait_for_contains(&server_control, "status", "transports=direct_udp").await;
|
||||
|
||||
let client_task = tokio::spawn(client::run_with_http3_connector_and_control_path(
|
||||
client_config(port, local_port),
|
||||
client_control.clone(),
|
||||
None,
|
||||
));
|
||||
wait_for_contains(&client_control, "status", "connected; server=127.0.0.1").await;
|
||||
let app = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
wait_for_echo(&app, local_port, b"before-restart").await;
|
||||
|
||||
let stopped = control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), server_task)
|
||||
.await
|
||||
.expect("first server instance should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let requested = control::send_command(&client_control, "reconnect")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(requested.contains("\"ok\":true"));
|
||||
// Wait until the client has made a failed connection attempt and entered
|
||||
// jittered backoff before starting the replacement server. This exercises
|
||||
// the daemon loop without relying on a guessed sleep duration.
|
||||
wait_for_contains(&client_control, "status", "error=").await;
|
||||
|
||||
let replacement = tokio::spawn(server::run_with_control_path(
|
||||
server_config(port, backend_addr),
|
||||
server_control.clone(),
|
||||
));
|
||||
wait_for_contains(&server_control, "status", "transports=direct_udp").await;
|
||||
let _stats = wait_for_reconnected_stats(&client_control).await;
|
||||
wait_for_echo(&app, local_port, b"after-restart").await;
|
||||
|
||||
let stopped = control::send_command(&server_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), replacement)
|
||||
.await
|
||||
.expect("replacement server should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stopped = control::send_command(&client_control, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stopped.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(5), client_task)
|
||||
.await
|
||||
.expect("client should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
backend_task.abort();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::{os::unix::fs::PermissionsExt, path::Path};
|
||||
use tempfile::tempdir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use wgcloak::{
|
||||
config::Config,
|
||||
control, server,
|
||||
transport::{direct_udp::DirectUdpSession, TransportSession},
|
||||
};
|
||||
|
||||
const DIRECT_SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn private_runtime_dir(root: &Path) -> std::path::PathBuf {
|
||||
let path = root.join("runtime");
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_commands_drive_only_supported_runtime_actions() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = private_runtime_dir(dir.path()).join("runtime.sock");
|
||||
let state = control::ControlState::default();
|
||||
state.enable_reconnect();
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
state.set_status("connected; transport=direct_udp").await;
|
||||
state
|
||||
.set_stats_json("{\"datagrams_sent\":7,\"datagrams_dropped\":1}")
|
||||
.await;
|
||||
let status = control::send_command(&path, "status").await.unwrap();
|
||||
assert!(status.contains("connected; transport=direct_udp"));
|
||||
let stats = control::send_command(&path, "stats").await.unwrap();
|
||||
assert!(stats.contains("\"datagrams_sent\":7"));
|
||||
|
||||
let reconnect = state.reconnect.notified();
|
||||
tokio::pin!(reconnect);
|
||||
let response = control::send_command(&path, "reconnect").await.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(1), &mut reconnect)
|
||||
.await
|
||||
.expect("reconnect should reach the running client loop");
|
||||
|
||||
let response = control::send_command(&path, "reload").await.unwrap();
|
||||
assert!(response.contains("\"ok\":false"));
|
||||
assert!(response.contains("not supported"));
|
||||
|
||||
let response = control::send_command(&path, "shutdown").await.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("control server should stop on shutdown")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_control_rejects_client_only_reconnect() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = private_runtime_dir(dir.path()).join("server.sock");
|
||||
let state = control::ControlState::default();
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let task = tokio::spawn(server.run());
|
||||
|
||||
let response = control::send_command(&path, "reconnect").await.unwrap();
|
||||
assert!(response.contains("\"ok\":false"));
|
||||
assert!(response.contains("not supported"));
|
||||
|
||||
state.shutdown.cancel();
|
||||
timeout(Duration::from_secs(1), task)
|
||||
.await
|
||||
.expect("control server should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_server_control_stats_track_authenticated_session_lifecycle() {
|
||||
let probe = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let config = Config::from_str(&format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:9'\n[transports.direct_udp]\nport={port}\n[[auth.clients]]\nid='runtime-test'\nsecret='{DIRECT_SECRET}'\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let client_config = Config::from_str(&format!(
|
||||
"[client]\nserver='127.0.0.1'\nid='runtime-test'\nsecret='{DIRECT_SECRET}'\n[transport.direct_udp]\nport={port}\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let control_path = private_runtime_dir(dir.path()).join("direct-runtime.sock");
|
||||
let server_task = tokio::spawn(server::run_with_control_path(config, control_path.clone()));
|
||||
|
||||
let session = timeout(
|
||||
Duration::from_secs(3),
|
||||
DirectUdpSession::connect(&client_config),
|
||||
)
|
||||
.await
|
||||
.expect("direct handshake should complete")
|
||||
.unwrap();
|
||||
|
||||
wait_for_active_count(&control_path, 1).await;
|
||||
let status = control::send_command(&control_path, "status")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(status.contains("active_sessions=1"));
|
||||
session.close().await.unwrap();
|
||||
wait_for_active_count(&control_path, 0).await;
|
||||
|
||||
let response = control::send_command(&control_path, "shutdown")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(response.contains("\"ok\":true"));
|
||||
timeout(Duration::from_secs(3), server_task)
|
||||
.await
|
||||
.expect("server runtime should stop")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn wait_for_active_count(path: &std::path::Path, expected: usize) {
|
||||
timeout(Duration::from_secs(4), async {
|
||||
loop {
|
||||
if let Ok(response) = control::send_command(path, "stats").await {
|
||||
if response.contains(&format!("\"active_sessions\":{expected}")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("server control stats did not report {expected} active sessions"));
|
||||
}
|
||||
+6
-16
@@ -1,18 +1,4 @@
|
||||
#[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},
|
||||
@@ -20,9 +6,13 @@ use std::{
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use supervisor::{ProbeFuture, TransportProbe, TransportSupervisor};
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use transport::{TransportError, TransportFuture, TransportSession};
|
||||
use wgcloak::{
|
||||
client::supervisor::{self as supervisor, ProbeFuture, TransportProbe, TransportSupervisor},
|
||||
config::{self, Config, TransportMode},
|
||||
protocol,
|
||||
transport::{TransportError, TransportFuture, TransportSession},
|
||||
};
|
||||
|
||||
struct Session;
|
||||
impl TransportSession for Session {
|
||||
|
||||
+3
-5
@@ -1,13 +1,11 @@
|
||||
#[path = "../src/transport/tls_tcp.rs"]
|
||||
mod tls_tcp;
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use tls_tcp::{
|
||||
use tokio::io::{duplex, split};
|
||||
use wgcloak::transport::tls_tcp;
|
||||
use wgcloak::transport::tls_tcp::{
|
||||
client_authenticate, server_authenticate, AuthChallenge, FrameCodec, FrameError, FramedReader,
|
||||
FramedWriter,
|
||||
};
|
||||
use tokio::io::{duplex, split};
|
||||
|
||||
#[test]
|
||||
fn codec_handles_split_and_coalesced_frames() {
|
||||
|
||||
Reference in new issue
Block a user