checkpoint: implement wgcloak v1 foundations
This commit is contained in:
commit
313ab00765
45 files changed
+12058
No files matched your search
+23
@@ -0,0 +1,23 @@
|
||||
/target/
|
||||
**/*.secret
|
||||
*.secret
|
||||
*.key
|
||||
*.pem
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Local runtime state and sockets
|
||||
*.sock
|
||||
run/
|
||||
|
||||
# Editor and OS files
|
||||
.DS_Store
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Coverage and local benchmark output
|
||||
*.profraw
|
||||
*.profdata
|
||||
coverage/
|
||||
bench-results/
|
||||
Generated
+1808
File diff suppressed because it is too large.
Load diff
+37
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "wgcloak"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Protocol-agnostic authenticated UDP transport layer"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
bytes = "1.7"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = { version = "0.0.10", features = ["datagram"] }
|
||||
hkdf = "0.12"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
quinn = "0.11"
|
||||
rand = "0.8"
|
||||
rustls = "0.23"
|
||||
secrecy = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.5"
|
||||
subtle = "2.6"
|
||||
thiserror = "1.0"
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "rt"] }
|
||||
tokio-rustls = "0.26"
|
||||
rustls-native-certs = "0.8"
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
zeroize = "1.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 wgcloak contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,98 @@
|
||||
# wgcloak
|
||||
|
||||
wgcloak is a Linux userspace relay for opaque UDP datagrams. It is intended to
|
||||
carry WireGuard traffic, but does not configure, inspect, or depend on
|
||||
WireGuard: the same relay can carry any UDP application.
|
||||
|
||||
The design keeps four concerns separate:
|
||||
|
||||
```text
|
||||
local UDP application -> tunnel core -> selected transport -> tunnel core
|
||||
-> backend UDP application
|
||||
```
|
||||
|
||||
The server gives each authenticated client its own backend UDP socket. The
|
||||
application payload is not decoded or rewritten.
|
||||
|
||||
## Status
|
||||
|
||||
This checkout is specification-first and under active implementation. The
|
||||
milestone and v1 acceptance checklist is in
|
||||
[`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md). A
|
||||
transport is available only when its code, tests, and integration criteria are
|
||||
verified; the documentation intentionally does not imply that all four
|
||||
transports are already present.
|
||||
|
||||
Target v1 transport order:
|
||||
|
||||
1. direct UDP (Milestone 1)
|
||||
2. QUIC DATAGRAM (Milestone 2)
|
||||
3. HTTP/3 CONNECT-UDP (Milestone 4)
|
||||
4. TLS/TCP framed fallback (Milestone 5)
|
||||
|
||||
## Build and install
|
||||
|
||||
Rust and Cargo are required. From the repository root:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
For a host installation (choose a reviewed release artifact):
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/wgcloak /usr/local/bin/wgcloak
|
||||
sudo install -d -m0750 /etc/wgcloak
|
||||
```
|
||||
|
||||
The binary name and subcommands are the intended interface from `spec.md`:
|
||||
|
||||
```bash
|
||||
wgcloak client --config /etc/wgcloak/client.toml
|
||||
wgcloak server --config /etc/wgcloak/server.toml
|
||||
wgcloak check-config --config /etc/wgcloak/client.toml
|
||||
wgcloak generate-secret
|
||||
```
|
||||
|
||||
Run `wgcloak --help` against the built binary to confirm the commands in the
|
||||
current build. QUIC, HTTP/3, control commands, and certificate reload are
|
||||
milestone-gated; see the checklist before deploying them.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy and edit one of the safe, placeholder-only examples:
|
||||
|
||||
- [`examples/client.minimal.toml`](examples/client.minimal.toml)
|
||||
- [`examples/client.advanced.toml`](examples/client.advanced.toml)
|
||||
- [`examples/server.minimal.toml`](examples/server.minimal.toml)
|
||||
- [`examples/server.advanced.toml`](examples/server.advanced.toml)
|
||||
|
||||
Put real secrets in separate files, owned by the service account, mode `0600`.
|
||||
Do not commit them. See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) and
|
||||
[`docs/SECURITY.md`](docs/SECURITY.md).
|
||||
|
||||
## Run with systemd
|
||||
|
||||
The templates in [`packaging/systemd/`](packaging/systemd/) assume a dedicated
|
||||
`wgcloak` user, `/usr/bin/wgcloak`, and configuration under `/etc/wgcloak/`.
|
||||
Review paths and transport support before installing them. The server template
|
||||
includes hardening and a commented optional `CAP_NET_BIND_SERVICE` setting for
|
||||
ports below 1024; normal operation does not require `CAP_NET_ADMIN` or
|
||||
`CAP_SYS_ADMIN`.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md): process, data path, and ownership boundaries.
|
||||
- [`docs/PROTOCOL.md`](docs/PROTOCOL.md): direct packet model and transport contracts.
|
||||
- [`docs/OPERATIONS.md`](docs/OPERATIONS.md): files, control socket, lifecycle, and deployment.
|
||||
- [`docs/SECURITY.md`](docs/SECURITY.md): threat model, secrets, privileges, and non-goals.
|
||||
- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md): checks for common startup and connectivity failures.
|
||||
- [`docs/IMPLEMENTATION_CHECKLIST.md`](docs/IMPLEMENTATION_CHECKLIST.md): milestones 1–6 and all 17 v1 criteria.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
wgcloak does not configure WireGuard, create TUN/TAP devices, change routes or
|
||||
firewalls, fragment application datagrams, spoof browser/TLS fingerprints, or
|
||||
provide clustering, roaming, a GUI, Windows/macOS support, or multiple client
|
||||
tunnels in v1.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Architecture
|
||||
|
||||
wgcloak is a single Linux userspace process with a `client` or `server` role.
|
||||
The core moves opaque datagrams between a local UDP socket and a transport
|
||||
session. It does not know whether the payload is WireGuard, DNS, or another UDP
|
||||
protocol.
|
||||
|
||||
```text
|
||||
CLIENT SERVER
|
||||
local UDP peer transport listener
|
||||
│ │
|
||||
▼ ▼
|
||||
local listener ── tunnel core ── transport ── authenticated session
|
||||
│
|
||||
▼
|
||||
dedicated connected UDP backend socket
|
||||
│
|
||||
▼
|
||||
configured UDP backend
|
||||
```
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
- The local listener receives datagrams and remembers the most recently active
|
||||
local peer for return traffic. One active peer per v1 tunnel is sufficient.
|
||||
- The core owns session lifecycle, limits, datagram-size checks, reconnect
|
||||
policy, and statistics. Payload bytes remain opaque.
|
||||
- A transport owns connection establishment and wire representation. Every
|
||||
transport exposes the same logical send/receive/close behavior and a maximum
|
||||
datagram size.
|
||||
- Authentication is a separate concern. Direct UDP uses the documented
|
||||
challenge/HMAC packet protocol; TLS-capable transports authenticate the
|
||||
client after transport establishment.
|
||||
- The server creates a backend UDP socket only after client authentication and
|
||||
maps that socket to one session. A backend response therefore has natural
|
||||
session ownership without inspecting the backend protocol.
|
||||
|
||||
## Transport lifecycle
|
||||
|
||||
The client state machine is:
|
||||
|
||||
```text
|
||||
STARTING → RESOLVING → PROBING → AUTHENTICATING → CONNECTED
|
||||
│ │
|
||||
└───────────┘ failure
|
||||
▼
|
||||
RECONNECTING
|
||||
│
|
||||
└→ PROBING
|
||||
```
|
||||
|
||||
In `auto` mode the target order is direct UDP, QUIC DATAGRAM, HTTP/3
|
||||
CONNECT-UDP, then TLS/TCP. Authentication rejection is a configuration error,
|
||||
not permission to probe indefinitely. Network failures use bounded exponential
|
||||
backoff with jitter.
|
||||
|
||||
## Server isolation
|
||||
|
||||
One malformed or unauthenticated packet must be rejected without terminating
|
||||
the listener or affecting another client. Server limits cover active sessions,
|
||||
per-source sessions, pending handshakes, handshake duration, authentication
|
||||
attempts, and maximum datagram size. Expiry closes the backend socket and
|
||||
releases replay/session state.
|
||||
|
||||
## Privileges and filesystem
|
||||
|
||||
Normal operation uses ordinary UDP/TCP/Unix sockets and needs no root,
|
||||
`CAP_NET_ADMIN`, or `CAP_SYS_ADMIN`. `CAP_NET_BIND_SERVICE` is optional when a
|
||||
reviewed deployment must bind a port below 1024. The control socket is local
|
||||
and protected by filesystem ownership/mode; it is not a network listener.
|
||||
|
||||
This document describes the intended v1 architecture. Check the milestone
|
||||
matrix before relying on a transport or control command in a particular build.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Development state
|
||||
|
||||
Checkpoint: 2026-09-14, end of day. Development is paused at a compilable
|
||||
checkpoint; no commit was made.
|
||||
|
||||
## Branch and worktree
|
||||
|
||||
- Branch: `main`.
|
||||
- `git status --short` reports the project files as untracked, including
|
||||
`Cargo.toml`, `Cargo.lock`, `src/`, `tests/`, documentation, examples, and
|
||||
packaging files. There is no useful committed baseline to compare against in
|
||||
this checkout.
|
||||
- Multiple agents have been working in the shared directory. Review all diffs
|
||||
and ownership boundaries before committing; in particular, do not assume an
|
||||
untracked file was created by this task or that a concurrent edit is final.
|
||||
|
||||
## Implemented and wired behavior
|
||||
|
||||
The current tree contains the following work, subject to integration review:
|
||||
|
||||
- Operational assets: MIT `LICENSE`, Linux-focused `README.md`, `.gitignore`,
|
||||
client/server TOML examples, architecture/protocol/security/operations/
|
||||
troubleshooting documents, an implementation checklist, and systemd client
|
||||
and server templates.
|
||||
- TLS/TCP transport primitives in `src/transport/tls_tcp.rs`: bounded
|
||||
incremental/coalesced u16-big-endian framing, truncated/oversized-frame
|
||||
handling, PEM/DER certificate and private-key loading, challenge-response
|
||||
authentication, authenticated data/heartbeat/close framing, idle timeout,
|
||||
and bounded shutdown.
|
||||
- HTTP/3 support primitives in `src/transport/http3.rs`: CONNECT-UDP request
|
||||
validation, QUIC varints, RFC 9297 HTTP Datagram context encoding, capsule
|
||||
encoding, and project authentication messages. The module does not fake
|
||||
CONNECT-UDP with POST or ordinary streams.
|
||||
- Runtime orchestration in `src/transport/mod.rs`, `src/client/mod.rs`, and
|
||||
`src/server/mod.rs` instantiates direct UDP, QUIC, and TLS/TCP connectors or
|
||||
listeners. TLS backend creation occurs after the authenticated session is
|
||||
established. HTTP/3 currently returns an explicit `Unsupported` result from
|
||||
runtime selection because the actual h3 datagram adapter is not complete.
|
||||
- The client and server start the Unix control server. Client reconnect and
|
||||
reload commands currently cause a reconnect attempt; server shutdown is
|
||||
handled by the encrypted listener paths, while direct-UDP/control lifecycle
|
||||
still needs review.
|
||||
- `h3-quinn` was enabled with its `datagram` feature; `tokio-rustls` and
|
||||
`rustls-native-certs` were added for TLS runtime integration.
|
||||
|
||||
## Files changed in this work
|
||||
|
||||
Primary files touched by the operations/integration work are:
|
||||
|
||||
- `Cargo.toml`, `Cargo.lock`
|
||||
- `src/transport/mod.rs`
|
||||
- `src/transport/tls_tcp.rs`
|
||||
- `src/transport/http3.rs`
|
||||
- `src/client/mod.rs`
|
||||
- `src/server/mod.rs`
|
||||
- `tests/tls_tcp.rs`, `tests/http3.rs`, `tests/integration_transports.rs`
|
||||
- `.gitignore`, `LICENSE`, `README.md`
|
||||
- `examples/*`, `packaging/systemd/*`, and the other files under `docs/`
|
||||
|
||||
`src/transport/quic.rs` was edited by another concurrent agent and is outside
|
||||
the TLS/integration ownership listed above. Preserve that ownership boundary
|
||||
when resolving conflicts.
|
||||
|
||||
## Verification and current failures
|
||||
|
||||
- TOML examples were parsed successfully with Python `tomllib`.
|
||||
- TLS/TCP focused tests passed: `cargo test --test tls_tcp --quiet` (7 tests).
|
||||
- HTTP/3 focused tests passed: `cargo test --test http3 --quiet` (4 tests).
|
||||
- `cargo check` passed after the transport integration edits.
|
||||
- The TLS loopback integration test compiles but currently panics while
|
||||
constructing a rustls configuration because no process-level crypto provider
|
||||
is installed:
|
||||
|
||||
`Could not automatically determine process-level CryptoProvider from Rustls crate features.`
|
||||
|
||||
The runtime/test setup must explicitly install the `ring` provider (or enable
|
||||
exactly one provider feature) before building rustls configurations.
|
||||
- The supervisor's latest failure reprobe reports that the QUIC focused tests
|
||||
now pass. Run the complete suite again after fixing the rustls provider; the
|
||||
earlier complete-suite run had QUIC handshake-timeout failures before the
|
||||
concurrent QUIC-agent fixes landed.
|
||||
- Clippy has not reached a clean result. Existing included test/source modules
|
||||
produce many dead-code and lint diagnostics, and integration code also needs
|
||||
a final lint pass after the provider fix.
|
||||
|
||||
## Remaining gaps, prioritized
|
||||
|
||||
1. Fix and verify explicit rustls crypto-provider initialization, then run the
|
||||
TLS loopback test and the complete test suite.
|
||||
2. Finish a real h3 0.0.8/h3-quinn 0.0.10 HTTP/3 CONNECT-UDP datagram adapter,
|
||||
or retain the explicit `Unsupported` path with a precise documented
|
||||
dependency/API blocker. Do not substitute POST, capsules-only streams, or
|
||||
ordinary HTTP request bodies for HTTP Datagrams.
|
||||
3. Review runtime lifecycle semantics: direct-UDP control shutdown wiring,
|
||||
actual file-backed config reload, client TLS heartbeat scheduling, and
|
||||
authentication-error mapping so auto fallback stops on rejected credentials.
|
||||
4. Run repository-wide formatting, tests, and clippy; inspect the shared
|
||||
untracked tree and reconcile concurrent `quic.rs` changes before any commit.
|
||||
|
||||
## First three tasks tomorrow
|
||||
|
||||
1. Install/select the rustls `ring` provider once at runtime and in the
|
||||
integration test setup; rerun TLS loopback plus `cargo test`.
|
||||
2. Inspect the enabled h3-quinn datagram API and implement/test the smallest
|
||||
standards-correct CONNECT-UDP client/server adapter; otherwise record the
|
||||
exact unsupported API/dependency condition in the public docs.
|
||||
3. Perform an end-to-end lifecycle review and repository-wide `fmt`, check,
|
||||
test, and clippy pass, resolving only changes within each agent's ownership.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Implementation and acceptance checklist
|
||||
|
||||
This is a coordinator checklist, not a claim that the items are complete.
|
||||
Mark an item only with code evidence, tests, and an integration result. Record
|
||||
the commit/test command or issue beside each checked item. A feature marked
|
||||
planned in the other documents must remain visibly planned here until proven.
|
||||
|
||||
## Milestones 1–6
|
||||
|
||||
- [ ] **1 — authenticated direct relay:** local UDP listener; direct UDP
|
||||
handshake; HMAC-authenticated data; replay window; keepalive/expiry; one
|
||||
backend UDP socket per authenticated client; multi-client isolation; TOML;
|
||||
logs and basic stats.
|
||||
- [ ] **2 — QUIC DATAGRAM:** certificate loading and hostname verification;
|
||||
client challenge/response; datagrams; keepalive/MTU; reconnect; multi-client
|
||||
handling without core API redesign.
|
||||
- [ ] **3 — automatic selection:** `mode = "auto"`; direct UDP then QUIC;
|
||||
bounded probes/retries; auth rejection stops probing; jittered reconnect;
|
||||
status reports selected transport.
|
||||
- [ ] **4 — HTTP/3 CONNECT-UDP:** standard CONNECT-UDP/HTTP Datagram semantics;
|
||||
authentication; backend mapping; reuse QUIC infrastructure where practical.
|
||||
- [ ] **5 — TLS/TCP fallback:** TLS listener; client authentication; bounded
|
||||
big-endian length framing; bidirectional datagram reconstruction; fallback
|
||||
integration; expected head-of-line behavior documented.
|
||||
- [ ] **6 — operational polish:** Unix control socket; status/stats/reconnect
|
||||
commands; config validation; systemd units; capability guidance; certificate
|
||||
and configuration reload; benchmarks; fuzzing; installation docs.
|
||||
|
||||
## v1 completion criteria (spec §89)
|
||||
|
||||
- [ ] 1. Multiple clients concurrently reach one UDP backend.
|
||||
- [ ] 2. No WireGuard-specific logic exists.
|
||||
- [ ] 3. Direct UDP works reliably.
|
||||
- [ ] 4. QUIC DATAGRAM works reliably.
|
||||
- [ ] 5. HTTP/3 CONNECT-UDP works.
|
||||
- [ ] 6. TLS/TCP fallback works.
|
||||
- [ ] 7. Automatic transport selection works.
|
||||
- [ ] 8. Authentication is enforced on every public transport.
|
||||
- [ ] 9. Direct UDP has replay and integrity protection.
|
||||
- [ ] 10. Direct UDP NAT rebinding works.
|
||||
- [ ] 11. Sessions expire and clean up correctly.
|
||||
- [ ] 12. The server survives malformed and unauthenticated traffic.
|
||||
- [ ] 13. The daemon runs unprivileged except optional bind capability.
|
||||
- [ ] 14. TOML configuration is clearly validated.
|
||||
- [ ] 15. Meaningful status and statistics are available.
|
||||
- [ ] 16. Reconnects occur automatically.
|
||||
- [ ] 17. Automated integration tests cover multi-client operation.
|
||||
|
||||
## Required evidence before v1 sign-off
|
||||
|
||||
- [ ] Unit tests cover parsing, malformed input, auth, key derivation, replay,
|
||||
config validation, and timeout logic.
|
||||
- [ ] Integration tests cover direct client/server, concurrent clients,
|
||||
source-port change, auth rejection, restart/reconnect, backend outage,
|
||||
oversized datagrams, and expiry; later transports have equivalent coverage.
|
||||
- [ ] Fuzz targets exercise direct packets, TCP frames, and auth messages.
|
||||
- [ ] `cargo test`, formatting, linting, and any documented integration/bench
|
||||
commands pass from a clean checkout.
|
||||
- [ ] Review confirms no payload/secret logging, no unsafe privilege grant, and
|
||||
no accidental WireGuard/routing/firewall coupling.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Operations
|
||||
|
||||
This guide assumes a dedicated `wgcloak` system user and `/etc/wgcloak/` for
|
||||
configuration. Adjust paths for a user service or a different packaging
|
||||
layout.
|
||||
|
||||
## First installation
|
||||
|
||||
```bash
|
||||
sudo install -Dm755 target/release/wgcloak /usr/bin/wgcloak
|
||||
sudo install -d -o wgcloak -g wgcloak -m0750 /etc/wgcloak/clients
|
||||
sudo install -o wgcloak -g wgcloak -m0600 client.secret /etc/wgcloak/client.secret
|
||||
```
|
||||
|
||||
Create server client secrets separately and transfer them through a protected
|
||||
channel. Do not put real secret contents in the repository. Validate a config
|
||||
before starting a service when `check-config` is available in the current
|
||||
build:
|
||||
|
||||
```bash
|
||||
sudo -u wgcloak wgcloak check-config --config /etc/wgcloak/client.toml
|
||||
```
|
||||
|
||||
The command is a target v1 CLI and is delivered in the operational-polish
|
||||
milestone; confirm with `wgcloak --help`.
|
||||
|
||||
## Starting and stopping
|
||||
|
||||
Foreground operation is useful for first-run diagnostics:
|
||||
|
||||
```bash
|
||||
wgcloak client --config /etc/wgcloak/client.toml
|
||||
wgcloak server --config /etc/wgcloak/server.toml
|
||||
```
|
||||
|
||||
SIGTERM and SIGINT should stop accepting new sessions, stop local ingestion,
|
||||
close backend sockets, flush useful metrics, and exit within a bounded timeout.
|
||||
A transport failure should reconnect with jitter; it should not terminate the
|
||||
daemon. The systemd templates set restart-on-failure for this reason.
|
||||
|
||||
## Control socket (intended interface)
|
||||
|
||||
The daemon should expose a Unix-domain control socket at
|
||||
`$XDG_RUNTIME_DIR/wgcloak.sock` (for a system service, the template maps this
|
||||
to its private runtime directory). Filesystem permissions, not a TCP port,
|
||||
authorize access. The initial control protocol may be newline-delimited JSON.
|
||||
|
||||
Target commands are:
|
||||
|
||||
```text
|
||||
status current role/state, selected transport, endpoint, MTU and uptime
|
||||
stats counters for traffic, drops, auth failures, reconnects and sessions
|
||||
reconnect ask the client to leave the current transport and probe again
|
||||
reload re-read supported configuration (not full hot reload in early builds)
|
||||
shutdown request graceful process shutdown
|
||||
```
|
||||
|
||||
The exact request/response field names are implementation-owned until the
|
||||
control protocol lands. A client should never send credentials or payloads
|
||||
through this socket. `status` and `stats` are read-only; `reconnect`, `reload`,
|
||||
and `shutdown` are state-changing and must remain local-admin operations.
|
||||
|
||||
## systemd deployment
|
||||
|
||||
Review and copy the units under `packaging/systemd/`. They assume:
|
||||
|
||||
- binary `/usr/bin/wgcloak`;
|
||||
- user/group `wgcloak`;
|
||||
- configuration `/etc/wgcloak/client.toml` or `server.toml`;
|
||||
- runtime socket under a private `/run` directory;
|
||||
- secret and certificate paths readable by the service user.
|
||||
|
||||
After review:
|
||||
|
||||
```bash
|
||||
sudo install -Dm0644 packaging/systemd/wgcloak-client.service /etc/systemd/system/wgcloak-client.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now wgcloak-client.service
|
||||
systemctl status wgcloak-client.service
|
||||
journalctl -u wgcloak-client.service -f
|
||||
```
|
||||
|
||||
For a low port such as 443, uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE`
|
||||
and its bounding-set companion in the server unit only after confirming the
|
||||
unit still starts. Direct UDP examples use an unprivileged port.
|
||||
|
||||
## Safe rollout
|
||||
|
||||
Start with direct UDP and one test client. Check local listener binding,
|
||||
server authentication, backend reachability, return-peer behavior, counters,
|
||||
and clean shutdown. Add one transport milestone at a time. Keep an independent
|
||||
path to the host while changing a public listener and retain the previous
|
||||
configuration until a restart test succeeds.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Protocol overview
|
||||
|
||||
The application protocol, tunnel session, transport, and authentication remain
|
||||
independent. The application payload is opaque and is never parsed as
|
||||
WireGuard (or any other protocol).
|
||||
|
||||
## Direct UDP wire protocol (target v1)
|
||||
|
||||
Direct messages use protocol version `1`, network byte order, and a 12-byte
|
||||
common header:
|
||||
|
||||
```text
|
||||
+---------+---------+---------+---------+
|
||||
| version | type | flags | hdr_len |
|
||||
| u8 | u8 | u8 | u8 |
|
||||
+---------------------------------------+
|
||||
| session_id (u64) |
|
||||
+---------------------------------------+
|
||||
```
|
||||
|
||||
Packet types are `HELLO (0x01)`, `ACCEPT (0x02)`, `REJECT (0x03)`,
|
||||
`DATA (0x10)`, `KEEPALIVE (0x11)`, and `CLOSE (0x12)`. Unknown or malformed
|
||||
types are rejected or ignored according to context and never crash the daemon.
|
||||
|
||||
`HELLO` has session ID zero and carries a bounded client ID, timestamp,
|
||||
16-byte client nonce, and a 32-byte HMAC-SHA256 authenticator over the
|
||||
canonical version/client/timestamp/nonce encoding. The server checks a default
|
||||
60-second clock window and remembers accepted nonces for replay protection.
|
||||
|
||||
`ACCEPT` assigns a random, non-zero 64-bit session ID and returns a server
|
||||
nonce, idle timeout, maximum datagram size, and an authenticator. `REJECT`
|
||||
returns only a small reason class; it must not disclose secret material.
|
||||
|
||||
Authenticated `DATA` and `KEEPALIVE` packets contain the common header, a
|
||||
32-bit direction-specific sequence number, a 128-bit truncated HMAC tag, and
|
||||
the optional payload. Session keys are derived with HKDF-SHA256 from the
|
||||
configured secret, both nonces, and the session ID. Implementations must use
|
||||
maintained cryptographic libraries and constant-time tag comparison.
|
||||
|
||||
Sequence windows provide duplicate/replay detection and diagnostics but do not
|
||||
reorder UDP. After MAC and replay checks, a valid packet may update the peer
|
||||
address, allowing NAT rebinding. A session expires after the configured idle
|
||||
timeout (target default 120 seconds), then its backend socket and replay state
|
||||
are released.
|
||||
|
||||
The core rejects oversized application datagrams, increments a counter, and
|
||||
logs at debug/warn level. v1 does not fragment datagrams; a conservative
|
||||
starting limit is 1200 bytes.
|
||||
|
||||
## Encapsulated transports
|
||||
|
||||
- **Direct UDP:** lowest overhead and native datagrams; uses the authenticated
|
||||
protocol above and provides integrity but not outer confidentiality.
|
||||
- **QUIC DATAGRAM:** TLS 1.3 protects the transport; client shared-secret
|
||||
challenge/response still authenticates the configured client.
|
||||
- **HTTP/3 CONNECT-UDP:** uses legitimate MASQUE CONNECT-UDP and HTTP Datagram
|
||||
semantics over QUIC. It is for compatibility, not fingerprint spoofing.
|
||||
- **TLS/TCP:** uses the same client authentication model. Each datagram is
|
||||
framed as a big-endian `u16` payload length followed by up to 65535 bytes;
|
||||
the configured lower limit should normally apply. TCP head-of-line blocking
|
||||
is expected.
|
||||
|
||||
QUIC, HTTP/3, and TLS/TCP are planned by milestones 2, 4, and 5 respectively.
|
||||
The direct packet details are a protocol target; confirm the implementation and
|
||||
integration tests before treating them as interoperable guarantees.
|
||||
|
||||
## Authentication and resource order
|
||||
|
||||
The server performs cheap bounds/version checks, authentication and rate-limit
|
||||
checks before allocating a backend UDP socket. Public listeners are hostile:
|
||||
limits, handshake timeouts, per-source caps, and malformed-packet handling are
|
||||
part of the protocol boundary.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Security model
|
||||
|
||||
## What is protected
|
||||
|
||||
The server treats every public listener as hostile. v1 requires a configured
|
||||
client ID and at least 256 bits of entropy in its shared secret. Direct UDP
|
||||
authenticates every session and every data packet, rejects replay, tolerates
|
||||
authenticated NAT rebinding, and does not trust a session ID by itself.
|
||||
TLS-capable transports authenticate the server through normal X.509 hostname
|
||||
verification and authenticate the client with a post-connection HMAC
|
||||
challenge/response.
|
||||
|
||||
Secrets and application payloads must not appear in logs. Cryptographic
|
||||
operations belong to maintained Rust libraries; wgcloak does not implement
|
||||
encryption, HMAC, HKDF, SHA, or TLS itself.
|
||||
|
||||
## Local permissions
|
||||
|
||||
Use separate secret files rather than inline values. Secret files should be
|
||||
owned by the service account and mode `0600`; the parent configuration
|
||||
directory should not be writable by untrusted users. The Unix control socket
|
||||
must be created in a private runtime directory with restrictive ownership and
|
||||
mode (normally `0600`). Do not put it in a world-writable directory.
|
||||
|
||||
The systemd templates apply `NoNewPrivileges`, a read-only system view, private
|
||||
temporary storage, restricted address families, and other process hardening.
|
||||
Review `ReadWritePaths`, certificate access, and local policy before enabling
|
||||
them. Grant only `CAP_NET_BIND_SERVICE` when a low port is essential. Never
|
||||
grant `CAP_NET_ADMIN` or `CAP_SYS_ADMIN` for normal operation.
|
||||
|
||||
## Threat boundaries
|
||||
|
||||
The relay protects session admission and transport integrity; it does not make
|
||||
the encapsulated application secure. Direct UDP is not encrypted by wgcloak,
|
||||
so use QUIC, HTTP/3, or TLS/TCP when outer confidentiality is needed. A shared
|
||||
secret is a credential: rotate it out of band and avoid putting it in command
|
||||
lines, shell history, or issue reports.
|
||||
|
||||
Resource limits and bounded parsing reduce denial-of-service exposure, but a
|
||||
public relay still needs host firewalling, monitoring, and capacity planning.
|
||||
Authentication failure must stop probing rather than causing endless fallback
|
||||
traffic.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
v1 does not configure WireGuard, create TUN/TAP interfaces, manage routes or
|
||||
firewalls, provide traffic-shaping or browser/TLS-fingerprint evasion, hide
|
||||
traffic metadata from a compromised host, cluster servers, or support multiple
|
||||
simultaneous client tunnels. These boundaries are intentional.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Troubleshooting
|
||||
|
||||
Check the simplest layer that can explain the symptom, then move outward.
|
||||
|
||||
## Process exits immediately
|
||||
|
||||
- Run the same command in the foreground and read the fatal configuration
|
||||
error.
|
||||
- Check that every secret/certificate path exists and is readable by the
|
||||
service account, but not by unrelated users.
|
||||
- Run `wgcloak --help` and, where implemented, `wgcloak check-config` to spot a
|
||||
command or schema mismatch.
|
||||
- Confirm listener addresses are valid and no other process owns the port.
|
||||
|
||||
## Client cannot connect
|
||||
|
||||
- Verify DNS resolution and the configured server name/port.
|
||||
- Confirm the selected transport is implemented in this build; an `auto` list
|
||||
may mention future transports.
|
||||
- For direct UDP, check both directions in the host firewall and allow the
|
||||
server's UDP listener.
|
||||
- For TLS transports, verify the hostname matches the certificate and do not
|
||||
disable certificate verification.
|
||||
- Authentication rejection means a bad/unknown client or secret and must not
|
||||
be diagnosed as a firewall failure. Fix credentials before retrying.
|
||||
|
||||
## Authentication failures or repeated probing
|
||||
|
||||
Check client ID spelling, secret-file contents, ownership/mode, and system
|
||||
clock skew. Direct HELLO timestamps have a target default 60-second window;
|
||||
replayed client nonces are rejected. Repeated invalid credentials should stop
|
||||
fallback probing rather than generate traffic across every transport.
|
||||
|
||||
## Connected but no application traffic
|
||||
|
||||
- Confirm the local application sends to the configured local UDP address.
|
||||
- Confirm the server backend is listening at `[backend].target`.
|
||||
- Check `status`/`stats` or structured logs for selected transport, maximum
|
||||
datagram size, dropped/oversized datagrams, and backend errors.
|
||||
- v1 remembers one most-recent local source peer per client tunnel. Concurrent
|
||||
source peers can replace that return destination and produce a warning.
|
||||
- No application-layer fragmentation exists: reduce payload size when the
|
||||
configured transport limit is exceeded (starting target is 1200 bytes).
|
||||
|
||||
## Session disappears
|
||||
|
||||
The target idle timeout is 120 seconds, refreshed by authenticated data or
|
||||
keepalive. Check keepalive settings, NAT expiry, server resource limits, and
|
||||
whether the client is reconnecting after a transport failure. A valid direct
|
||||
UDP packet can update the peer address after NAT rebinding; a spoofed packet
|
||||
without a valid MAC cannot.
|
||||
|
||||
## Control socket unavailable
|
||||
|
||||
Check the daemon's `XDG_RUNTIME_DIR`, socket ownership/mode, and whether a
|
||||
stale socket path remains after an unclean exit. The control interface is local
|
||||
only and may be absent until the operational-polish milestone is implemented.
|
||||
|
||||
## Host hardening prevents startup
|
||||
|
||||
Read the unit's journal output. Common causes are a secret/certificate path
|
||||
outside the unit's read paths, a changed binary location, or an address family
|
||||
not allowed by `RestrictAddressFamilies`. Correct the reviewed unit rather
|
||||
than weakening all hardening directives. `CAP_NET_BIND_SERVICE` is only needed
|
||||
for ports below 1024.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Advanced client example. Values are placeholders, not credentials.
|
||||
|
||||
[client]
|
||||
server = "vpn.example.invalid"
|
||||
id = "replace-me"
|
||||
secret_file = "/etc/wgcloak/client.secret"
|
||||
|
||||
[local]
|
||||
listen = "127.0.0.1:51821"
|
||||
|
||||
[transport]
|
||||
mode = "auto"
|
||||
|
||||
# Auto probing is target behavior; use only transports supported by the build.
|
||||
[transport.auto]
|
||||
order = ["direct_udp", "quic", "http3", "tls_tcp"]
|
||||
probe_timeout_ms = 1500
|
||||
retries = 2
|
||||
|
||||
[transport.direct_udp]
|
||||
port = 51830
|
||||
keepalive_secs = 15
|
||||
|
||||
[transport.quic]
|
||||
port = 443
|
||||
keepalive_secs = 15
|
||||
|
||||
[transport.http3]
|
||||
port = 443
|
||||
path = "/tunnel"
|
||||
|
||||
[transport.tls_tcp]
|
||||
port = 443
|
||||
|
||||
[network]
|
||||
max_datagram_size = 1200
|
||||
|
||||
[logging]
|
||||
level = "info"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Minimal client. Replace the hostname, id, and secret path before use.
|
||||
# The secret file must contain a generated secret and be mode 0600.
|
||||
|
||||
[client]
|
||||
server = "vpn.example.invalid"
|
||||
id = "replace-me"
|
||||
secret_file = "/etc/wgcloak/client.secret"
|
||||
|
||||
[local]
|
||||
listen = "127.0.0.1:51821"
|
||||
|
||||
[transport]
|
||||
mode = "auto"
|
||||
@@ -0,0 +1,49 @@
|
||||
# Advanced server example. Values are placeholders, not credentials.
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0:51830"
|
||||
|
||||
[backend]
|
||||
target = "127.0.0.1:51820"
|
||||
|
||||
[transports.direct_udp]
|
||||
enabled = true
|
||||
port = 51830
|
||||
|
||||
[transports.quic]
|
||||
enabled = true
|
||||
port = 443
|
||||
|
||||
[transports.http3]
|
||||
enabled = true
|
||||
port = 443
|
||||
path = "/tunnel"
|
||||
|
||||
[transports.tls_tcp]
|
||||
enabled = true
|
||||
port = 443
|
||||
|
||||
# Required when TLS-capable transports are enabled.
|
||||
[tls]
|
||||
certificate = "/etc/letsencrypt/live/vpn.example.invalid/fullchain.pem"
|
||||
private_key = "/etc/letsencrypt/live/vpn.example.invalid/privkey.pem"
|
||||
|
||||
[session]
|
||||
idle_timeout_secs = 120
|
||||
|
||||
[limits]
|
||||
max_sessions = 1024
|
||||
max_sessions_per_ip = 32
|
||||
max_pending_handshakes = 256
|
||||
handshake_timeout_ms = 3000
|
||||
|
||||
[[auth.clients]]
|
||||
id = "laptop"
|
||||
secret_file = "/etc/wgcloak/clients/laptop.secret"
|
||||
|
||||
[[auth.clients]]
|
||||
id = "desktop"
|
||||
secret_file = "/etc/wgcloak/clients/desktop.secret"
|
||||
|
||||
[logging]
|
||||
level = "info"
|
||||
@@ -0,0 +1,22 @@
|
||||
# Minimal server. Replace the backend and client secret path before use.
|
||||
# The listener address/port is an example; open only the transports you need.
|
||||
|
||||
[server]
|
||||
listen = "0.0.0.0:51830"
|
||||
|
||||
[backend]
|
||||
target = "127.0.0.1:51820"
|
||||
|
||||
# Milestone 1 direct-UDP deployment. Other transports are planned or optional.
|
||||
[transports.quic]
|
||||
enabled = false
|
||||
|
||||
[transports.http3]
|
||||
enabled = false
|
||||
|
||||
[transports.tls_tcp]
|
||||
enabled = false
|
||||
|
||||
[[auth.clients]]
|
||||
id = "replace-me"
|
||||
secret_file = "/etc/wgcloak/clients/replace-me.secret"
|
||||
@@ -0,0 +1,39 @@
|
||||
[Unit]
|
||||
Description=wgcloak UDP transport client
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wgcloak
|
||||
Group=wgcloak
|
||||
ExecStart=/usr/bin/wgcloak client --config /etc/wgcloak/client.toml
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
TimeoutStopSec=15s
|
||||
UMask=0077
|
||||
|
||||
# Keep the control socket in a private runtime directory.
|
||||
RuntimeDirectory=wgcloak-client
|
||||
RuntimeDirectoryMode=0750
|
||||
Environment=XDG_RUNTIME_DIR=/run/wgcloak-client
|
||||
|
||||
# Process and filesystem hardening. Review paths if the deployment changes.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,44 @@
|
||||
[Unit]
|
||||
Description=wgcloak UDP transport server
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=wgcloak
|
||||
Group=wgcloak
|
||||
ExecStart=/usr/bin/wgcloak server --config /etc/wgcloak/server.toml
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
TimeoutStopSec=15s
|
||||
UMask=0077
|
||||
|
||||
# Keep the control socket in a private runtime directory.
|
||||
RuntimeDirectory=wgcloak-server
|
||||
RuntimeDirectoryMode=0750
|
||||
Environment=XDG_RUNTIME_DIR=/run/wgcloak-server
|
||||
|
||||
# Process and filesystem hardening. Review paths if the deployment changes.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
|
||||
# Ports below 1024 require this optional capability. Leave commented for the
|
||||
# normal unprivileged configuration. Keep the bounding set this narrow.
|
||||
# AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "wgcloak",
|
||||
version,
|
||||
about = "Authenticated protocol-agnostic UDP transport"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Run the client role.
|
||||
Client {
|
||||
/// Path to the client TOML configuration.
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
config: PathBuf,
|
||||
},
|
||||
/// Run the server role.
|
||||
Server {
|
||||
/// Path to the server TOML configuration.
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
config: PathBuf,
|
||||
},
|
||||
/// Parse and validate a client or server TOML configuration.
|
||||
CheckConfig {
|
||||
/// Path to the TOML configuration.
|
||||
#[arg(short, long, value_name = "FILE")]
|
||||
config: PathBuf,
|
||||
},
|
||||
/// Generate a cryptographically random 256-bit secret as base64.
|
||||
GenerateSecret,
|
||||
/// Query a running daemon's control socket.
|
||||
Status {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
/// Query counters from a running daemon.
|
||||
Stats {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
/// Request transport reconnection.
|
||||
Reconnect {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
/// Request configuration reload.
|
||||
Reload {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
/// Request graceful shutdown.
|
||||
Shutdown {
|
||||
#[arg(short, long)]
|
||||
socket: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn parses_runtime_commands_and_config_paths() {
|
||||
let cli = Cli::try_parse_from(["wgcloak", "client", "--config", "client.toml"]).unwrap();
|
||||
assert!(
|
||||
matches!(cli.command, Command::Client { config } if config == PathBuf::from("client.toml"))
|
||||
);
|
||||
|
||||
let cli = Cli::try_parse_from(["wgcloak", "check-config", "-c", "server.toml"]).unwrap();
|
||||
assert!(
|
||||
matches!(cli.command, Command::CheckConfig { config } if config == PathBuf::from("server.toml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_generate_secret_without_arguments() {
|
||||
assert!(matches!(
|
||||
Cli::try_parse_from(["wgcloak", "generate-secret"])
|
||||
.unwrap()
|
||||
.command,
|
||||
Command::GenerateSecret
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use bytes::Bytes;
|
||||
use tokio::{net::UdpSocket, time};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, TransportMode},
|
||||
control::{ControlServer, ControlState},
|
||||
transport::{
|
||||
quic::QuicClientConnector, DirectUdpConnector, TlsTcpConnector, TransportConnector,
|
||||
TransportError, TransportSession,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod supervisor;
|
||||
|
||||
pub async fn run(config: ClientConfig) -> Result<()> {
|
||||
let local = UdpSocket::bind(config.local.listen).await?;
|
||||
info!(local_addr=%local.local_addr()?, "client local UDP listener started");
|
||||
let control_state = ControlState::default();
|
||||
let control =
|
||||
ControlServer::bind(crate::control::default_socket_path(), control_state.clone()).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = control.run().await;
|
||||
});
|
||||
let mut latest_peer = None;
|
||||
let mut backoff = Duration::from_millis(250);
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
loop {
|
||||
let connected = tokio::select! {
|
||||
_ = &mut signal => return Ok(()),
|
||||
_ = control_state.shutdown.cancelled() => return Ok(()),
|
||||
result = connect_for_mode(&config) => result,
|
||||
};
|
||||
let (session, mode) = match connected {
|
||||
Ok(value) => {
|
||||
backoff = Duration::from_millis(250);
|
||||
value
|
||||
}
|
||||
Err(TransportError::AuthenticationRejected(reason)) => {
|
||||
warn!(?reason, "server rejected client authentication");
|
||||
return Err(anyhow::anyhow!(TransportError::AuthenticationRejected(
|
||||
reason
|
||||
)));
|
||||
}
|
||||
Err(error @ TransportError::Unsupported(_)) => return Err(error.into()),
|
||||
Err(error) => {
|
||||
warn!(error=%error, retry_in_ms=backoff.as_millis(), "configured transports unavailable");
|
||||
tokio::select! { _ = &mut signal => return Ok(()), _ = control_state.shutdown.cancelled() => return Ok(()), _ = time::sleep(backoff) => {} }
|
||||
backoff = (backoff * 2).min(Duration::from_secs(30));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
info!(?mode, "client transport connected");
|
||||
let outcome =
|
||||
run_connected(&local, session.as_ref(), &mut latest_peer, &control_state).await;
|
||||
let _ = session.close().await;
|
||||
if outcome {
|
||||
return Ok(());
|
||||
}
|
||||
warn!(?mode, "transport session lost; reconnecting");
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_for_mode(
|
||||
config: &ClientConfig,
|
||||
) -> Result<(Box<dyn TransportSession>, TransportMode), TransportError> {
|
||||
let modes = match config.transport.mode {
|
||||
TransportMode::Auto => config.transport.auto.order.clone(),
|
||||
mode => vec![mode],
|
||||
};
|
||||
let mut last = TransportError::Unsupported("no transport configured".into());
|
||||
for mode in modes {
|
||||
let result = match mode {
|
||||
TransportMode::DirectUdp => DirectUdpConnector::new(config.clone()).connect().await,
|
||||
TransportMode::Quic => QuicClientConnector::new(config.clone()).connect().await,
|
||||
TransportMode::TlsTcp => TlsTcpConnector::new(config.clone()).connect().await,
|
||||
TransportMode::Http3 => Err(TransportError::Unsupported(
|
||||
"HTTP/3 CONNECT-UDP adapter requires h3-quinn datagram integration".into(),
|
||||
)),
|
||||
TransportMode::Auto => Err(TransportError::Unsupported(
|
||||
"nested auto transport mode".into(),
|
||||
)),
|
||||
};
|
||||
match result {
|
||||
Ok(session) => return Ok((session, mode)),
|
||||
Err(TransportError::AuthenticationRejected(reason)) => {
|
||||
return Err(TransportError::AuthenticationRejected(reason))
|
||||
}
|
||||
Err(error) => {
|
||||
debug!(?mode, error=%error, "transport probe failed");
|
||||
last = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last)
|
||||
}
|
||||
|
||||
async fn run_connected(
|
||||
local: &UdpSocket,
|
||||
session: &dyn TransportSession,
|
||||
latest_peer: &mut Option<std::net::SocketAddr>,
|
||||
control: &ControlState,
|
||||
) -> bool {
|
||||
let mut local_buf = vec![0u8; session.max_datagram_size().saturating_add(1)];
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut signal => return true,
|
||||
_ = control.shutdown.cancelled() => return true,
|
||||
_ = control.reconnect.notified() => return false,
|
||||
_ = control.reload.notified() => return false,
|
||||
received = local.recv_from(&mut local_buf) => {
|
||||
let (len, peer) = match received { Ok(v) => v, Err(_) => return false };
|
||||
if latest_peer.map(|old| old != peer).unwrap_or(false) { debug!(old=?latest_peer, new_addr=%peer, "local UDP peer changed; using newest peer"); }
|
||||
*latest_peer = Some(peer);
|
||||
if len > session.max_datagram_size() { warn!(len, maximum=session.max_datagram_size(), "dropping oversized local datagram"); continue; }
|
||||
if session.send_datagram(Bytes::copy_from_slice(&local_buf[..len])).await.is_err() { return false; }
|
||||
}
|
||||
received = session.recv_datagram() => {
|
||||
let payload = match received { Ok(v) => v, Err(_) => return false };
|
||||
if let Some(peer) = *latest_peer { if local.send_to(&payload, peer).await.is_err() { return false; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Generic client transport selection and reconnection policy.
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, TransportMode},
|
||||
transport::{TransportConnector, TransportError, TransportSession},
|
||||
};
|
||||
use std::{future::Future, pin::Pin, sync::Arc, time::Duration};
|
||||
use tokio::{
|
||||
sync::{Mutex, Notify},
|
||||
time::{self, timeout},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
pub type ProbeFuture =
|
||||
Pin<Box<dyn Future<Output = Result<Box<dyn TransportSession>, TransportError>> + Send>>;
|
||||
/// A probe-owned lifecycle watcher. It must report only session lifecycle
|
||||
/// events; it must not read application datagrams from the session.
|
||||
pub type SessionWatchFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'a>>;
|
||||
pub trait TransportProbe: Send + Sync + 'static {
|
||||
fn connect(&self, mode: TransportMode) -> ProbeFuture;
|
||||
|
||||
/// Wait for the established session to fail. The default keeps the
|
||||
/// historical supervisor behavior for probes without an external
|
||||
/// lifecycle signal. Concrete adapters should implement this using their
|
||||
/// connection/driver task or cancellation signal, never `recv_datagram`.
|
||||
fn wait_for_session_failure<'a>(
|
||||
&'a self,
|
||||
_session: &'a dyn TransportSession,
|
||||
) -> SessionWatchFuture<'a> {
|
||||
Box::pin(std::future::pending())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SupervisorState {
|
||||
Starting,
|
||||
Probing,
|
||||
Connected,
|
||||
Reconnecting,
|
||||
AuthenticationRejected,
|
||||
Stopped,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SupervisorStatus {
|
||||
pub state: SupervisorState,
|
||||
pub selected_transport: Option<TransportMode>,
|
||||
pub attempts: u64,
|
||||
pub reconnections: u64,
|
||||
pub backoff: Duration,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
impl Default for SupervisorStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: SupervisorState::Starting,
|
||||
selected_transport: None,
|
||||
attempts: 0,
|
||||
reconnections: 0,
|
||||
backoff: Duration::from_millis(250),
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TransportSupervisor<P> {
|
||||
config: ClientConfig,
|
||||
probe: Arc<P>,
|
||||
status: Arc<Mutex<SupervisorStatus>>,
|
||||
reconnect: Arc<Notify>,
|
||||
shutdown: CancellationToken,
|
||||
backoff_step: u32,
|
||||
}
|
||||
impl<P: TransportProbe> TransportSupervisor<P> {
|
||||
pub fn new(config: ClientConfig, probe: P) -> Self {
|
||||
Self {
|
||||
config,
|
||||
probe: Arc::new(probe),
|
||||
status: Arc::new(Mutex::new(SupervisorStatus::default())),
|
||||
reconnect: Arc::new(Notify::new()),
|
||||
shutdown: CancellationToken::new(),
|
||||
backoff_step: 0,
|
||||
}
|
||||
}
|
||||
pub fn shutdown_token(&self) -> CancellationToken {
|
||||
self.shutdown.clone()
|
||||
}
|
||||
pub fn reconnect_handle(&self) -> Arc<Notify> {
|
||||
self.reconnect.clone()
|
||||
}
|
||||
pub async fn status(&self) -> SupervisorStatus {
|
||||
self.status.lock().await.clone()
|
||||
}
|
||||
pub fn request_reconnect(&self) {
|
||||
self.reconnect.notify_one();
|
||||
}
|
||||
pub fn request_shutdown(&self) {
|
||||
self.shutdown.cancel();
|
||||
}
|
||||
fn modes(&self) -> Vec<TransportMode> {
|
||||
match self.config.transport.mode {
|
||||
TransportMode::Auto => self.config.transport.auto.order.clone(),
|
||||
mode => vec![mode],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_once(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
self.status.lock().await.state = SupervisorState::Probing;
|
||||
let deadline = Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1));
|
||||
let attempts = self.config.transport.auto.retries.max(1);
|
||||
let mut last = None;
|
||||
for mode in self.modes() {
|
||||
for attempt in 0..attempts {
|
||||
self.status.lock().await.attempts += 1;
|
||||
debug!(?mode, attempt, "probing transport");
|
||||
match timeout(deadline, self.probe.connect(mode)).await {
|
||||
Ok(Ok(session)) => {
|
||||
let mut s = self.status.lock().await;
|
||||
s.state = SupervisorState::Connected;
|
||||
s.selected_transport = Some(mode);
|
||||
s.last_error = None;
|
||||
return Ok(session);
|
||||
}
|
||||
Ok(Err(error @ TransportError::AuthenticationRejected(_))) => {
|
||||
let mut s = self.status.lock().await;
|
||||
s.state = SupervisorState::AuthenticationRejected;
|
||||
s.last_error = Some(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Err(error)) => last = Some(error),
|
||||
Err(_) => last = Some(TransportError::HandshakeTimeout),
|
||||
}
|
||||
}
|
||||
}
|
||||
let error = last.unwrap_or(TransportError::HandshakeTimeout);
|
||||
self.status.lock().await.last_error = Some(error.to_string());
|
||||
Err(error)
|
||||
}
|
||||
pub fn backoff_delay(&mut self) -> Duration {
|
||||
let n = self.backoff_step.min(17);
|
||||
let base = 250u64.saturating_mul(1u64 << n).min(30_000);
|
||||
let jitter = if base <= 250 {
|
||||
0
|
||||
} else {
|
||||
rand::random::<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))
|
||||
}
|
||||
pub fn reset_backoff(&mut self) {
|
||||
self.backoff_step = 0;
|
||||
}
|
||||
pub async fn run(mut self) -> Result<(), TransportError> {
|
||||
loop {
|
||||
if self.shutdown.is_cancelled() {
|
||||
self.status.lock().await.state = SupervisorState::Stopped;
|
||||
return Ok(());
|
||||
}
|
||||
match self.connect_once().await {
|
||||
Ok(session) => {
|
||||
self.reset_backoff();
|
||||
self.status.lock().await.backoff = Duration::from_millis(250);
|
||||
tokio::select! {
|
||||
_ = self.shutdown.cancelled() => {
|
||||
let _ = session.close().await;
|
||||
self.status.lock().await.state = SupervisorState::Stopped;
|
||||
return Ok(());
|
||||
},
|
||||
_ = self.reconnect.notified() => {
|
||||
let _ = session.close().await;
|
||||
let mut s = self.status.lock().await;
|
||||
s.state = SupervisorState::Reconnecting;
|
||||
s.reconnections += 1;
|
||||
},
|
||||
result = self.probe.wait_for_session_failure(session.as_ref()) => {
|
||||
let _ = session.close().await;
|
||||
let mut s = self.status.lock().await;
|
||||
s.state = SupervisorState::Reconnecting;
|
||||
s.reconnections += 1;
|
||||
s.last_error = result.as_ref().err().map(ToString::to_string);
|
||||
if let Err(error @ TransportError::AuthenticationRejected(_)) = result {
|
||||
return Err(error);
|
||||
}
|
||||
drop(s);
|
||||
let delay = self.backoff_delay();
|
||||
self.status.lock().await.backoff = delay;
|
||||
tokio::select! {
|
||||
_ = self.shutdown.cancelled() => {
|
||||
self.status.lock().await.state = SupervisorState::Stopped;
|
||||
return Ok(());
|
||||
},
|
||||
_ = self.reconnect.notified() => {},
|
||||
_ = time::sleep(delay) => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error @ TransportError::AuthenticationRejected(_)) => return Err(error),
|
||||
Err(error) => {
|
||||
warn!(error=%error, "all configured transports unavailable");
|
||||
let delay = self.backoff_delay();
|
||||
{
|
||||
let mut s = self.status.lock().await;
|
||||
s.state = SupervisorState::Reconnecting;
|
||||
s.backoff = delay;
|
||||
}
|
||||
tokio::select! { _ = self.shutdown.cancelled() => { self.status.lock().await.state = SupervisorState::Stopped; return Ok(()); }, _ = self.reconnect.notified() => {}, _ = time::sleep(delay) => {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SingleConnector<C> {
|
||||
connector: Arc<C>,
|
||||
}
|
||||
impl<C> SingleConnector<C> {
|
||||
pub fn new(connector: C) -> Self {
|
||||
Self {
|
||||
connector: Arc::new(connector),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<C: crate::transport::TransportConnector + 'static> TransportProbe for SingleConnector<C> {
|
||||
fn connect(&self, _mode: TransportMode) -> ProbeFuture {
|
||||
let connector = self.connector.clone();
|
||||
Box::pin(async move { connector.connect().await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Milestone 1 adapter. Unsupported carriers fail as connectivity errors, so
|
||||
/// an automatic supervisor may continue with the next configured mode.
|
||||
pub struct DirectUdpProbe {
|
||||
config: ClientConfig,
|
||||
}
|
||||
impl DirectUdpProbe {
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
impl TransportProbe for DirectUdpProbe {
|
||||
fn connect(&self, mode: TransportMode) -> ProbeFuture {
|
||||
let config = self.config.clone();
|
||||
Box::pin(async move {
|
||||
if mode != TransportMode::DirectUdp {
|
||||
return Err(TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!("transport {mode:?} is not available"),
|
||||
)));
|
||||
}
|
||||
crate::transport::DirectUdpConnector::new(config)
|
||||
.connect()
|
||||
.await
|
||||
})
|
||||
}
|
||||
}
|
||||
+826
@@ -0,0 +1,826 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fs,
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{de::Deserializer, Deserialize};
|
||||
use thiserror::Error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
const DEFAULT_MAX_DATAGRAM_SIZE: u16 = 1200;
|
||||
const MIN_SECRET_BYTES: usize = 32;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("could not read configuration {path}: {source}")]
|
||||
Read {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("could not parse TOML configuration: {0}")]
|
||||
Parse(#[from] toml::de::Error),
|
||||
#[error("configuration is neither a client nor a server configuration")]
|
||||
UnknownRole,
|
||||
#[error("invalid configuration: {0}")]
|
||||
Invalid(String),
|
||||
#[error("could not read secret file {path}: {source}")]
|
||||
SecretRead {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("secret file {path} must not be readable by group or other users (mode {mode:o})")]
|
||||
SecretPermissions { path: PathBuf, mode: u32 },
|
||||
#[error("secret for {label} must contain at least 256 bits of entropy")]
|
||||
SecretTooShort { label: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Config {
|
||||
Client(ClientConfig),
|
||||
Server(ServerConfig),
|
||||
}
|
||||
|
||||
pub enum RoleConfig {
|
||||
Client(ClientConfig),
|
||||
Server(ServerConfig),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let text =
|
||||
fs::read_to_string(&path).map_err(|source| ConfigError::Read { path, source })?;
|
||||
Self::from_str(&text)
|
||||
}
|
||||
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(text: &str) -> Result<Self, ConfigError> {
|
||||
let value: toml::Value = toml::from_str(text)?;
|
||||
let has_client = value.get("client").is_some();
|
||||
let has_server = value.get("server").is_some();
|
||||
match (has_client, has_server) {
|
||||
(true, false) => Ok(Self::Client(value.try_into()?)),
|
||||
(false, true) => Ok(Self::Server(value.try_into()?)),
|
||||
_ => Err(ConfigError::UnknownRole),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
match self {
|
||||
Self::Client(config) => config.validate(),
|
||||
Self::Server(config) => config.validate(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn role_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Client(_) => "client",
|
||||
Self::Server(_) => "server",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_client(self) -> Result<ClientConfig, &'static str> {
|
||||
match self {
|
||||
Self::Client(config) => Ok(config),
|
||||
Self::Server(_) => Err("configuration is for server, not client"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_server(self) -> Result<ServerConfig, &'static str> {
|
||||
match self {
|
||||
Self::Server(config) => Ok(config),
|
||||
Self::Client(_) => Err("configuration is for client, not server"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientConfig> for Config {
|
||||
fn from(value: ClientConfig) -> Self {
|
||||
Self::Client(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientConfig {
|
||||
pub client: ClientIdentityConfig,
|
||||
#[serde(default)]
|
||||
pub local: LocalConfig,
|
||||
#[serde(default)]
|
||||
pub transport: ClientTransportConfig,
|
||||
#[serde(default)]
|
||||
pub network: NetworkConfig,
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig,
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
if self.client.server.trim().is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"client.server must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if self.client.id.trim().is_empty() {
|
||||
return Err(ConfigError::Invalid("client.id must not be empty".into()));
|
||||
}
|
||||
validate_secret_source(&self.client.secret, "client")?;
|
||||
validate_max_datagram(self.network.max_datagram_size)?;
|
||||
self.transport.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientIdentityConfig {
|
||||
pub server: String,
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub secret: SecretSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LocalConfig {
|
||||
#[serde(
|
||||
default = "default_local_listen",
|
||||
deserialize_with = "deserialize_socket_addr"
|
||||
)]
|
||||
pub listen: SocketAddr,
|
||||
}
|
||||
|
||||
impl Default for LocalConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen: default_local_listen(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientTransportConfig {
|
||||
#[serde(default)]
|
||||
pub mode: TransportMode,
|
||||
#[serde(default)]
|
||||
pub auto: AutoTransportConfig,
|
||||
#[serde(default)]
|
||||
pub direct_udp: ClientDirectUdpConfig,
|
||||
#[serde(default)]
|
||||
pub quic: ClientQuicConfig,
|
||||
#[serde(default)]
|
||||
pub http3: ClientHttp3Config,
|
||||
#[serde(default)]
|
||||
pub tls_tcp: ClientTlsTcpConfig,
|
||||
}
|
||||
|
||||
impl Default for ClientTransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: TransportMode::Auto,
|
||||
auto: AutoTransportConfig::default(),
|
||||
direct_udp: ClientDirectUdpConfig::default(),
|
||||
quic: ClientQuicConfig::default(),
|
||||
http3: ClientHttp3Config::default(),
|
||||
tls_tcp: ClientTlsTcpConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientTransportConfig {
|
||||
fn validate(&self) -> Result<(), ConfigError> {
|
||||
if self.auto.order.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.auto.order must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if self.auto.probe_timeout_ms == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.auto.probe_timeout_ms must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if self.auto.retries == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"transport.auto.retries must be > 0".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AutoTransportConfig {
|
||||
#[serde(default = "default_transport_order")]
|
||||
pub order: Vec<TransportMode>,
|
||||
#[serde(default = "default_probe_timeout_ms")]
|
||||
pub probe_timeout_ms: u64,
|
||||
#[serde(default = "default_retries")]
|
||||
pub retries: u8,
|
||||
}
|
||||
|
||||
impl Default for AutoTransportConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
order: default_transport_order(),
|
||||
probe_timeout_ms: default_probe_timeout_ms(),
|
||||
retries: default_retries(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransportMode {
|
||||
#[default]
|
||||
Auto,
|
||||
DirectUdp,
|
||||
Quic,
|
||||
Http3,
|
||||
TlsTcp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientDirectUdpConfig {
|
||||
#[serde(default = "default_direct_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_keepalive")]
|
||||
pub keepalive_secs: u64,
|
||||
}
|
||||
impl Default for ClientDirectUdpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_direct_port(),
|
||||
keepalive_secs: default_keepalive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientQuicConfig {
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_keepalive")]
|
||||
pub keepalive_secs: u64,
|
||||
}
|
||||
impl Default for ClientQuicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_tls_port(),
|
||||
keepalive_secs: default_keepalive(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientHttp3Config {
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_http3_path")]
|
||||
pub path: String,
|
||||
}
|
||||
impl Default for ClientHttp3Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_tls_port(),
|
||||
path: default_http3_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ClientTlsTcpConfig {
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
impl Default for ClientTlsTcpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_tls_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NetworkConfig {
|
||||
#[serde(default = "default_max_datagram_size")]
|
||||
pub max_datagram_size: u16,
|
||||
}
|
||||
impl Default for NetworkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_datagram_size: default_max_datagram_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub server: ServerListenConfig,
|
||||
pub backend: BackendConfig,
|
||||
#[serde(default)]
|
||||
pub transports: ServerTransportsConfig,
|
||||
#[serde(default)]
|
||||
pub tls: TlsConfig,
|
||||
#[serde(default)]
|
||||
pub session: SessionConfig,
|
||||
#[serde(default)]
|
||||
pub limits: LimitsConfig,
|
||||
pub auth: AuthConfig,
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig,
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||
if self.server.listen.trim().is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"server.listen must not be empty".into(),
|
||||
));
|
||||
}
|
||||
self.backend
|
||||
.target
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|_| ConfigError::Invalid("backend.target must be a socket address".into()))?;
|
||||
if self.auth.clients.is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"auth.clients must contain at least one client".into(),
|
||||
));
|
||||
}
|
||||
if self.session.idle_timeout_secs == 0 {
|
||||
return Err(ConfigError::Invalid(
|
||||
"session.idle_timeout_secs must be > 0".into(),
|
||||
));
|
||||
}
|
||||
if self.limits.max_sessions == 0
|
||||
|| self.limits.max_sessions_per_ip == 0
|
||||
|| self.limits.max_pending_handshakes == 0
|
||||
{
|
||||
return Err(ConfigError::Invalid("session limits must be > 0".into()));
|
||||
}
|
||||
let mut client_ids = HashSet::with_capacity(self.auth.clients.len());
|
||||
for client in &self.auth.clients {
|
||||
if client.id.trim().is_empty() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"auth client id must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if !client_ids.insert(client.id.as_str()) {
|
||||
return Err(ConfigError::Invalid(format!(
|
||||
"duplicate auth client id {:?}",
|
||||
client.id
|
||||
)));
|
||||
}
|
||||
validate_secret_source(&client.secret, &format!("auth client {}", client.id))?;
|
||||
}
|
||||
self.tls.validate()?;
|
||||
if (self.transports.quic.enabled
|
||||
|| self.transports.http3.enabled
|
||||
|| self.transports.tls_tcp.enabled)
|
||||
&& self.tls.certificate.is_none()
|
||||
{
|
||||
return Err(ConfigError::Invalid(
|
||||
"tls.certificate and tls.private_key are required when a TLS transport is enabled"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerListenConfig {
|
||||
pub listen: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct BackendConfig {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct ServerTransportsConfig {
|
||||
#[serde(default)]
|
||||
pub direct_udp: ServerDirectUdpConfig,
|
||||
#[serde(default)]
|
||||
pub quic: ServerQuicConfig,
|
||||
#[serde(default)]
|
||||
pub http3: ServerHttp3Config,
|
||||
#[serde(default)]
|
||||
pub tls_tcp: ServerTlsTcpConfig,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerDirectUdpConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_direct_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
impl Default for ServerDirectUdpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
port: default_direct_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerQuicConfig {
|
||||
#[serde(default = "default_disabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
impl Default for ServerQuicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: default_tls_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerHttp3Config {
|
||||
#[serde(default = "default_disabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_http3_path")]
|
||||
pub path: String,
|
||||
}
|
||||
impl Default for ServerHttp3Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: default_tls_port(),
|
||||
path: default_http3_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ServerTlsTcpConfig {
|
||||
#[serde(default = "default_disabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_tls_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
impl Default for ServerTlsTcpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
port: default_tls_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
pub certificate: Option<PathBuf>,
|
||||
pub private_key: Option<PathBuf>,
|
||||
}
|
||||
impl TlsConfig {
|
||||
fn validate(&self) -> Result<(), ConfigError> {
|
||||
if self.certificate.is_some() != self.private_key.is_some() {
|
||||
return Err(ConfigError::Invalid(
|
||||
"tls.certificate and tls.private_key must be provided together".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SessionConfig {
|
||||
#[serde(default = "default_idle_timeout")]
|
||||
pub idle_timeout_secs: u64,
|
||||
}
|
||||
impl Default for SessionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout_secs: default_idle_timeout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LimitsConfig {
|
||||
#[serde(default = "default_max_sessions")]
|
||||
pub max_sessions: usize,
|
||||
#[serde(default = "default_max_sessions_per_ip")]
|
||||
pub max_sessions_per_ip: usize,
|
||||
#[serde(default = "default_max_pending_handshakes")]
|
||||
pub max_pending_handshakes: usize,
|
||||
#[serde(default = "default_handshake_timeout_ms")]
|
||||
pub handshake_timeout_ms: u64,
|
||||
}
|
||||
impl Default for LimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_sessions: default_max_sessions(),
|
||||
max_sessions_per_ip: default_max_sessions_per_ip(),
|
||||
max_pending_handshakes: default_max_pending_handshakes(),
|
||||
handshake_timeout_ms: default_handshake_timeout_ms(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub clients: Vec<AuthClientConfig>,
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AuthClientConfig {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub secret: SecretSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
#[serde(default = "default_log_level")]
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SecretSource {
|
||||
pub secret_file: Option<PathBuf>,
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
impl SecretSource {
|
||||
pub fn load(&self, label: &str) -> Result<SecretString, ConfigError> {
|
||||
match (&self.secret_file, &self.secret) {
|
||||
(Some(_), Some(_)) => Err(ConfigError::Invalid(format!(
|
||||
"{label} must use either secret_file or secret, not both"
|
||||
))),
|
||||
(Some(path), None) => {
|
||||
let bytes = read_secret_file(path)?;
|
||||
let value = String::from_utf8(bytes).map_err(|_| {
|
||||
ConfigError::Invalid(format!("{label} secret file is not UTF-8"))
|
||||
})?;
|
||||
let value = value.trim().to_owned();
|
||||
ensure_secret_length(&value, label)?;
|
||||
Ok(SecretString::new(value))
|
||||
}
|
||||
(None, Some(value)) => {
|
||||
warn!(
|
||||
label,
|
||||
"inline secret configured; use secret_file for production"
|
||||
);
|
||||
ensure_secret_length(value, label)?;
|
||||
Ok(SecretString::new(value.clone()))
|
||||
}
|
||||
(None, None) => Err(ConfigError::Invalid(format!(
|
||||
"{label} requires secret_file or secret"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_bytes(&self, label: &str) -> Result<Vec<u8>, ConfigError> {
|
||||
let value = self.load(label)?;
|
||||
decode_secret(value.expose_secret(), label)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_secret() -> String {
|
||||
let mut bytes = [0u8; MIN_SECRET_BYTES];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn init_tracing(level: &str) -> anyhow::Result<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_new(level)
|
||||
.or_else(|_| tracing_subscriber::EnvFilter::try_new("info"))?;
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(false)
|
||||
.try_init()
|
||||
.ok();
|
||||
info!(level, "structured logging initialized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_secret_source(source: &SecretSource, label: &str) -> Result<(), ConfigError> {
|
||||
match (&source.secret_file, &source.secret) {
|
||||
(Some(_), Some(_)) => Err(ConfigError::Invalid(format!(
|
||||
"{label} must use either secret_file or secret, not both"
|
||||
))),
|
||||
(Some(path), None) => {
|
||||
let bytes = read_secret_file(path)?;
|
||||
let value = String::from_utf8(bytes)
|
||||
.map_err(|_| ConfigError::Invalid(format!("{label} secret file is not UTF-8")))?;
|
||||
ensure_secret_length(value.trim(), label)
|
||||
}
|
||||
(None, Some(value)) => {
|
||||
warn!(
|
||||
label,
|
||||
"inline secret configured; use secret_file for production"
|
||||
);
|
||||
ensure_secret_length(value, label)
|
||||
}
|
||||
(None, None) => Err(ConfigError::Invalid(format!(
|
||||
"{label} requires secret_file or secret"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_secret_file(path: &Path) -> Result<Vec<u8>, ConfigError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let metadata = fs::metadata(path).map_err(|source| ConfigError::SecretRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(ConfigError::SecretPermissions {
|
||||
path: path.to_path_buf(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
fs::read(path).map_err(|source| ConfigError::SecretRead {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_secret(value: &str, label: &str) -> Result<Vec<u8>, ConfigError> {
|
||||
use base64::Engine;
|
||||
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(value) {
|
||||
if bytes.len() >= MIN_SECRET_BYTES {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
if let Ok(bytes) = hex::decode(value) {
|
||||
if bytes.len() >= MIN_SECRET_BYTES {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
if value.len() >= MIN_SECRET_BYTES {
|
||||
return Ok(value.as_bytes().to_vec());
|
||||
}
|
||||
Err(ConfigError::SecretTooShort {
|
||||
label: label.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_secret_length(value: &str, label: &str) -> Result<(), ConfigError> {
|
||||
decode_secret(value, label).map(|_| ())
|
||||
}
|
||||
|
||||
fn deserialize_socket_addr<'de, D>(deserializer: D) -> Result<SocketAddr, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
fn default_local_listen() -> SocketAddr {
|
||||
"127.0.0.1:51821".parse().expect("valid default")
|
||||
}
|
||||
fn default_transport_order() -> Vec<TransportMode> {
|
||||
vec![
|
||||
TransportMode::DirectUdp,
|
||||
TransportMode::Quic,
|
||||
TransportMode::Http3,
|
||||
TransportMode::TlsTcp,
|
||||
]
|
||||
}
|
||||
fn default_probe_timeout_ms() -> u64 {
|
||||
1500
|
||||
}
|
||||
fn default_retries() -> u8 {
|
||||
2
|
||||
}
|
||||
fn default_direct_port() -> u16 {
|
||||
51830
|
||||
}
|
||||
fn default_tls_port() -> u16 {
|
||||
443
|
||||
}
|
||||
fn default_keepalive() -> u64 {
|
||||
15
|
||||
}
|
||||
fn default_http3_path() -> String {
|
||||
"/tunnel".to_owned()
|
||||
}
|
||||
fn default_max_datagram_size() -> u16 {
|
||||
DEFAULT_MAX_DATAGRAM_SIZE
|
||||
}
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_disabled() -> bool {
|
||||
false
|
||||
}
|
||||
fn default_idle_timeout() -> u64 {
|
||||
120
|
||||
}
|
||||
fn default_max_sessions() -> usize {
|
||||
1024
|
||||
}
|
||||
fn default_max_sessions_per_ip() -> usize {
|
||||
32
|
||||
}
|
||||
fn default_max_pending_handshakes() -> usize {
|
||||
256
|
||||
}
|
||||
fn default_handshake_timeout_ms() -> u64 {
|
||||
3000
|
||||
}
|
||||
fn default_log_level() -> String {
|
||||
"info".to_owned()
|
||||
}
|
||||
fn validate_max_datagram(value: u16) -> Result<(), ConfigError> {
|
||||
if value == 0 {
|
||||
Err(ConfigError::Invalid(
|
||||
"network.max_datagram_size must be > 0".into(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fs, os::unix::fs::PermissionsExt};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const SECRET: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
|
||||
#[test]
|
||||
fn client_defaults_and_validation_work() {
|
||||
let text = format!("[client]\nserver='vpn.example.com'\nid='laptop'\nsecret='{SECRET}'\n");
|
||||
let config = Config::from_str(&text).unwrap();
|
||||
config.validate().unwrap();
|
||||
let client = config.into_client().unwrap();
|
||||
assert_eq!(client.local.listen, "127.0.0.1:51821".parse().unwrap());
|
||||
assert_eq!(client.network.max_datagram_size, 1200);
|
||||
assert_eq!(client.transport.mode, TransportMode::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_client_is_rejected() {
|
||||
let config = Config::from_str("[client]\nserver=''\nid='x'\nsecret='short'\n").unwrap();
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_example_parses() {
|
||||
let text = format!("[server]\nlisten='0.0.0.0'\n[backend]\ntarget='127.0.0.1:51820'\n[auth]\n[[auth.clients]]\nid='laptop'\nsecret='{SECRET}'\n");
|
||||
let config = Config::from_str(&text).unwrap();
|
||||
config.validate().unwrap();
|
||||
assert_eq!(config.role_name(), "server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_auth_client_ids_are_rejected() {
|
||||
let text = format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='same'\nsecret='{SECRET}'\n[[auth.clients]]\nid='same'\nsecret='{SECRET}'\n"
|
||||
);
|
||||
let config = Config::from_str(&text).unwrap();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(ConfigError::Invalid(message)) if message.contains("duplicate")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_only_server_does_not_require_tls_identity() {
|
||||
let text = format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[[auth.clients]]\nid='one'\nsecret='{SECRET}'\n"
|
||||
);
|
||||
Config::from_str(&text).unwrap().validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_tls_transport_requires_certificate_and_key() {
|
||||
let text = format!(
|
||||
"[server]\nlisten='127.0.0.1'\n[backend]\ntarget='127.0.0.1:51820'\n[transports.quic]\nenabled=true\n[[auth.clients]]\nid='one'\nsecret='{SECRET}'\n"
|
||||
);
|
||||
let config = Config::from_str(&text).unwrap();
|
||||
assert!(matches!(
|
||||
config.validate(),
|
||||
Err(ConfigError::Invalid(message)) if message.contains("tls.certificate")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_file_must_be_private() {
|
||||
let file = NamedTempFile::new().unwrap();
|
||||
fs::write(file.path(), SECRET).unwrap();
|
||||
fs::set_permissions(file.path(), fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let source = SecretSource {
|
||||
secret_file: Some(file.path().into()),
|
||||
secret: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
source.load("test"),
|
||||
Err(ConfigError::SecretPermissions { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
//! Bounded newline-delimited JSON control socket for local administration.
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
|
||||
net::{UnixListener, UnixStream},
|
||||
sync::{Mutex, Notify},
|
||||
task::JoinSet,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlState {
|
||||
status: Arc<Mutex<String>>,
|
||||
stats: Arc<Mutex<String>>,
|
||||
pub reconnect: Arc<Notify>,
|
||||
pub reload: Arc<Notify>,
|
||||
pub shutdown: CancellationToken,
|
||||
}
|
||||
impl Default for ControlState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new("starting".into())),
|
||||
stats: Arc::new(Mutex::new("{}".into())),
|
||||
reconnect: Arc::new(Notify::new()),
|
||||
reload: Arc::new(Notify::new()),
|
||||
shutdown: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ControlState {
|
||||
pub async fn set_status(&self, value: impl Into<String>) {
|
||||
*self.status.lock().await = value.into();
|
||||
}
|
||||
pub async fn set_stats_json(&self, value: impl Into<String>) {
|
||||
*self.stats.lock().await = value.into();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ControlServer {
|
||||
listener: UnixListener,
|
||||
path: PathBuf,
|
||||
state: ControlState,
|
||||
}
|
||||
impl ControlServer {
|
||||
pub async fn bind(path: impl Into<PathBuf>, state: ControlState) -> io::Result<Self> {
|
||||
let path = path.into();
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&path) {
|
||||
if !metadata.file_type().is_socket() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"control path is not a socket",
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(Self {
|
||||
listener,
|
||||
path,
|
||||
state,
|
||||
})
|
||||
}
|
||||
pub async fn run(self) -> io::Result<()> {
|
||||
let mut tasks = JoinSet::new();
|
||||
loop {
|
||||
tokio::select! { _ = self.state.shutdown.cancelled() => break, Some(result) = tasks.join_next() => { if let Err(error) = result { tracing::debug!(%error, "control connection ended"); } }, accepted = self.listener.accept() => { let (stream, _) = accepted?; let state = self.state.clone(); tasks.spawn(async move { let _ = handle_connection(stream, state).await; }); } }
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for ControlServer {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: UnixStream, state: ControlState) -> io::Result<()> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut reader = BufReader::new(read).take(8192);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).await?;
|
||||
let response = if line.len() >= 8192 {
|
||||
"{\"ok\":false,\"error\":\"request too large\"}\n".to_owned()
|
||||
} else {
|
||||
match command_name(&line) {
|
||||
Some("status") => {
|
||||
let value = state.status.lock().await.clone();
|
||||
format!("{{\"ok\":true,\"status\":\"{}\"}}\n", json_escape(&value))
|
||||
}
|
||||
Some("stats") => format!("{{\"ok\":true,\"stats\":{}}}\n", state.stats.lock().await),
|
||||
Some("reconnect") => {
|
||||
state.reconnect.notify_one();
|
||||
"{\"ok\":true,\"command\":\"reconnect\"}\n".into()
|
||||
}
|
||||
Some("reload") => {
|
||||
state.reload.notify_one();
|
||||
"{\"ok\":true,\"command\":\"reload\"}\n".into()
|
||||
}
|
||||
Some("shutdown") => {
|
||||
state.shutdown.cancel();
|
||||
"{\"ok\":true,\"command\":\"shutdown\"}\n".into()
|
||||
}
|
||||
_ => "{\"ok\":false,\"error\":\"unknown command\"}\n".into(),
|
||||
}
|
||||
};
|
||||
write.write_all(response.as_bytes()).await
|
||||
}
|
||||
|
||||
fn command_name(input: &str) -> Option<&str> {
|
||||
let input = input.trim();
|
||||
if matches!(
|
||||
input,
|
||||
"status" | "stats" | "reconnect" | "reload" | "shutdown"
|
||||
) {
|
||||
return Some(input);
|
||||
}
|
||||
let rest = input
|
||||
.split_once("\"command\"")?
|
||||
.1
|
||||
.trim_start()
|
||||
.strip_prefix(':')?
|
||||
.trim_start()
|
||||
.strip_prefix('"')?;
|
||||
let command = &rest[..rest.find('"')?];
|
||||
if matches!(
|
||||
command,
|
||||
"status" | "stats" | "reconnect" | "reload" | "shutdown"
|
||||
) {
|
||||
Some(command)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn json_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
}
|
||||
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("wgcloak.sock")
|
||||
}
|
||||
pub async fn send_command(path: impl AsRef<Path>, command: &str) -> io::Result<String> {
|
||||
let mut stream = UnixStream::connect(path).await?;
|
||||
stream
|
||||
.write_all(format!("{{\"command\":\"{}\"}}\n", command).as_bytes())
|
||||
.await?;
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
Ok(String::from_utf8_lossy(&response).into_owned())
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use bytes::Bytes;
|
||||
|
||||
/// An opaque application UDP datagram.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Datagram {
|
||||
pub payload: Bytes,
|
||||
}
|
||||
|
||||
impl Datagram {
|
||||
pub fn new(payload: impl Into<Bytes>) -> Self {
|
||||
Self {
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.payload.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.payload.is_empty()
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
mod cli;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod datagram;
|
||||
pub mod session;
|
||||
pub mod stats;
|
||||
|
||||
pub mod client;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod transport;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command};
|
||||
use config::Config;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Client { config } => {
|
||||
let parsed = Config::from_file(&config)?;
|
||||
let client = parsed.into_client().map_err(anyhow::Error::msg)?;
|
||||
config::init_tracing(&client.logging.level)?;
|
||||
client::run(client).await
|
||||
}
|
||||
Command::Server { config } => {
|
||||
let parsed = Config::from_file(&config)?;
|
||||
let server = parsed.into_server().map_err(anyhow::Error::msg)?;
|
||||
config::init_tracing(&server.logging.level)?;
|
||||
server::run(server).await
|
||||
}
|
||||
Command::CheckConfig { config } => {
|
||||
let parsed = Config::from_file(&config)?;
|
||||
let level = match &parsed {
|
||||
Config::Client(value) => &value.logging.level,
|
||||
Config::Server(value) => &value.logging.level,
|
||||
};
|
||||
config::init_tracing(level)?;
|
||||
parsed.validate().map_err(anyhow::Error::msg)?;
|
||||
println!("configuration is valid ({})", parsed.role_name());
|
||||
Ok(())
|
||||
}
|
||||
Command::GenerateSecret => {
|
||||
println!("{}", config::generate_secret());
|
||||
Ok(())
|
||||
}
|
||||
Command::Status { socket } => control_command(socket, "status").await,
|
||||
Command::Stats { socket } => control_command(socket, "stats").await,
|
||||
Command::Reconnect { socket } => control_command(socket, "reconnect").await,
|
||||
Command::Reload { socket } => control_command(socket, "reload").await,
|
||||
Command::Shutdown { socket } => control_command(socket, "shutdown").await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn control_command(socket: Option<std::path::PathBuf>, command: &str) -> Result<()> {
|
||||
let response =
|
||||
control::send_command(socket.unwrap_or_else(control::default_socket_path), command).await?;
|
||||
print!("{response}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Cryptographic operations used by the direct protocol.
|
||||
//!
|
||||
//! This module only composes maintained Rust crypto crates. It does not
|
||||
//! implement any cryptographic primitive itself.
|
||||
|
||||
use hkdf::Hkdf;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use super::direct::PacketType;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
pub const HMAC_LEN: usize = 32;
|
||||
pub const DATA_TAG_LEN: usize = 16;
|
||||
pub const SESSION_KEY_LEN: usize = 32;
|
||||
|
||||
/// Canonical HELLO authenticator input.
|
||||
///
|
||||
/// The fields are concatenated in this exact order. Integer fields are
|
||||
/// network byte order; the client-id length is deliberately not included,
|
||||
/// matching the protocol specification.
|
||||
pub fn hello_auth_input(
|
||||
version: u8,
|
||||
client_id: &[u8],
|
||||
timestamp: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
) -> Vec<u8> {
|
||||
let mut input = Vec::with_capacity(1 + client_id.len() + 8 + client_nonce.len());
|
||||
input.push(version);
|
||||
input.extend_from_slice(client_id);
|
||||
input.extend_from_slice(×tamp.to_be_bytes());
|
||||
input.extend_from_slice(client_nonce);
|
||||
input
|
||||
}
|
||||
|
||||
pub fn hello_authenticator(
|
||||
client_secret: &[u8],
|
||||
version: u8,
|
||||
client_id: &[u8],
|
||||
timestamp: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
) -> [u8; HMAC_LEN] {
|
||||
hmac_sha256(
|
||||
client_secret,
|
||||
&hello_auth_input(version, client_id, timestamp, client_nonce),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_hello_authenticator(
|
||||
client_secret: &[u8],
|
||||
version: u8,
|
||||
client_id: &[u8],
|
||||
timestamp: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
received: &[u8; HMAC_LEN],
|
||||
) -> bool {
|
||||
let expected = hello_authenticator(client_secret, version, client_id, timestamp, client_nonce);
|
||||
expected.ct_eq(received).into()
|
||||
}
|
||||
|
||||
/// Canonical ACCEPT authenticator input.
|
||||
pub fn accept_auth_input(
|
||||
session_id: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
server_nonce: &[u8; 16],
|
||||
idle_timeout: u32,
|
||||
max_datagram: u16,
|
||||
) -> [u8; 8 + 16 + 16 + 4 + 2] {
|
||||
let mut input = [0u8; 46];
|
||||
let mut at = 0;
|
||||
input[at..at + 8].copy_from_slice(&session_id.to_be_bytes());
|
||||
at += 8;
|
||||
input[at..at + 16].copy_from_slice(client_nonce);
|
||||
at += 16;
|
||||
input[at..at + 16].copy_from_slice(server_nonce);
|
||||
at += 16;
|
||||
input[at..at + 4].copy_from_slice(&idle_timeout.to_be_bytes());
|
||||
at += 4;
|
||||
input[at..at + 2].copy_from_slice(&max_datagram.to_be_bytes());
|
||||
input
|
||||
}
|
||||
|
||||
pub fn accept_authenticator(
|
||||
client_secret: &[u8],
|
||||
session_id: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
server_nonce: &[u8; 16],
|
||||
idle_timeout: u32,
|
||||
max_datagram: u16,
|
||||
) -> [u8; HMAC_LEN] {
|
||||
hmac_sha256(
|
||||
client_secret,
|
||||
&accept_auth_input(
|
||||
session_id,
|
||||
client_nonce,
|
||||
server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_accept_authenticator(
|
||||
client_secret: &[u8],
|
||||
session_id: u64,
|
||||
client_nonce: &[u8; 16],
|
||||
server_nonce: &[u8; 16],
|
||||
idle_timeout: u32,
|
||||
max_datagram: u16,
|
||||
received: &[u8; HMAC_LEN],
|
||||
) -> bool {
|
||||
let expected = accept_authenticator(
|
||||
client_secret,
|
||||
session_id,
|
||||
client_nonce,
|
||||
server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
);
|
||||
expected.ct_eq(received).into()
|
||||
}
|
||||
|
||||
/// Derive the 32-byte per-session key.
|
||||
///
|
||||
/// HKDF-SHA256 uses the configured secret as input keying material and the
|
||||
/// handshake transcript as the info/context. No key material is returned to
|
||||
/// the wire. The context is the exact concatenation specified on the wire:
|
||||
/// client nonce, server nonce, and big-endian session ID.
|
||||
pub fn derive_session_key(
|
||||
client_secret: &[u8],
|
||||
client_nonce: &[u8; 16],
|
||||
server_nonce: &[u8; 16],
|
||||
session_id: u64,
|
||||
) -> [u8; SESSION_KEY_LEN] {
|
||||
let mut context = Vec::with_capacity(16 + 16 + 8);
|
||||
context.extend_from_slice(client_nonce);
|
||||
context.extend_from_slice(server_nonce);
|
||||
context.extend_from_slice(&session_id.to_be_bytes());
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(None, client_secret);
|
||||
let mut key = [0u8; SESSION_KEY_LEN];
|
||||
// The fixed-size output and fixed info cannot fail with the HKDF SHA-256
|
||||
// limits. Keep the assertion local so callers never receive a Result.
|
||||
hk.expand(&context, &mut key)
|
||||
.expect("fixed-size HKDF expansion is valid");
|
||||
key
|
||||
}
|
||||
|
||||
/// Canonical DATA-family MAC input: version, type, flags, session ID,
|
||||
/// sequence, then the opaque payload.
|
||||
pub fn data_auth_input(
|
||||
version: u8,
|
||||
packet_type: PacketType,
|
||||
flags: u8,
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
payload: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut input = Vec::with_capacity(1 + 1 + 1 + 8 + 4 + payload.len());
|
||||
input.push(version);
|
||||
input.push(packet_type.as_u8());
|
||||
input.push(flags);
|
||||
input.extend_from_slice(&session_id.to_be_bytes());
|
||||
input.extend_from_slice(&sequence.to_be_bytes());
|
||||
input.extend_from_slice(payload);
|
||||
input
|
||||
}
|
||||
|
||||
pub fn data_auth_tag(
|
||||
session_key: &[u8],
|
||||
version: u8,
|
||||
packet_type: PacketType,
|
||||
flags: u8,
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
payload: &[u8],
|
||||
) -> [u8; DATA_TAG_LEN] {
|
||||
let full = hmac_sha256(
|
||||
session_key,
|
||||
&data_auth_input(version, packet_type, flags, session_id, sequence, payload),
|
||||
);
|
||||
let mut tag = [0u8; DATA_TAG_LEN];
|
||||
tag.copy_from_slice(&full[..DATA_TAG_LEN]);
|
||||
tag
|
||||
}
|
||||
|
||||
pub fn verify_data_auth_tag(
|
||||
session_key: &[u8],
|
||||
version: u8,
|
||||
packet_type: PacketType,
|
||||
flags: u8,
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
payload: &[u8],
|
||||
received: &[u8; DATA_TAG_LEN],
|
||||
) -> bool {
|
||||
let expected = data_auth_tag(
|
||||
session_key,
|
||||
version,
|
||||
packet_type,
|
||||
flags,
|
||||
session_id,
|
||||
sequence,
|
||||
payload,
|
||||
);
|
||||
expected.ct_eq(received).into()
|
||||
}
|
||||
|
||||
pub fn hmac_sha256(key: &[u8], input: &[u8]) -> [u8; HMAC_LEN] {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of every length");
|
||||
mac.update(input);
|
||||
let digest = mac.finalize().into_bytes();
|
||||
let mut output = [0u8; HMAC_LEN];
|
||||
output.copy_from_slice(&digest);
|
||||
output
|
||||
}
|
||||
|
||||
/// Generate a cryptographically random, non-zero session or sequence value.
|
||||
pub fn random_nonzero_u64() -> u64 {
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
let mut rng = OsRng;
|
||||
loop {
|
||||
let value = rng.next_u64();
|
||||
if value != 0 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn random_nonzero_u32() -> u32 {
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
let mut rng = OsRng;
|
||||
loop {
|
||||
let value = rng.next_u32();
|
||||
if value != 0 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_session_id() -> u64 {
|
||||
random_nonzero_u64()
|
||||
}
|
||||
|
||||
pub fn generate_sequence_number() -> u32 {
|
||||
random_nonzero_u32()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::PROTOCOL_VERSION;
|
||||
|
||||
#[test]
|
||||
fn hmac_and_verification_round_trip() {
|
||||
let nonce = [7u8; 16];
|
||||
let tag = hello_authenticator(b"secret", PROTOCOL_VERSION, b"client", 42, &nonce);
|
||||
assert!(verify_hello_authenticator(
|
||||
b"secret",
|
||||
PROTOCOL_VERSION,
|
||||
b"client",
|
||||
42,
|
||||
&nonce,
|
||||
&tag
|
||||
));
|
||||
assert!(!verify_hello_authenticator(
|
||||
b"wrong",
|
||||
PROTOCOL_VERSION,
|
||||
b"client",
|
||||
42,
|
||||
&nonce,
|
||||
&tag
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_derivation_is_deterministic_and_context_bound() {
|
||||
let key = derive_session_key(b"secret", &[1; 16], &[2; 16], 3);
|
||||
assert_eq!(key, derive_session_key(b"secret", &[1; 16], &[2; 16], 3));
|
||||
assert_ne!(key, derive_session_key(b"secret", &[1; 16], &[2; 16], 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_tag_covers_header_fields_and_payload() {
|
||||
let tag = data_auth_tag(b"k", PROTOCOL_VERSION, PacketType::Data, 0, 9, 4, b"x");
|
||||
assert!(verify_data_auth_tag(
|
||||
b"k",
|
||||
PROTOCOL_VERSION,
|
||||
PacketType::Data,
|
||||
0,
|
||||
9,
|
||||
4,
|
||||
b"x",
|
||||
&tag
|
||||
));
|
||||
assert!(!verify_data_auth_tag(
|
||||
b"k",
|
||||
PROTOCOL_VERSION,
|
||||
PacketType::Data,
|
||||
1,
|
||||
9,
|
||||
4,
|
||||
b"x",
|
||||
&tag
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
//! Deterministic direct UDP packet encoding and parsing.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::auth;
|
||||
|
||||
pub const PROTOCOL_VERSION: u8 = 1;
|
||||
pub const MAX_PACKET_SIZE: usize = u16::MAX as usize;
|
||||
// 255 (u8 hdr_len maximum) - 69 bytes of fixed HELLO fields.
|
||||
pub const MAX_CLIENT_ID_LEN: usize = 186;
|
||||
pub const COMMON_HEADER_LEN: usize = 12;
|
||||
pub const DATA_HEADER_LEN: usize = 32;
|
||||
|
||||
const HELLO_FIXED_LEN: usize = COMMON_HEADER_LEN + 1 + 8 + 16 + 32;
|
||||
const ACCEPT_LEN: usize = COMMON_HEADER_LEN + 16 + 4 + 2 + 32;
|
||||
const REJECT_LEN: usize = COMMON_HEADER_LEN + 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum PacketType {
|
||||
Hello = 0x01,
|
||||
Accept = 0x02,
|
||||
Reject = 0x03,
|
||||
Data = 0x10,
|
||||
Keepalive = 0x11,
|
||||
Close = 0x12,
|
||||
}
|
||||
|
||||
impl PacketType {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0x01 => Some(Self::Hello),
|
||||
0x02 => Some(Self::Accept),
|
||||
0x03 => Some(Self::Reject),
|
||||
0x10 => Some(Self::Data),
|
||||
0x11 => Some(Self::Keepalive),
|
||||
0x12 => Some(Self::Close),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum RejectReason {
|
||||
UnsupportedVersion = 0x01,
|
||||
UnknownClient = 0x02,
|
||||
AuthenticationFailed = 0x03,
|
||||
ReplayDetected = 0x04,
|
||||
ServerBusy = 0x05,
|
||||
RateLimited = 0x06,
|
||||
}
|
||||
|
||||
impl RejectReason {
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub const fn from_u8(value: u8) -> Option<Self> {
|
||||
match value {
|
||||
0x01 => Some(Self::UnsupportedVersion),
|
||||
0x02 => Some(Self::UnknownClient),
|
||||
0x03 => Some(Self::AuthenticationFailed),
|
||||
0x04 => Some(Self::ReplayDetected),
|
||||
0x05 => Some(Self::ServerBusy),
|
||||
0x06 => Some(Self::RateLimited),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
pub enum ProtocolError {
|
||||
#[error("packet is truncated (need at least {needed} bytes, got {actual})")]
|
||||
Truncated { needed: usize, actual: usize },
|
||||
#[error("packet exceeds maximum size of {maximum} bytes")]
|
||||
Oversized { maximum: usize },
|
||||
#[error("unsupported protocol version {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("unknown packet type 0x{0:02x}")]
|
||||
UnknownPacketType(u8),
|
||||
#[error("invalid header length {actual} for {packet_type:?}, expected {expected}")]
|
||||
InvalidHeaderLength {
|
||||
packet_type: PacketType,
|
||||
actual: usize,
|
||||
expected: usize,
|
||||
},
|
||||
#[error("invalid packet length {actual}, expected {expected}")]
|
||||
InvalidPacketLength { actual: usize, expected: usize },
|
||||
#[error("HELLO client id is too long")]
|
||||
ClientIdTooLong,
|
||||
#[error("HELLO must have a zero session id")]
|
||||
HelloSessionNotZero,
|
||||
#[error("session packet must have a non-zero session id")]
|
||||
SessionIdZero,
|
||||
#[error("unknown reject reason 0x{0:02x}")]
|
||||
UnknownRejectReason(u8),
|
||||
#[error("packet cannot be encoded with an invalid field")]
|
||||
InvalidField,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HelloPacket {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub client_id: Bytes,
|
||||
pub timestamp: u64,
|
||||
pub client_nonce: [u8; 16],
|
||||
pub authenticator: [u8; 32],
|
||||
}
|
||||
|
||||
impl HelloPacket {
|
||||
pub fn new(
|
||||
client_id: impl Into<Bytes>,
|
||||
timestamp: u64,
|
||||
client_nonce: [u8; 16],
|
||||
authenticator: [u8; 32],
|
||||
) -> Result<Self, ProtocolError> {
|
||||
let client_id = client_id.into();
|
||||
if client_id.len() > MAX_CLIENT_ID_LEN {
|
||||
return Err(ProtocolError::ClientIdTooLong);
|
||||
}
|
||||
Ok(Self {
|
||||
version: PROTOCOL_VERSION,
|
||||
flags: 0,
|
||||
client_id,
|
||||
timestamp,
|
||||
client_nonce,
|
||||
authenticator,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn authenticated(
|
||||
client_secret: &[u8],
|
||||
client_id: impl Into<Bytes>,
|
||||
timestamp: u64,
|
||||
client_nonce: [u8; 16],
|
||||
) -> Result<Self, ProtocolError> {
|
||||
let client_id = client_id.into();
|
||||
let authenticator = auth::hello_authenticator(
|
||||
client_secret,
|
||||
PROTOCOL_VERSION,
|
||||
&client_id,
|
||||
timestamp,
|
||||
&client_nonce,
|
||||
);
|
||||
Self::new(client_id, timestamp, client_nonce, authenticator)
|
||||
}
|
||||
|
||||
pub fn verify_authenticator(&self, client_secret: &[u8]) -> bool {
|
||||
auth::verify_hello_authenticator(
|
||||
client_secret,
|
||||
self.version,
|
||||
&self.client_id,
|
||||
self.timestamp,
|
||||
&self.client_nonce,
|
||||
&self.authenticator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_fresh(&self, now: SystemTime, allowed_clock_skew: Duration) -> bool {
|
||||
let Ok(now) = now.duration_since(UNIX_EPOCH) else {
|
||||
return false;
|
||||
};
|
||||
let now = now.as_secs();
|
||||
if self.timestamp >= now {
|
||||
self.timestamp - now <= allowed_clock_skew.as_secs()
|
||||
} else {
|
||||
now - self.timestamp <= allowed_clock_skew.as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
encode_hello(self)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.encode()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AcceptPacket {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub session_id: u64,
|
||||
pub server_nonce: [u8; 16],
|
||||
pub idle_timeout: u32,
|
||||
pub max_datagram: u16,
|
||||
pub authenticator: [u8; 32],
|
||||
}
|
||||
|
||||
impl AcceptPacket {
|
||||
pub fn new(
|
||||
session_id: u64,
|
||||
server_nonce: [u8; 16],
|
||||
idle_timeout: u32,
|
||||
max_datagram: u16,
|
||||
authenticator: [u8; 32],
|
||||
) -> Self {
|
||||
Self {
|
||||
version: PROTOCOL_VERSION,
|
||||
flags: 0,
|
||||
session_id,
|
||||
server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
authenticator,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated(
|
||||
client_secret: &[u8],
|
||||
session_id: u64,
|
||||
client_nonce: [u8; 16],
|
||||
server_nonce: [u8; 16],
|
||||
idle_timeout: u32,
|
||||
max_datagram: u16,
|
||||
) -> Self {
|
||||
let authenticator = auth::accept_authenticator(
|
||||
client_secret,
|
||||
session_id,
|
||||
&client_nonce,
|
||||
&server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
);
|
||||
Self::new(
|
||||
session_id,
|
||||
server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
authenticator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_authenticator(&self, client_secret: &[u8], client_nonce: &[u8; 16]) -> bool {
|
||||
auth::verify_accept_authenticator(
|
||||
client_secret,
|
||||
self.session_id,
|
||||
client_nonce,
|
||||
&self.server_nonce,
|
||||
self.idle_timeout,
|
||||
self.max_datagram,
|
||||
&self.authenticator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
encode_accept(self)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.encode()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RejectPacket {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub reason: RejectReason,
|
||||
}
|
||||
|
||||
impl RejectPacket {
|
||||
pub fn new(reason: RejectReason) -> Self {
|
||||
Self {
|
||||
version: PROTOCOL_VERSION,
|
||||
flags: 0,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
encode_reject(self)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.encode()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DataPacket {
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub session_id: u64,
|
||||
pub sequence: u32,
|
||||
pub auth_tag: [u8; 16],
|
||||
pub payload: Bytes,
|
||||
}
|
||||
|
||||
impl DataPacket {
|
||||
pub fn new(
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
payload: impl Into<Bytes>,
|
||||
auth_tag: [u8; 16],
|
||||
) -> Self {
|
||||
Self {
|
||||
version: PROTOCOL_VERSION,
|
||||
flags: 0,
|
||||
session_id,
|
||||
sequence,
|
||||
auth_tag,
|
||||
payload: payload.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated(
|
||||
session_key: &[u8],
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
payload: impl Into<Bytes>,
|
||||
) -> Self {
|
||||
let payload = payload.into();
|
||||
let auth_tag = auth::data_auth_tag(
|
||||
session_key,
|
||||
PROTOCOL_VERSION,
|
||||
PacketType::Data,
|
||||
0,
|
||||
session_id,
|
||||
sequence,
|
||||
&payload,
|
||||
);
|
||||
Self::new(session_id, sequence, payload, auth_tag)
|
||||
}
|
||||
|
||||
pub fn verify_authenticator(&self, session_key: &[u8]) -> bool {
|
||||
auth::verify_data_auth_tag(
|
||||
session_key,
|
||||
self.version,
|
||||
PacketType::Data,
|
||||
self.flags,
|
||||
self.session_id,
|
||||
self.sequence,
|
||||
&self.payload,
|
||||
&self.auth_tag,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
encode_data(self)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.encode()
|
||||
}
|
||||
}
|
||||
|
||||
/// KEEPALIVE and CLOSE have the same authenticated envelope as DATA but no
|
||||
/// payload. The packet variant selects which type is placed in the header.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionPacket {
|
||||
pub packet_type: PacketType,
|
||||
pub version: u8,
|
||||
pub flags: u8,
|
||||
pub session_id: u64,
|
||||
pub sequence: u32,
|
||||
pub auth_tag: [u8; 16],
|
||||
}
|
||||
|
||||
pub type KeepalivePacket = SessionPacket;
|
||||
pub type ClosePacket = SessionPacket;
|
||||
|
||||
impl SessionPacket {
|
||||
pub fn new(
|
||||
packet_type: PacketType,
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
auth_tag: [u8; 16],
|
||||
) -> Self {
|
||||
Self {
|
||||
packet_type,
|
||||
version: PROTOCOL_VERSION,
|
||||
flags: 0,
|
||||
session_id,
|
||||
sequence,
|
||||
auth_tag,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keepalive(session_id: u64, sequence: u32, auth_tag: [u8; 16]) -> Self {
|
||||
Self::new(PacketType::Keepalive, session_id, sequence, auth_tag)
|
||||
}
|
||||
|
||||
pub fn close(session_id: u64, sequence: u32, auth_tag: [u8; 16]) -> Self {
|
||||
Self::new(PacketType::Close, session_id, sequence, auth_tag)
|
||||
}
|
||||
|
||||
pub fn authenticated(
|
||||
packet_type: PacketType,
|
||||
session_key: &[u8],
|
||||
session_id: u64,
|
||||
sequence: u32,
|
||||
) -> Self {
|
||||
let auth_tag = auth::data_auth_tag(
|
||||
session_key,
|
||||
PROTOCOL_VERSION,
|
||||
packet_type,
|
||||
0,
|
||||
session_id,
|
||||
sequence,
|
||||
&[],
|
||||
);
|
||||
Self::new(packet_type, session_id, sequence, auth_tag)
|
||||
}
|
||||
|
||||
pub fn verify_authenticator(&self, session_key: &[u8]) -> bool {
|
||||
auth::verify_data_auth_tag(
|
||||
session_key,
|
||||
self.version,
|
||||
self.packet_type,
|
||||
self.flags,
|
||||
self.session_id,
|
||||
self.sequence,
|
||||
&[],
|
||||
&self.auth_tag,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
encode_session(self)
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
self.encode()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Packet {
|
||||
Hello(HelloPacket),
|
||||
Accept(AcceptPacket),
|
||||
Reject(RejectPacket),
|
||||
Data(DataPacket),
|
||||
Keepalive(SessionPacket),
|
||||
Close(SessionPacket),
|
||||
}
|
||||
|
||||
impl Packet {
|
||||
pub fn packet_type(&self) -> PacketType {
|
||||
match self {
|
||||
Self::Hello(_) => PacketType::Hello,
|
||||
Self::Accept(_) => PacketType::Accept,
|
||||
Self::Reject(_) => PacketType::Reject,
|
||||
Self::Data(_) => PacketType::Data,
|
||||
Self::Keepalive(_) => PacketType::Keepalive,
|
||||
Self::Close(_) => PacketType::Close,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> u64 {
|
||||
match self {
|
||||
Self::Hello(_) | Self::Reject(_) => 0,
|
||||
Self::Accept(packet) => packet.session_id,
|
||||
Self::Data(packet) => packet.session_id,
|
||||
Self::Keepalive(packet) | Self::Close(packet) => packet.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Result<Vec<u8>, ProtocolError> {
|
||||
match self {
|
||||
Self::Hello(packet) => packet.encode(),
|
||||
Self::Accept(packet) => packet.encode(),
|
||||
Self::Reject(packet) => packet.encode(),
|
||||
Self::Data(packet) => packet.encode(),
|
||||
Self::Keepalive(packet) | Self::Close(packet) => packet.encode(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_common(
|
||||
output: &mut Vec<u8>,
|
||||
version: u8,
|
||||
packet_type: PacketType,
|
||||
flags: u8,
|
||||
hdr_len: usize,
|
||||
session_id: u64,
|
||||
) {
|
||||
output.push(version);
|
||||
output.push(packet_type.as_u8());
|
||||
output.push(flags);
|
||||
output.push(hdr_len as u8);
|
||||
output.extend_from_slice(&session_id.to_be_bytes());
|
||||
}
|
||||
|
||||
fn encode_hello(packet: &HelloPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
if packet.client_id.len() > MAX_CLIENT_ID_LEN {
|
||||
return Err(ProtocolError::ClientIdTooLong);
|
||||
}
|
||||
if packet.version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(packet.version));
|
||||
}
|
||||
let hdr_len = HELLO_FIXED_LEN + packet.client_id.len();
|
||||
let mut output = Vec::with_capacity(hdr_len);
|
||||
write_common(
|
||||
&mut output,
|
||||
packet.version,
|
||||
PacketType::Hello,
|
||||
packet.flags,
|
||||
hdr_len,
|
||||
0,
|
||||
);
|
||||
output.push(packet.client_id.len() as u8);
|
||||
output.extend_from_slice(&packet.client_id);
|
||||
output.extend_from_slice(&packet.timestamp.to_be_bytes());
|
||||
output.extend_from_slice(&packet.client_nonce);
|
||||
output.extend_from_slice(&packet.authenticator);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_accept(packet: &AcceptPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
if packet.version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(packet.version));
|
||||
}
|
||||
if packet.session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
let mut output = Vec::with_capacity(ACCEPT_LEN);
|
||||
write_common(
|
||||
&mut output,
|
||||
packet.version,
|
||||
PacketType::Accept,
|
||||
packet.flags,
|
||||
ACCEPT_LEN,
|
||||
packet.session_id,
|
||||
);
|
||||
output.extend_from_slice(&packet.server_nonce);
|
||||
output.extend_from_slice(&packet.idle_timeout.to_be_bytes());
|
||||
output.extend_from_slice(&packet.max_datagram.to_be_bytes());
|
||||
output.extend_from_slice(&packet.authenticator);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_reject(packet: &RejectPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
if packet.version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(packet.version));
|
||||
}
|
||||
let mut output = Vec::with_capacity(REJECT_LEN);
|
||||
write_common(
|
||||
&mut output,
|
||||
packet.version,
|
||||
PacketType::Reject,
|
||||
packet.flags,
|
||||
REJECT_LEN,
|
||||
0,
|
||||
);
|
||||
output.push(packet.reason.as_u8());
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_data(packet: &DataPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
if packet.version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(packet.version));
|
||||
}
|
||||
if packet.session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
let total =
|
||||
DATA_HEADER_LEN
|
||||
.checked_add(packet.payload.len())
|
||||
.ok_or(ProtocolError::Oversized {
|
||||
maximum: MAX_PACKET_SIZE,
|
||||
})?;
|
||||
if total > MAX_PACKET_SIZE {
|
||||
return Err(ProtocolError::Oversized {
|
||||
maximum: MAX_PACKET_SIZE,
|
||||
});
|
||||
}
|
||||
let mut output = Vec::with_capacity(total);
|
||||
write_common(
|
||||
&mut output,
|
||||
packet.version,
|
||||
PacketType::Data,
|
||||
packet.flags,
|
||||
DATA_HEADER_LEN,
|
||||
packet.session_id,
|
||||
);
|
||||
output.extend_from_slice(&packet.sequence.to_be_bytes());
|
||||
output.extend_from_slice(&packet.auth_tag);
|
||||
output.extend_from_slice(&packet.payload);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn encode_session(packet: &SessionPacket) -> Result<Vec<u8>, ProtocolError> {
|
||||
if packet.version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(packet.version));
|
||||
}
|
||||
if packet.session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
if !matches!(
|
||||
packet.packet_type,
|
||||
PacketType::Keepalive | PacketType::Close
|
||||
) {
|
||||
return Err(ProtocolError::InvalidField);
|
||||
}
|
||||
let mut output = Vec::with_capacity(DATA_HEADER_LEN);
|
||||
write_common(
|
||||
&mut output,
|
||||
packet.version,
|
||||
packet.packet_type,
|
||||
packet.flags,
|
||||
DATA_HEADER_LEN,
|
||||
packet.session_id,
|
||||
);
|
||||
output.extend_from_slice(&packet.sequence.to_be_bytes());
|
||||
output.extend_from_slice(&packet.auth_tag);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn take<'a>(input: &'a [u8], at: &mut usize, len: usize) -> Result<&'a [u8], ProtocolError> {
|
||||
let end = at.checked_add(len).ok_or(ProtocolError::InvalidField)?;
|
||||
if end > input.len() {
|
||||
return Err(ProtocolError::Truncated {
|
||||
needed: end,
|
||||
actual: input.len(),
|
||||
});
|
||||
}
|
||||
let output = &input[*at..end];
|
||||
*at = end;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn array<const N: usize>(input: &[u8]) -> [u8; N] {
|
||||
let mut output = [0u8; N];
|
||||
output.copy_from_slice(input);
|
||||
output
|
||||
}
|
||||
|
||||
/// Parse one complete direct UDP datagram. All structural checks occur before
|
||||
/// the only potentially large allocation (copying a valid DATA payload).
|
||||
pub fn parse_packet(input: &[u8]) -> Result<Packet, ProtocolError> {
|
||||
if input.len() > MAX_PACKET_SIZE {
|
||||
return Err(ProtocolError::Oversized {
|
||||
maximum: MAX_PACKET_SIZE,
|
||||
});
|
||||
}
|
||||
if input.len() < COMMON_HEADER_LEN {
|
||||
return Err(ProtocolError::Truncated {
|
||||
needed: COMMON_HEADER_LEN,
|
||||
actual: input.len(),
|
||||
});
|
||||
}
|
||||
let version = input[0];
|
||||
if version != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion(version));
|
||||
}
|
||||
let packet_type =
|
||||
PacketType::from_u8(input[1]).ok_or(ProtocolError::UnknownPacketType(input[1]))?;
|
||||
let flags = input[2];
|
||||
let hdr_len = input[3] as usize;
|
||||
let session_id = u64::from_be_bytes(array(&input[4..12]));
|
||||
if hdr_len < COMMON_HEADER_LEN {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected: COMMON_HEADER_LEN,
|
||||
});
|
||||
}
|
||||
if hdr_len > input.len() {
|
||||
return Err(ProtocolError::Truncated {
|
||||
needed: hdr_len,
|
||||
actual: input.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut at = COMMON_HEADER_LEN;
|
||||
match packet_type {
|
||||
PacketType::Hello => {
|
||||
if session_id != 0 {
|
||||
return Err(ProtocolError::HelloSessionNotZero);
|
||||
}
|
||||
let id_len = *take(input, &mut at, 1)?.first().unwrap() as usize;
|
||||
if id_len > MAX_CLIENT_ID_LEN {
|
||||
return Err(ProtocolError::ClientIdTooLong);
|
||||
}
|
||||
let expected = HELLO_FIXED_LEN + id_len;
|
||||
if hdr_len != expected {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected,
|
||||
});
|
||||
}
|
||||
if input.len() != expected {
|
||||
return Err(ProtocolError::InvalidPacketLength {
|
||||
actual: input.len(),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
let client_id = Bytes::copy_from_slice(take(input, &mut at, id_len)?);
|
||||
let timestamp = u64::from_be_bytes(array(take(input, &mut at, 8)?));
|
||||
let client_nonce = array(take(input, &mut at, 16)?);
|
||||
let authenticator = array(take(input, &mut at, 32)?);
|
||||
Ok(Packet::Hello(HelloPacket {
|
||||
version,
|
||||
flags,
|
||||
client_id,
|
||||
timestamp,
|
||||
client_nonce,
|
||||
authenticator,
|
||||
}))
|
||||
}
|
||||
PacketType::Accept => {
|
||||
if session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
if hdr_len != ACCEPT_LEN {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected: ACCEPT_LEN,
|
||||
});
|
||||
}
|
||||
if input.len() != ACCEPT_LEN {
|
||||
return Err(ProtocolError::InvalidPacketLength {
|
||||
actual: input.len(),
|
||||
expected: ACCEPT_LEN,
|
||||
});
|
||||
}
|
||||
let server_nonce = array(take(input, &mut at, 16)?);
|
||||
let idle_timeout = u32::from_be_bytes(array(take(input, &mut at, 4)?));
|
||||
let max_datagram = u16::from_be_bytes(array(take(input, &mut at, 2)?));
|
||||
let authenticator = array(take(input, &mut at, 32)?);
|
||||
Ok(Packet::Accept(AcceptPacket {
|
||||
version,
|
||||
flags,
|
||||
session_id,
|
||||
server_nonce,
|
||||
idle_timeout,
|
||||
max_datagram,
|
||||
authenticator,
|
||||
}))
|
||||
}
|
||||
PacketType::Reject => {
|
||||
if session_id != 0 {
|
||||
return Err(ProtocolError::InvalidField);
|
||||
}
|
||||
if hdr_len != REJECT_LEN {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected: REJECT_LEN,
|
||||
});
|
||||
}
|
||||
if input.len() != REJECT_LEN {
|
||||
return Err(ProtocolError::InvalidPacketLength {
|
||||
actual: input.len(),
|
||||
expected: REJECT_LEN,
|
||||
});
|
||||
}
|
||||
let reason = RejectReason::from_u8(*take(input, &mut at, 1)?.first().unwrap())
|
||||
.ok_or(ProtocolError::UnknownRejectReason(input[12]))?;
|
||||
Ok(Packet::Reject(RejectPacket {
|
||||
version,
|
||||
flags,
|
||||
reason,
|
||||
}))
|
||||
}
|
||||
PacketType::Data => {
|
||||
if session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
if hdr_len != DATA_HEADER_LEN {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected: DATA_HEADER_LEN,
|
||||
});
|
||||
}
|
||||
let sequence = u32::from_be_bytes(array(take(input, &mut at, 4)?));
|
||||
let auth_tag = array(take(input, &mut at, 16)?);
|
||||
// The size check above makes this bound safe and caps the copy to
|
||||
// MAX_PACKET_SIZE - DATA_HEADER_LEN bytes.
|
||||
let payload = Bytes::copy_from_slice(&input[at..]);
|
||||
Ok(Packet::Data(DataPacket {
|
||||
version,
|
||||
flags,
|
||||
session_id,
|
||||
sequence,
|
||||
auth_tag,
|
||||
payload,
|
||||
}))
|
||||
}
|
||||
PacketType::Keepalive | PacketType::Close => {
|
||||
if session_id == 0 {
|
||||
return Err(ProtocolError::SessionIdZero);
|
||||
}
|
||||
if hdr_len != DATA_HEADER_LEN {
|
||||
return Err(ProtocolError::InvalidHeaderLength {
|
||||
packet_type,
|
||||
actual: hdr_len,
|
||||
expected: DATA_HEADER_LEN,
|
||||
});
|
||||
}
|
||||
if input.len() != DATA_HEADER_LEN {
|
||||
return Err(ProtocolError::InvalidPacketLength {
|
||||
actual: input.len(),
|
||||
expected: DATA_HEADER_LEN,
|
||||
});
|
||||
}
|
||||
let sequence = u32::from_be_bytes(array(take(input, &mut at, 4)?));
|
||||
let auth_tag = array(take(input, &mut at, 16)?);
|
||||
let packet = SessionPacket {
|
||||
packet_type,
|
||||
version,
|
||||
flags,
|
||||
session_id,
|
||||
sequence,
|
||||
auth_tag,
|
||||
};
|
||||
if packet_type == PacketType::Keepalive {
|
||||
Ok(Packet::Keepalive(packet))
|
||||
} else {
|
||||
Ok(Packet::Close(packet))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for Packet {
|
||||
type Error = ProtocolError;
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
parse_packet(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::auth;
|
||||
|
||||
fn hello() -> HelloPacket {
|
||||
HelloPacket::authenticated(b"secret", Bytes::from_static(b"laptop"), 123, [7; 16]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_packet_types_round_trip() {
|
||||
let hello = hello();
|
||||
let encoded = hello.encode().unwrap();
|
||||
assert_eq!(encoded.len(), 75);
|
||||
assert_eq!(parse_packet(&encoded), Ok(Packet::Hello(hello)));
|
||||
|
||||
let accept = AcceptPacket::authenticated(b"secret", 42, [7; 16], [8; 16], 120, 1200);
|
||||
assert_eq!(
|
||||
parse_packet(&accept.encode().unwrap()),
|
||||
Ok(Packet::Accept(accept))
|
||||
);
|
||||
|
||||
let reject = RejectPacket::new(RejectReason::AuthenticationFailed);
|
||||
assert_eq!(
|
||||
parse_packet(&reject.encode().unwrap()),
|
||||
Ok(Packet::Reject(reject))
|
||||
);
|
||||
|
||||
let key = auth::derive_session_key(b"secret", &[7; 16], &[8; 16], 42);
|
||||
let data = DataPacket::authenticated(&key, 42, 9, Bytes::from_static(b"opaque UDP"));
|
||||
assert_eq!(
|
||||
parse_packet(&data.encode().unwrap()),
|
||||
Ok(Packet::Data(data))
|
||||
);
|
||||
|
||||
let keepalive = SessionPacket::authenticated(PacketType::Keepalive, &key, 42, 10);
|
||||
assert_eq!(
|
||||
parse_packet(&keepalive.encode().unwrap()),
|
||||
Ok(Packet::Keepalive(keepalive))
|
||||
);
|
||||
let close = SessionPacket::authenticated(PacketType::Close, &key, 42, 11);
|
||||
assert_eq!(
|
||||
parse_packet(&close.encode().unwrap()),
|
||||
Ok(Packet::Close(close))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_packets_are_rejected() {
|
||||
assert!(matches!(
|
||||
parse_packet(&[]),
|
||||
Err(ProtocolError::Truncated { .. })
|
||||
));
|
||||
let mut packet = hello().encode().unwrap();
|
||||
packet[0] = 2;
|
||||
assert_eq!(
|
||||
parse_packet(&packet),
|
||||
Err(ProtocolError::UnsupportedVersion(2))
|
||||
);
|
||||
packet = hello().encode().unwrap();
|
||||
packet[1] = 0xff;
|
||||
assert_eq!(
|
||||
parse_packet(&packet),
|
||||
Err(ProtocolError::UnknownPacketType(0xff))
|
||||
);
|
||||
packet = hello().encode().unwrap();
|
||||
packet[3] = 12;
|
||||
assert!(matches!(
|
||||
parse_packet(&packet),
|
||||
Err(ProtocolError::InvalidHeaderLength { .. })
|
||||
));
|
||||
packet = hello().encode().unwrap();
|
||||
packet.truncate(packet.len() - 1);
|
||||
assert!(matches!(
|
||||
parse_packet(&packet),
|
||||
Err(ProtocolError::Truncated { .. })
|
||||
));
|
||||
let mut oversized = vec![0u8; MAX_PACKET_SIZE + 1];
|
||||
oversized[..12].copy_from_slice(&[1, 0x10, 0, 32, 0, 0, 0, 0, 0, 0, 0, 1]);
|
||||
assert!(matches!(
|
||||
parse_packet(&oversized),
|
||||
Err(ProtocolError::Oversized { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_header_length_bound_is_enforced() {
|
||||
assert!(HelloPacket::new(vec![0; MAX_CLIENT_ID_LEN], 0, [0; 16], [0; 32]).is_ok());
|
||||
assert!(matches!(
|
||||
HelloPacket::new(vec![0; MAX_CLIENT_ID_LEN + 1], 0, [0; 16], [0; 32]),
|
||||
Err(ProtocolError::ClientIdTooLong)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_failure_does_not_change_parsing() {
|
||||
let mut packet = hello();
|
||||
assert!(packet.verify_authenticator(b"secret"));
|
||||
packet.authenticator[0] ^= 1;
|
||||
assert!(!packet.verify_authenticator(b"secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freshness_uses_absolute_clock_skew() {
|
||||
let packet = hello();
|
||||
let now = UNIX_EPOCH + Duration::from_secs(123);
|
||||
assert!(packet.is_fresh(now, Duration::from_secs(60)));
|
||||
let old = HelloPacket {
|
||||
timestamp: 62,
|
||||
..packet
|
||||
};
|
||||
assert!(!old.is_fresh(now, Duration::from_secs(60)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Wire protocol primitives for the authenticated direct UDP transport.
|
||||
//!
|
||||
//! The parser is intentionally independent of sockets and runtime state. It
|
||||
//! accepts a complete datagram and either returns a fully validated packet or
|
||||
//! a small typed error. Authentication and replay decisions remain explicit
|
||||
//! operations for the caller.
|
||||
|
||||
pub mod auth;
|
||||
pub mod direct;
|
||||
pub mod replay;
|
||||
|
||||
pub use auth::{
|
||||
derive_session_key, generate_sequence_number, generate_session_id, random_nonzero_u32,
|
||||
random_nonzero_u64,
|
||||
};
|
||||
|
||||
pub use direct::{
|
||||
parse_packet, AcceptPacket, ClosePacket, DataPacket, HelloPacket, KeepalivePacket, Packet,
|
||||
PacketType, ProtocolError, RejectPacket, RejectReason, SessionPacket, MAX_CLIENT_ID_LEN,
|
||||
MAX_PACKET_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Replay protection for HELLO nonces and DATA-family sequence numbers.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const WINDOW_SIZE: usize = 1024;
|
||||
const WORDS: usize = WINDOW_SIZE / 64;
|
||||
|
||||
/// A wrapping u32 sliding receive window with a 1024-packet history.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReplayWindow {
|
||||
highest: Option<u32>,
|
||||
bits: [u64; WORDS],
|
||||
}
|
||||
|
||||
impl Default for ReplayWindow {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReplayWindow {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
highest: None,
|
||||
bits: [0; WORDS],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn highest(&self) -> Option<u32> {
|
||||
self.highest
|
||||
}
|
||||
|
||||
/// Record a sequence number, returning false for duplicates or values
|
||||
/// outside the receive window. Values are considered newer using the
|
||||
/// standard half-range serial-number rule, making wraparound explicit and
|
||||
/// deterministic.
|
||||
pub fn accept(&mut self, sequence: u32) -> bool {
|
||||
let Some(highest) = self.highest else {
|
||||
self.highest = Some(sequence);
|
||||
self.bits = [0; WORDS];
|
||||
self.bits[0] = 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
let forward = sequence.wrapping_sub(highest);
|
||||
if forward == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if forward < (1u32 << 31) {
|
||||
self.shift_left(forward as usize);
|
||||
self.highest = Some(sequence);
|
||||
self.bits[0] |= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
let age = highest.wrapping_sub(sequence) as usize;
|
||||
if age >= WINDOW_SIZE {
|
||||
return false;
|
||||
}
|
||||
let word = age / 64;
|
||||
let bit = age % 64;
|
||||
let mask = 1u64 << bit;
|
||||
if self.bits[word] & mask != 0 {
|
||||
return false;
|
||||
}
|
||||
self.bits[word] |= mask;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn check_and_record(&mut self, sequence: u32) -> bool {
|
||||
self.accept(sequence)
|
||||
}
|
||||
|
||||
fn shift_left(&mut self, amount: usize) {
|
||||
if amount >= WINDOW_SIZE {
|
||||
self.bits = [0; WORDS];
|
||||
return;
|
||||
}
|
||||
let words = amount / 64;
|
||||
let bits = amount % 64;
|
||||
if words != 0 {
|
||||
for i in (0..WORDS).rev() {
|
||||
self.bits[i] = if i >= words { self.bits[i - words] } else { 0 };
|
||||
}
|
||||
}
|
||||
if bits != 0 {
|
||||
for i in (0..WORDS).rev() {
|
||||
let carry = if i > 0 { self.bits[i - 1] } else { 0 };
|
||||
self.bits[i] = (self.bits[i] << bits) | (carry >> (64 - bits));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
struct NonceKey {
|
||||
client_id: Vec<u8>,
|
||||
nonce: [u8; 16],
|
||||
}
|
||||
|
||||
/// Recently accepted HELLO nonce state. Entries expire after the configured
|
||||
/// duration (normally twice the allowed clock skew).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NonceReplayCache {
|
||||
expiry: Duration,
|
||||
entries: HashMap<NonceKey, Instant>,
|
||||
}
|
||||
|
||||
impl NonceReplayCache {
|
||||
pub fn new(expiry: Duration) -> Self {
|
||||
Self {
|
||||
expiry,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expiry(&self) -> Duration {
|
||||
self.expiry
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Return true and retain the nonce if it has not been seen recently.
|
||||
pub fn check_and_record(&mut self, client_id: &[u8], nonce: [u8; 16], now: Instant) -> bool {
|
||||
self.purge_expired(now);
|
||||
let key = NonceKey {
|
||||
client_id: client_id.to_vec(),
|
||||
nonce,
|
||||
};
|
||||
if self.entries.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
self.entries.insert(key, now);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn purge_expired(&mut self, now: Instant) {
|
||||
self.entries
|
||||
.retain(|_, seen| now.saturating_duration_since(*seen) < self.expiry);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn window_handles_duplicate_old_and_out_of_order_packets() {
|
||||
let mut window = ReplayWindow::new();
|
||||
assert!(window.accept(100));
|
||||
assert!(window.accept(102));
|
||||
assert!(window.accept(101));
|
||||
assert!(!window.accept(101));
|
||||
assert!(!window.accept(100));
|
||||
assert!(window.accept(110));
|
||||
for value in 111..=1134 {
|
||||
assert!(window.accept(value));
|
||||
}
|
||||
assert!(!window.accept(110));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_wraps_and_rejects_values_outside_history() {
|
||||
let mut window = ReplayWindow::new();
|
||||
assert!(window.accept(u32::MAX - 2));
|
||||
assert!(window.accept(u32::MAX - 1));
|
||||
assert!(window.accept(u32::MAX));
|
||||
assert!(window.accept(0));
|
||||
assert!(window.accept(1));
|
||||
assert!(!window.accept(u32::MAX - 2 + 1)); // duplicate old value
|
||||
assert!(!window.accept(u32::MAX - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonce_replay_expires() {
|
||||
let now = Instant::now();
|
||||
let mut cache = NonceReplayCache::new(Duration::from_secs(2));
|
||||
assert!(cache.check_and_record(b"a", [1; 16], now));
|
||||
assert!(!cache.check_and_record(b"a", [1; 16], now + Duration::from_secs(1)));
|
||||
assert!(cache.check_and_record(b"a", [1; 16], now + Duration::from_secs(3)));
|
||||
assert!(cache.check_and_record(b"b", [1; 16], now));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::{
|
||||
config::ServerConfig,
|
||||
control::{ControlServer, ControlState},
|
||||
transport::{
|
||||
quic::QuicServer, DirectUdpServer, TlsTcpServer, TransportError, TransportSession,
|
||||
},
|
||||
};
|
||||
|
||||
/// Run one configured server listener. Direct UDP remains the default; set it
|
||||
/// off when selecting QUIC or TLS/TCP because each role owns its listener port.
|
||||
pub async fn run(config: ServerConfig) -> Result<()> {
|
||||
if config.transports.direct_udp.enabled {
|
||||
let state = ControlState::default();
|
||||
start_control(state.clone()).await?;
|
||||
return DirectUdpServer::bind(config).await?.run().await;
|
||||
}
|
||||
let state = ControlState::default();
|
||||
start_control(state.clone()).await?;
|
||||
if config.transports.quic.enabled {
|
||||
return run_quic(config, state).await;
|
||||
}
|
||||
if config.transports.tls_tcp.enabled {
|
||||
return run_tls(config, state).await;
|
||||
}
|
||||
anyhow::bail!("no server transport enabled")
|
||||
}
|
||||
|
||||
async fn start_control(state: ControlState) -> Result<()> {
|
||||
let control = ControlServer::bind(crate::control::default_socket_path(), state).await?;
|
||||
tokio::spawn(async move {
|
||||
let _ = control.run().await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_quic(config: ServerConfig, state: ControlState) -> Result<()> {
|
||||
let listener = Arc::new(QuicServer::bind(&config).await?);
|
||||
let backend = config.backend.target.parse()?;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => { listener.close(); return Ok(()); }
|
||||
result = listener.accept_session() => match result {
|
||||
Ok(session) => { let state = state.clone(); tokio::spawn(async move { let _ = relay_session(Box::new(session), backend, state).await; }); }
|
||||
Err(TransportError::AuthenticationRejected(_)) => warn!("QUIC client authentication rejected"),
|
||||
Err(error) => warn!(error=%error, "QUIC session failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tls(config: ServerConfig, state: ControlState) -> Result<()> {
|
||||
let listener = Arc::new(TlsTcpServer::bind(&config).await?);
|
||||
let backend = config.backend.target.parse()?;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => return Ok(()),
|
||||
result = listener.accept_session() => match result {
|
||||
Ok(session) => { let state = state.clone(); tokio::spawn(async move { let _ = relay_session(session, backend, state).await; }); }
|
||||
Err(error) => warn!(error=%error, "TLS/TCP session failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn relay_session(
|
||||
session: Box<dyn TransportSession>,
|
||||
backend_target: std::net::SocketAddr,
|
||||
state: ControlState,
|
||||
) -> Result<()> {
|
||||
let backend = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
backend.connect(backend_target).await?;
|
||||
let backend = Arc::new(backend);
|
||||
let mut buf = vec![0u8; session.max_datagram_size().saturating_add(1)];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = state.shutdown.cancelled() => { let _ = session.close().await; return Ok(()); }
|
||||
received = session.recv_datagram() => {
|
||||
let payload = received?;
|
||||
if payload.len() <= session.max_datagram_size() { backend.send(&payload).await?; }
|
||||
}
|
||||
received = backend.recv(&mut buf) => {
|
||||
let len = received?;
|
||||
session.send_datagram(bytes::Bytes::copy_from_slice(&buf[..len])).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::stats::Stats;
|
||||
|
||||
pub type SessionId = u64;
|
||||
pub type ClientId = String;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TransportKind {
|
||||
DirectUdp,
|
||||
Quic,
|
||||
Http3,
|
||||
TlsTcp,
|
||||
}
|
||||
|
||||
/// Generic server-side session state. Transport-specific state belongs in the transport module.
|
||||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
pub id: SessionId,
|
||||
pub client_id: ClientId,
|
||||
pub transport: TransportKind,
|
||||
pub backend: UdpSocket,
|
||||
pub created_at: Instant,
|
||||
pub last_activity: Instant,
|
||||
pub stats: Stats,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(
|
||||
id: SessionId,
|
||||
client_id: impl Into<ClientId>,
|
||||
transport: TransportKind,
|
||||
backend: UdpSocket,
|
||||
) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
id,
|
||||
client_id: client_id.into(),
|
||||
transport,
|
||||
backend,
|
||||
created_at: now,
|
||||
last_activity: now,
|
||||
stats: Stats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch(&mut self) {
|
||||
self.last_activity = Instant::now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Counters shared by a session or runtime. Values are monotonic.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stats {
|
||||
pub bytes_sent: AtomicU64,
|
||||
pub bytes_received: AtomicU64,
|
||||
pub datagrams_sent: AtomicU64,
|
||||
pub datagrams_received: AtomicU64,
|
||||
pub datagrams_dropped: AtomicU64,
|
||||
pub oversized_datagrams: AtomicU64,
|
||||
pub authentication_failures: AtomicU64,
|
||||
pub reconnections: AtomicU64,
|
||||
pub expired_sessions: AtomicU64,
|
||||
pub handshake_attempts: AtomicU64,
|
||||
pub authentication_rejections: AtomicU64,
|
||||
pub rate_limited_attempts: AtomicU64,
|
||||
pub backend_errors: AtomicU64,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
pub fn add_sent(&self, bytes: usize) {
|
||||
self.bytes_sent.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
self.datagrams_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn add_received(&self, bytes: usize) {
|
||||
self.bytes_received
|
||||
.fetch_add(bytes as u64, Ordering::Relaxed);
|
||||
self.datagrams_received.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> StatsSnapshot {
|
||||
StatsSnapshot {
|
||||
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
|
||||
bytes_received: self.bytes_received.load(Ordering::Relaxed),
|
||||
datagrams_sent: self.datagrams_sent.load(Ordering::Relaxed),
|
||||
datagrams_received: self.datagrams_received.load(Ordering::Relaxed),
|
||||
datagrams_dropped: self.datagrams_dropped.load(Ordering::Relaxed),
|
||||
oversized_datagrams: self.oversized_datagrams.load(Ordering::Relaxed),
|
||||
authentication_failures: self.authentication_failures.load(Ordering::Relaxed),
|
||||
reconnections: self.reconnections.load(Ordering::Relaxed),
|
||||
expired_sessions: self.expired_sessions.load(Ordering::Relaxed),
|
||||
handshake_attempts: self.handshake_attempts.load(Ordering::Relaxed),
|
||||
authentication_rejections: self.authentication_rejections.load(Ordering::Relaxed),
|
||||
rate_limited_attempts: self.rate_limited_attempts.load(Ordering::Relaxed),
|
||||
backend_errors: self.backend_errors.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct StatsSnapshot {
|
||||
pub bytes_sent: u64,
|
||||
pub bytes_received: u64,
|
||||
pub datagrams_sent: u64,
|
||||
pub datagrams_received: u64,
|
||||
pub datagrams_dropped: u64,
|
||||
pub oversized_datagrams: u64,
|
||||
pub authentication_failures: u64,
|
||||
pub reconnections: u64,
|
||||
pub expired_sessions: u64,
|
||||
pub handshake_attempts: u64,
|
||||
pub authentication_rejections: u64,
|
||||
pub rate_limited_attempts: u64,
|
||||
pub backend_errors: u64,
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
//! Authenticated, opaque-datagram direct UDP transport.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use bytes::Bytes;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use tokio::{
|
||||
net::{lookup_host, UdpSocket},
|
||||
sync::Mutex,
|
||||
task::JoinSet,
|
||||
time::{self, timeout},
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, ServerConfig},
|
||||
protocol::{
|
||||
self, auth,
|
||||
direct::{
|
||||
AcceptPacket, DataPacket, HelloPacket, Packet, PacketType, RejectPacket, RejectReason,
|
||||
SessionPacket,
|
||||
},
|
||||
replay::{NonceReplayCache, ReplayWindow},
|
||||
},
|
||||
stats::Stats,
|
||||
};
|
||||
|
||||
use super::{TransportConnector, TransportError, TransportFuture, TransportSession};
|
||||
|
||||
const DEFAULT_MAX_DATAGRAM: usize = 1200;
|
||||
const MAX_UDP_DATAGRAM: usize = u16::MAX as usize;
|
||||
|
||||
fn unix_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
async fn resolve_server(server: &str, port: u16) -> Result<SocketAddr> {
|
||||
if let Ok(addr) = server.parse::<SocketAddr>() {
|
||||
return Ok(addr);
|
||||
}
|
||||
let endpoint = format!("{server}:{port}");
|
||||
lookup_host(endpoint.clone())
|
||||
.await?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("could not resolve {endpoint}"))
|
||||
}
|
||||
|
||||
fn direct_bind_addr(listen: &str, port: u16) -> Result<String> {
|
||||
if listen.parse::<SocketAddr>().is_ok() {
|
||||
Ok(listen.to_owned())
|
||||
} else {
|
||||
Ok(format!("{listen}:{port}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Established client-side direct UDP session.
|
||||
pub struct DirectUdpSession {
|
||||
socket: Arc<UdpSocket>,
|
||||
peer: Mutex<SocketAddr>,
|
||||
session_id: u64,
|
||||
session_key: [u8; auth::SESSION_KEY_LEN],
|
||||
send_sequence: AtomicU32,
|
||||
receive_window: Mutex<ReplayWindow>,
|
||||
max_datagram: usize,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
impl DirectUdpSession {
|
||||
pub fn session_id(&self) -> u64 {
|
||||
self.session_id
|
||||
}
|
||||
pub fn peer(&self) -> TransportFuture<'_, SocketAddr> {
|
||||
Box::pin(async move { Ok(*self.peer.lock().await) })
|
||||
}
|
||||
|
||||
pub async fn connect(config: &ClientConfig) -> Result<Self, TransportError> {
|
||||
let secret = config.client.secret.load_bytes("client").map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let peer = resolve_server(&config.client.server, config.transport.direct_udp.port)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
let socket = Arc::new(socket);
|
||||
let mut nonce = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let hello = HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::copy_from_slice(config.client.id.as_bytes()),
|
||||
unix_timestamp(),
|
||||
nonce,
|
||||
)?;
|
||||
let encoded = hello.encode()?;
|
||||
let handshake_timeout =
|
||||
Duration::from_millis(config.transport.auto.probe_timeout_ms.max(1))
|
||||
.min(Duration::from_secs(30));
|
||||
for _attempt in 0..config.transport.auto.retries.max(1) {
|
||||
socket.send_to(&encoded, peer).await?;
|
||||
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
|
||||
match timeout(handshake_timeout, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, source))) => {
|
||||
let packet = match protocol::parse_packet(&buf[..len]) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
debug!(error=%e, "ignoring malformed handshake response");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match packet {
|
||||
Packet::Accept(accept) => {
|
||||
if source.ip() != peer.ip()
|
||||
|| !accept.verify_authenticator(&secret, &nonce)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let max_datagram = (config.network.max_datagram_size as usize)
|
||||
.min(accept.max_datagram as usize);
|
||||
let session_key = auth::derive_session_key(
|
||||
&secret,
|
||||
&nonce,
|
||||
&accept.server_nonce,
|
||||
accept.session_id,
|
||||
);
|
||||
info!(session_id=accept.session_id, remote_addr=%source, max_datagram, "direct UDP session authenticated");
|
||||
return Ok(Self {
|
||||
socket,
|
||||
peer: Mutex::new(source),
|
||||
session_id: accept.session_id,
|
||||
session_key,
|
||||
send_sequence: AtomicU32::new(auth::generate_sequence_number()),
|
||||
receive_window: Mutex::new(ReplayWindow::new()),
|
||||
max_datagram,
|
||||
closed: CancellationToken::new(),
|
||||
});
|
||||
}
|
||||
Packet::Reject(reject) => {
|
||||
return Err(TransportError::AuthenticationRejected(reject.reason))
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => return Err(e.into()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
}
|
||||
|
||||
async fn send_packet(&self, bytes: &[u8]) -> Result<(), TransportError> {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
let peer = *self.peer.lock().await;
|
||||
self.socket.send_to(bytes, peer).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an authenticated control keepalive. It intentionally shares the
|
||||
/// outgoing DATA sequence space only within this direction.
|
||||
pub async fn send_keepalive(&self) -> Result<(), TransportError> {
|
||||
let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let packet = SessionPacket::authenticated(
|
||||
PacketType::Keepalive,
|
||||
&self.session_key,
|
||||
self.session_id,
|
||||
sequence,
|
||||
);
|
||||
self.send_packet(&packet.encode()?).await
|
||||
}
|
||||
|
||||
async fn receive_valid(&self) -> Result<(Packet, SocketAddr), TransportError> {
|
||||
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
|
||||
loop {
|
||||
let (len, source) = self.socket.recv_from(&mut buf).await?;
|
||||
let packet = match protocol::parse_packet(&buf[..len]) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
debug!(error=%e, "dropping malformed direct UDP packet");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let valid = match &packet {
|
||||
Packet::Data(p) => {
|
||||
p.session_id == self.session_id && p.verify_authenticator(&self.session_key)
|
||||
}
|
||||
Packet::Keepalive(p) | Packet::Close(p) => {
|
||||
p.session_id == self.session_id && p.verify_authenticator(&self.session_key)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
let sequence = match &packet {
|
||||
Packet::Data(p) => p.sequence,
|
||||
Packet::Keepalive(p) | Packet::Close(p) => p.sequence,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !self.receive_window.lock().await.accept(sequence) {
|
||||
continue;
|
||||
}
|
||||
*self.peer.lock().await = source;
|
||||
return Ok((packet, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportSession for DirectUdpSession {
|
||||
fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if data.len() > self.max_datagram {
|
||||
return Err(TransportError::Oversized {
|
||||
maximum: self.max_datagram,
|
||||
});
|
||||
}
|
||||
let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let packet =
|
||||
DataPacket::authenticated(&self.session_key, self.session_id, sequence, data);
|
||||
self.send_packet(&packet.encode()?).await
|
||||
})
|
||||
}
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
match self.receive_valid().await? {
|
||||
(Packet::Data(packet), _) => return Ok(packet.payload),
|
||||
(Packet::Keepalive(_), _) => continue,
|
||||
(Packet::Close(_), _) => {
|
||||
self.closed.cancel();
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn close<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if self.closed.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let sequence = self.send_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let packet = SessionPacket::authenticated(
|
||||
PacketType::Close,
|
||||
&self.session_key,
|
||||
self.session_id,
|
||||
sequence,
|
||||
);
|
||||
let _ = self.send_packet(&packet.encode()?).await;
|
||||
self.closed.cancel();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DirectUdpConnector {
|
||||
config: ClientConfig,
|
||||
}
|
||||
impl DirectUdpConnector {
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
impl TransportConnector for DirectUdpConnector {
|
||||
fn connect<'a>(&'a self) -> TransportFuture<'a, Box<dyn TransportSession>> {
|
||||
Box::pin(async move {
|
||||
Ok(Box::new(DirectUdpSession::connect(&self.config).await?)
|
||||
as Box<dyn TransportSession>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSession {
|
||||
id: u64,
|
||||
client_id: String,
|
||||
key: [u8; auth::SESSION_KEY_LEN],
|
||||
peer: Mutex<SocketAddr>,
|
||||
backend: Arc<UdpSocket>,
|
||||
receive_window: Mutex<ReplayWindow>,
|
||||
send_sequence: AtomicU32,
|
||||
last_activity: Mutex<Instant>,
|
||||
max_datagram: usize,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
/// Multi-client direct UDP server. Each authenticated session gets its own
|
||||
/// connected backend socket and therefore natural response demultiplexing.
|
||||
pub struct DirectUdpServer {
|
||||
socket: Arc<UdpSocket>,
|
||||
backend_target: SocketAddr,
|
||||
clients: HashMap<String, Vec<u8>>,
|
||||
sessions: Arc<Mutex<HashMap<u64, Arc<ServerSession>>>>,
|
||||
nonces: Arc<Mutex<NonceReplayCache>>,
|
||||
pending: Arc<Mutex<HashMap<SocketAddr, Instant>>>,
|
||||
config: ServerConfig,
|
||||
stats: Arc<Stats>,
|
||||
}
|
||||
|
||||
impl DirectUdpServer {
|
||||
pub async fn bind(config: ServerConfig) -> Result<Self> {
|
||||
let bind = direct_bind_addr(&config.server.listen, config.transports.direct_udp.port)?;
|
||||
let socket = Arc::new(
|
||||
UdpSocket::bind(&bind)
|
||||
.await
|
||||
.context("bind direct UDP listener")?,
|
||||
);
|
||||
let backend_target = config
|
||||
.backend
|
||||
.target
|
||||
.parse::<SocketAddr>()
|
||||
.context("invalid backend target")?;
|
||||
let mut clients = HashMap::new();
|
||||
for client in &config.auth.clients {
|
||||
clients.insert(
|
||||
client.id.clone(),
|
||||
client
|
||||
.secret
|
||||
.load_bytes(&format!("auth client {}", client.id))?,
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
socket,
|
||||
backend_target,
|
||||
clients,
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
nonces: Arc::new(Mutex::new(NonceReplayCache::new(Duration::from_secs(120)))),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
config,
|
||||
stats: Arc::new(Stats::default()),
|
||||
})
|
||||
}
|
||||
pub fn local_addr(&self) -> Result<SocketAddr> {
|
||||
Ok(self.socket.local_addr()?)
|
||||
}
|
||||
pub fn stats(&self) -> Arc<Stats> {
|
||||
self.stats.clone()
|
||||
}
|
||||
pub async fn active_sessions(&self) -> usize {
|
||||
self.sessions.lock().await.len()
|
||||
}
|
||||
|
||||
async fn send_reject(&self, peer: SocketAddr, reason: RejectReason) {
|
||||
if let Ok(bytes) = RejectPacket::new(reason).encode() {
|
||||
if self.socket.send_to(&bytes, peer).await.is_ok() {
|
||||
self.stats.add_sent(bytes.len());
|
||||
}
|
||||
}
|
||||
self.stats
|
||||
.authentication_rejections
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
async fn handle_hello(
|
||||
&self,
|
||||
hello: protocol::HelloPacket,
|
||||
peer: SocketAddr,
|
||||
) -> Result<Option<Arc<ServerSession>>> {
|
||||
self.stats
|
||||
.handshake_attempts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let now = Instant::now();
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
let expiry = Duration::from_millis(self.config.limits.handshake_timeout_ms.max(1));
|
||||
pending.retain(|_, seen| now.duration_since(*seen) < expiry);
|
||||
if !pending.contains_key(&peer)
|
||||
&& pending.len() >= self.config.limits.max_pending_handshakes
|
||||
{
|
||||
self.stats
|
||||
.rate_limited_attempts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
drop(pending);
|
||||
self.send_reject(peer, RejectReason::RateLimited).await;
|
||||
return Ok(None);
|
||||
}
|
||||
pending.insert(peer, now);
|
||||
}
|
||||
let source_ip = peer.ip();
|
||||
let active = self.sessions.lock().await;
|
||||
let sessions_for_ip = {
|
||||
let mut count = 0;
|
||||
for session in active.values() {
|
||||
if session.peer.lock().await.ip() == source_ip {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
};
|
||||
if active.len() >= self.config.limits.max_sessions
|
||||
|| sessions_for_ip >= self.config.limits.max_sessions_per_ip
|
||||
{
|
||||
drop(active);
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::ServerBusy).await;
|
||||
return Ok(None);
|
||||
}
|
||||
drop(active);
|
||||
let Some(secret) = self
|
||||
.clients
|
||||
.get(std::str::from_utf8(&hello.client_id).unwrap_or(""))
|
||||
else {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::AuthenticationFailed)
|
||||
.await;
|
||||
return Ok(None);
|
||||
};
|
||||
if !hello.is_fresh(SystemTime::now(), Duration::from_secs(60))
|
||||
|| !hello.verify_authenticator(secret)
|
||||
{
|
||||
self.stats
|
||||
.authentication_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::AuthenticationFailed)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.nonces.lock().await.check_and_record(
|
||||
&hello.client_id,
|
||||
hello.client_nonce,
|
||||
Instant::now(),
|
||||
) {
|
||||
self.pending.lock().await.remove(&peer);
|
||||
self.send_reject(peer, RejectReason::ReplayDetected).await;
|
||||
return Ok(None);
|
||||
}
|
||||
let backend = Arc::new(UdpSocket::bind("0.0.0.0:0").await?);
|
||||
backend.connect(self.backend_target).await?;
|
||||
let mut session_id = auth::generate_session_id();
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
while sessions.contains_key(&session_id) {
|
||||
session_id = auth::generate_session_id();
|
||||
}
|
||||
let mut server_nonce = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut server_nonce);
|
||||
let idle = self.config.session.idle_timeout_secs.min(u32::MAX as u64) as u32;
|
||||
let max_datagram = DEFAULT_MAX_DATAGRAM as u16;
|
||||
let key = auth::derive_session_key(secret, &hello.client_nonce, &server_nonce, session_id);
|
||||
let session = Arc::new(ServerSession {
|
||||
id: session_id,
|
||||
client_id: String::from_utf8_lossy(&hello.client_id).into_owned(),
|
||||
key,
|
||||
peer: Mutex::new(peer),
|
||||
backend,
|
||||
receive_window: Mutex::new(ReplayWindow::new()),
|
||||
send_sequence: AtomicU32::new(auth::generate_sequence_number()),
|
||||
last_activity: Mutex::new(Instant::now()),
|
||||
max_datagram: max_datagram as usize,
|
||||
cancel: CancellationToken::new(),
|
||||
});
|
||||
sessions.insert(session_id, session.clone());
|
||||
drop(sessions);
|
||||
self.pending.lock().await.remove(&peer);
|
||||
let accept = AcceptPacket::authenticated(
|
||||
secret,
|
||||
session_id,
|
||||
hello.client_nonce,
|
||||
server_nonce,
|
||||
idle,
|
||||
max_datagram,
|
||||
);
|
||||
let accept_bytes = accept.encode()?;
|
||||
self.socket.send_to(&accept_bytes, peer).await?;
|
||||
self.stats.add_sent(accept_bytes.len());
|
||||
info!(session_id, client_id=%session.client_id, remote_addr=%peer, "direct UDP client authenticated");
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn remove_session(&self, id: u64, expired: bool) {
|
||||
if let Some(session) = self.sessions.lock().await.remove(&id) {
|
||||
session.cancel.cancel();
|
||||
if expired {
|
||||
self.stats.expired_sessions.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
|
||||
let mut cleanup = time::interval(Duration::from_secs(1));
|
||||
let signal = shutdown_signal();
|
||||
tokio::pin!(signal);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut signal => { info!("direct UDP server shutting down"); break; }
|
||||
Some(done) = tasks.join_next() => { if let Ok(id) = done { self.remove_session(id, false).await; } }
|
||||
_ = cleanup.tick() => {
|
||||
let now = Instant::now();
|
||||
let expired: Vec<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; } };
|
||||
match packet {
|
||||
Packet::Hello(hello) => match self.handle_hello(hello, peer).await {
|
||||
Ok(Some(session)) => {
|
||||
let listener = self.socket.clone(); let backend = session.backend.clone(); let session_clone = session.clone(); let stats = self.stats.clone();
|
||||
tasks.spawn(async move { backend_loop(listener, backend, session_clone, stats).await });
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => warn!(remote_addr=%peer, error=%error, "failed to establish direct UDP session"),
|
||||
},
|
||||
Packet::Data(data) => if let Err(error) = self.handle_session_packet(PacketType::Data, data.session_id, data.sequence, data.auth_tag, data.payload, peer).await { self.stats.backend_errors.fetch_add(1, Ordering::Relaxed); self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed); warn!(session_id=data.session_id, error=%error, "backend forwarding failed"); },
|
||||
Packet::Keepalive(p) => if let Err(error) = self.handle_session_packet(p.packet_type, p.session_id, p.sequence, p.auth_tag, Bytes::new(), peer).await { warn!(session_id=p.session_id, error=%error, "keepalive handling failed"); },
|
||||
Packet::Close(p) => { if let Err(error) = self.handle_session_packet(p.packet_type, p.session_id, p.sequence, p.auth_tag, Bytes::new(), peer).await { warn!(session_id=p.session_id, error=%error, "close handling failed"); } self.remove_session(p.session_id, false).await; },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids: Vec<u64> = self.sessions.lock().await.keys().copied().collect();
|
||||
for id in ids {
|
||||
self.remove_session(id, false).await;
|
||||
}
|
||||
while tasks.join_next().await.is_some() {}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_session_packet(
|
||||
&self,
|
||||
packet_type: PacketType,
|
||||
id: u64,
|
||||
sequence: u32,
|
||||
tag: [u8; 16],
|
||||
payload: Bytes,
|
||||
peer: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let session = match self.sessions.lock().await.get(&id).cloned() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if payload.len() > session.max_datagram {
|
||||
self.stats
|
||||
.oversized_datagrams
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
if !auth::verify_data_auth_tag(
|
||||
&session.key,
|
||||
protocol::PROTOCOL_VERSION,
|
||||
packet_type,
|
||||
0,
|
||||
id,
|
||||
sequence,
|
||||
&payload,
|
||||
&tag,
|
||||
) {
|
||||
self.stats
|
||||
.authentication_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
if !session.receive_window.lock().await.accept(sequence) {
|
||||
self.stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
*session.peer.lock().await = peer;
|
||||
*session.last_activity.lock().await = Instant::now();
|
||||
match packet_type {
|
||||
PacketType::Data => {
|
||||
session.backend.send(&payload).await?;
|
||||
}
|
||||
PacketType::Close => session.cancel.cancel(),
|
||||
PacketType::Keepalive => {}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn backend_loop(
|
||||
listener: Arc<UdpSocket>,
|
||||
backend: Arc<UdpSocket>,
|
||||
session: Arc<ServerSession>,
|
||||
stats: Arc<Stats>,
|
||||
) -> u64 {
|
||||
let mut buf = vec![0u8; MAX_UDP_DATAGRAM];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = session.cancel.cancelled() => break,
|
||||
received = backend.recv(&mut buf) => {
|
||||
let len = match received { Ok(len) => len, Err(_) => break };
|
||||
if len > session.max_datagram {
|
||||
stats.oversized_datagrams.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let sequence = session.send_sequence.fetch_add(1, Ordering::Relaxed);
|
||||
let packet = DataPacket::authenticated(&session.key, session.id, sequence, Bytes::copy_from_slice(&buf[..len]));
|
||||
let bytes = match packet.encode() { Ok(v) => v, Err(_) => continue };
|
||||
let peer = *session.peer.lock().await;
|
||||
if listener.send_to(&bytes, peer).await.is_err() {
|
||||
stats.backend_errors.fetch_add(1, Ordering::Relaxed);
|
||||
stats.datagrams_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
stats.add_sent(bytes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
session.id
|
||||
}
|
||||
|
||||
fn async_ready_expired(
|
||||
session: &Arc<ServerSession>,
|
||||
now: Instant,
|
||||
timeout: Duration,
|
||||
) -> Option<u64> {
|
||||
session.last_activity.try_lock().ok().and_then(|last| {
|
||||
if now.duration_since(*last) >= timeout {
|
||||
Some(session.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! HTTP/3 CONNECT-UDP protocol primitives.
|
||||
//!
|
||||
//! `h3` 0.0.8 exposes the extended-CONNECT and HTTP-Datagram settings, while
|
||||
//! `h3-quinn` 0.0.10 exposes the actual datagram adapter only behind its
|
||||
//! `datagram` feature. This module therefore keeps the wire-level
|
||||
//! CONNECT-UDP/context/capsule logic independent of that optional adapter.
|
||||
//! Callers can implement [`HttpDatagramIo`] for the h3-datagram handlers once
|
||||
//! the feature is enabled; application packets are never sent as HTTP body
|
||||
//! streams.
|
||||
|
||||
use std::{fmt, future::Future, pin::Pin, str::FromStr, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const CONNECT_UDP_CONTEXT_ID: u64 = 0;
|
||||
pub const MAX_QUIC_VARINT: u64 = (1 << 62) - 1;
|
||||
pub const DEFAULT_MAX_DATAGRAM: usize = 1200;
|
||||
|
||||
// Private protocol capsule types. Capsule framing is standardized; these
|
||||
// payload types are project-local and are consumed only before activation.
|
||||
pub const AUTH_CHALLENGE_CAPSULE: u64 = 0x2a;
|
||||
pub const AUTH_RESPONSE_CAPSULE: u64 = 0x2b;
|
||||
pub const AUTH_ACCEPT_CAPSULE: u64 = 0x2c;
|
||||
pub const AUTH_REJECT_CAPSULE: u64 = 0x2d;
|
||||
const AUTH_VERSION: u8 = 1;
|
||||
const CHALLENGE_LEN: usize = 32;
|
||||
const CLIENT_NONCE_LEN: usize = 16;
|
||||
const MAC_LEN: usize = 32;
|
||||
const MAX_CLIENT_ID: usize = u8::MAX as usize;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Http3Error {
|
||||
InvalidRequest(&'static str),
|
||||
VarintOverflow,
|
||||
Truncated,
|
||||
InvalidCapsule,
|
||||
ContextMismatch { expected: u64, actual: u64 },
|
||||
Oversized { length: usize, maximum: usize },
|
||||
Io(String),
|
||||
Closed,
|
||||
IdleTimeout,
|
||||
}
|
||||
|
||||
impl fmt::Display for Http3Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequest(reason) => write!(f, "invalid CONNECT-UDP request: {reason}"),
|
||||
Self::VarintOverflow => write!(f, "QUIC variable integer overflow"),
|
||||
Self::Truncated => write!(f, "truncated HTTP/3 value"),
|
||||
Self::InvalidCapsule => write!(f, "invalid HTTP capsule"),
|
||||
Self::ContextMismatch { expected, actual } => {
|
||||
write!(
|
||||
f,
|
||||
"HTTP Datagram context {actual} does not match {expected}"
|
||||
)
|
||||
}
|
||||
Self::Oversized { length, maximum } => {
|
||||
write!(f, "datagram length {length} exceeds maximum {maximum}")
|
||||
}
|
||||
Self::Io(error) => write!(f, "HTTP/3 I/O error: {error}"),
|
||||
Self::Closed => write!(f, "HTTP/3 session closed"),
|
||||
Self::IdleTimeout => write!(f, "HTTP/3 session idle timeout"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Http3Error {}
|
||||
|
||||
/// Request metadata needed for an RFC 9220-style extended CONNECT request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConnectUdpRequest {
|
||||
pub method: String,
|
||||
pub protocol: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl ConnectUdpRequest {
|
||||
pub fn new(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
method: "CONNECT".into(),
|
||||
protocol: "connect-udp".into(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self, configured_path: &str) -> Result<(), Http3Error> {
|
||||
validate_connect_udp_request(&self.method, &self.protocol, &self.path, configured_path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_connect_udp_request(
|
||||
method: &str,
|
||||
protocol: &str,
|
||||
path: &str,
|
||||
configured_path: &str,
|
||||
) -> Result<(), Http3Error> {
|
||||
if method != "CONNECT" {
|
||||
return Err(Http3Error::InvalidRequest("method must be CONNECT"));
|
||||
}
|
||||
let parsed = h3::ext::Protocol::from_str(protocol)
|
||||
.map_err(|_| Http3Error::InvalidRequest(":protocol must be connect-udp"))?;
|
||||
if parsed != h3::ext::Protocol::CONNECT_UDP {
|
||||
return Err(Http3Error::InvalidRequest(":protocol must be connect-udp"));
|
||||
}
|
||||
if path != configured_path {
|
||||
return Err(Http3Error::InvalidRequest("request path is not configured"));
|
||||
}
|
||||
if !path.starts_with('/') {
|
||||
return Err(Http3Error::InvalidRequest("path must be absolute"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build h3 settings required before sending CONNECT-UDP.
|
||||
pub fn client_builder() -> h3::client::Builder {
|
||||
let mut builder = h3::client::builder();
|
||||
builder.enable_extended_connect(true).enable_datagram(true);
|
||||
builder
|
||||
}
|
||||
|
||||
/// Build h3 settings required before accepting CONNECT-UDP.
|
||||
pub fn server_builder() -> h3::server::Builder {
|
||||
let mut builder = h3::server::builder();
|
||||
builder.enable_extended_connect(true).enable_datagram(true);
|
||||
builder
|
||||
}
|
||||
|
||||
fn encode_varint(value: u64) -> Result<Vec<u8>, Http3Error> {
|
||||
if value > MAX_QUIC_VARINT {
|
||||
return Err(Http3Error::VarintOverflow);
|
||||
}
|
||||
let (prefix, width) = if value < (1 << 6) {
|
||||
(0u8, 1)
|
||||
} else if value < (1 << 14) {
|
||||
(0x40, 2)
|
||||
} else if value < (1 << 30) {
|
||||
(0x80, 4)
|
||||
} else {
|
||||
(0xc0, 8)
|
||||
};
|
||||
let mut out = vec![0u8; width];
|
||||
for index in (0..width).rev() {
|
||||
let shift = 8 * (width - 1 - index);
|
||||
out[index] = ((value >> shift) & 0xff) as u8;
|
||||
}
|
||||
out[0] = (out[0] & 0x3f) | prefix;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decode_varint(input: &[u8]) -> Result<(u64, usize), Http3Error> {
|
||||
let Some(&first) = input.first() else {
|
||||
return Err(Http3Error::Truncated);
|
||||
};
|
||||
let width = 1usize << (first >> 6);
|
||||
if input.len() < width {
|
||||
return Err(Http3Error::Truncated);
|
||||
}
|
||||
let mut value = (first & 0x3f) as u64;
|
||||
for byte in &input[1..width] {
|
||||
value = (value << 8) | (*byte as u64);
|
||||
}
|
||||
Ok((value, width))
|
||||
}
|
||||
|
||||
/// Encode an HTTP Datagram payload (RFC 9297): context ID varint followed by
|
||||
/// the opaque UDP datagram. CONNECT-UDP uses context ID zero.
|
||||
pub fn encode_http_datagram(
|
||||
context_id: u64,
|
||||
payload: &[u8],
|
||||
maximum: usize,
|
||||
) -> Result<Bytes, Http3Error> {
|
||||
if payload.len() > maximum {
|
||||
return Err(Http3Error::Oversized {
|
||||
length: payload.len(),
|
||||
maximum,
|
||||
});
|
||||
}
|
||||
let context = encode_varint(context_id)?;
|
||||
let mut out = Vec::with_capacity(context.len() + payload.len());
|
||||
out.extend_from_slice(&context);
|
||||
out.extend_from_slice(payload);
|
||||
Ok(Bytes::from(out))
|
||||
}
|
||||
|
||||
pub fn decode_http_datagram(
|
||||
encoded: &[u8],
|
||||
expected_context_id: u64,
|
||||
maximum: usize,
|
||||
) -> Result<Bytes, Http3Error> {
|
||||
let (context_id, offset) = decode_varint(encoded)?;
|
||||
if context_id != expected_context_id {
|
||||
return Err(Http3Error::ContextMismatch {
|
||||
expected: expected_context_id,
|
||||
actual: context_id,
|
||||
});
|
||||
}
|
||||
let payload = &encoded[offset..];
|
||||
if payload.len() > maximum {
|
||||
return Err(Http3Error::Oversized {
|
||||
length: payload.len(),
|
||||
maximum,
|
||||
});
|
||||
}
|
||||
Ok(Bytes::copy_from_slice(payload))
|
||||
}
|
||||
|
||||
/// Encode one RFC 9297 capsule (type varint, length varint, payload).
|
||||
pub fn encode_capsule(capsule_type: u64, payload: &[u8]) -> Result<Bytes, Http3Error> {
|
||||
let kind = encode_varint(capsule_type)?;
|
||||
let length = encode_varint(payload.len() as u64)?;
|
||||
let mut out = Vec::with_capacity(kind.len() + length.len() + payload.len());
|
||||
out.extend_from_slice(&kind);
|
||||
out.extend_from_slice(&length);
|
||||
out.extend_from_slice(payload);
|
||||
Ok(Bytes::from(out))
|
||||
}
|
||||
|
||||
pub fn decode_capsule(encoded: &[u8]) -> Result<(u64, Bytes), Http3Error> {
|
||||
let (capsule_type, type_len) = decode_varint(encoded)?;
|
||||
let (payload_len, length_len) = decode_varint(&encoded[type_len..])?;
|
||||
let start = type_len + length_len;
|
||||
let payload_len = usize::try_from(payload_len).map_err(|_| Http3Error::VarintOverflow)?;
|
||||
let end = start
|
||||
.checked_add(payload_len)
|
||||
.ok_or(Http3Error::VarintOverflow)?;
|
||||
if encoded.len() != end {
|
||||
return Err(if encoded.len() < end {
|
||||
Http3Error::Truncated
|
||||
} else {
|
||||
Http3Error::InvalidCapsule
|
||||
});
|
||||
}
|
||||
Ok((capsule_type, Bytes::copy_from_slice(&encoded[start..end])))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthChallenge {
|
||||
pub bytes: [u8; CHALLENGE_LEN],
|
||||
}
|
||||
|
||||
impl AuthChallenge {
|
||||
pub fn random() -> Self {
|
||||
let mut bytes = [0u8; CHALLENGE_LEN];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self { bytes }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthenticatedClient {
|
||||
pub id: String,
|
||||
pub nonce: [u8; CLIENT_NONCE_LEN],
|
||||
}
|
||||
|
||||
fn challenge_mac(
|
||||
challenge: &[u8; CHALLENGE_LEN],
|
||||
client_id: &[u8],
|
||||
nonce: &[u8; CLIENT_NONCE_LEN],
|
||||
secret: &[u8],
|
||||
) -> [u8; MAC_LEN] {
|
||||
let mut input = Vec::with_capacity(1 + CHALLENGE_LEN + 1 + client_id.len() + CLIENT_NONCE_LEN);
|
||||
input.push(AUTH_VERSION);
|
||||
input.extend_from_slice(challenge);
|
||||
input.push(client_id.len() as u8);
|
||||
input.extend_from_slice(client_id);
|
||||
input.extend_from_slice(nonce);
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts every key length");
|
||||
mac.update(&input);
|
||||
mac.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
pub fn encode_auth_challenge(challenge: &AuthChallenge) -> Result<Bytes, Http3Error> {
|
||||
encode_capsule(AUTH_CHALLENGE_CAPSULE, &challenge.bytes)
|
||||
}
|
||||
|
||||
pub fn encode_auth_accept() -> Result<Bytes, Http3Error> {
|
||||
encode_capsule(AUTH_ACCEPT_CAPSULE, &[])
|
||||
}
|
||||
|
||||
pub fn encode_auth_reject() -> Result<Bytes, Http3Error> {
|
||||
encode_capsule(AUTH_REJECT_CAPSULE, &[])
|
||||
}
|
||||
|
||||
pub fn is_auth_accept(capsule: &[u8]) -> Result<bool, Http3Error> {
|
||||
let (kind, payload) = decode_capsule(capsule)?;
|
||||
if !payload.is_empty() {
|
||||
return Err(Http3Error::InvalidCapsule);
|
||||
}
|
||||
match kind {
|
||||
AUTH_ACCEPT_CAPSULE => Ok(true),
|
||||
AUTH_REJECT_CAPSULE => Ok(false),
|
||||
_ => Err(Http3Error::InvalidCapsule),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_auth_response(
|
||||
challenge: &AuthChallenge,
|
||||
client_id: &str,
|
||||
nonce: &[u8; CLIENT_NONCE_LEN],
|
||||
secret: &[u8],
|
||||
) -> Result<Bytes, Http3Error> {
|
||||
let id = client_id.as_bytes();
|
||||
if id.is_empty() || id.len() > MAX_CLIENT_ID {
|
||||
return Err(Http3Error::InvalidRequest("client ID length is invalid"));
|
||||
}
|
||||
let mac = challenge_mac(&challenge.bytes, id, nonce, secret);
|
||||
let mut payload = Vec::with_capacity(1 + id.len() + CLIENT_NONCE_LEN + MAC_LEN);
|
||||
payload.push(id.len() as u8);
|
||||
payload.extend_from_slice(id);
|
||||
payload.extend_from_slice(nonce);
|
||||
payload.extend_from_slice(&mac);
|
||||
encode_capsule(AUTH_RESPONSE_CAPSULE, &payload)
|
||||
}
|
||||
|
||||
pub fn verify_auth_response(
|
||||
challenge: &AuthChallenge,
|
||||
capsule: &[u8],
|
||||
clients: &std::collections::HashMap<String, Vec<u8>>,
|
||||
) -> Result<AuthenticatedClient, Http3Error> {
|
||||
let (kind, payload) = decode_capsule(capsule)?;
|
||||
if kind != AUTH_RESPONSE_CAPSULE || payload.len() < 1 + CLIENT_NONCE_LEN + MAC_LEN {
|
||||
return Err(Http3Error::InvalidCapsule);
|
||||
}
|
||||
let id_len = payload[0] as usize;
|
||||
let expected = 1 + id_len + CLIENT_NONCE_LEN + MAC_LEN;
|
||||
if id_len == 0 || id_len > MAX_CLIENT_ID || expected != payload.len() {
|
||||
return Err(Http3Error::InvalidCapsule);
|
||||
}
|
||||
let id =
|
||||
std::str::from_utf8(&payload[1..1 + id_len]).map_err(|_| Http3Error::InvalidCapsule)?;
|
||||
let mut nonce = [0u8; CLIENT_NONCE_LEN];
|
||||
nonce.copy_from_slice(&payload[1 + id_len..1 + id_len + CLIENT_NONCE_LEN]);
|
||||
let mut received = [0u8; MAC_LEN];
|
||||
received.copy_from_slice(&payload[1 + id_len + CLIENT_NONCE_LEN..]);
|
||||
let Some(secret) = clients.get(id) else {
|
||||
return Err(Http3Error::InvalidRequest("unknown client"));
|
||||
};
|
||||
let expected_mac = challenge_mac(&challenge.bytes, id.as_bytes(), &nonce, secret);
|
||||
if !bool::from(expected_mac.ct_eq(&received)) {
|
||||
return Err(Http3Error::InvalidRequest("authentication failed"));
|
||||
}
|
||||
Ok(AuthenticatedClient {
|
||||
id: id.to_owned(),
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
|
||||
/// Narrow adapter boundary for the optional h3-datagram implementation.
|
||||
pub trait HttpDatagramIo: Send + Sync {
|
||||
type Error: fmt::Display + Send + Sync + 'static;
|
||||
fn send<'a>(
|
||||
&'a self,
|
||||
encoded: Bytes,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + 'a>>;
|
||||
fn recv<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<Bytes, Self::Error>> + Send + 'a>>;
|
||||
fn close<'a>(&'a self) -> Pin<Box<dyn Future<Output = Result<(), Self::Error>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
pub struct ConnectUdpSession<I> {
|
||||
io: I,
|
||||
context_id: u64,
|
||||
maximum_datagram: usize,
|
||||
idle_timeout: Option<Duration>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
impl<I: HttpDatagramIo> ConnectUdpSession<I> {
|
||||
pub fn new(io: I, maximum_datagram: usize) -> Self {
|
||||
Self {
|
||||
io,
|
||||
context_id: CONNECT_UDP_CONTEXT_ID,
|
||||
maximum_datagram,
|
||||
idle_timeout: None,
|
||||
closed: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self {
|
||||
self.idle_timeout = Some(idle_timeout);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_datagram_size(&self) -> usize {
|
||||
self.maximum_datagram
|
||||
}
|
||||
|
||||
pub async fn send_datagram(&self, payload: &[u8]) -> Result<(), Http3Error> {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(Http3Error::Closed);
|
||||
}
|
||||
let encoded = encode_http_datagram(self.context_id, payload, self.maximum_datagram)?;
|
||||
self.io
|
||||
.send(encoded)
|
||||
.await
|
||||
.map_err(|error| Http3Error::Io(error.to_string()))
|
||||
}
|
||||
|
||||
pub async fn recv_datagram(&self) -> Result<Bytes, Http3Error> {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(Http3Error::Closed);
|
||||
}
|
||||
let future = self.io.recv();
|
||||
let encoded = match self.idle_timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| Http3Error::IdleTimeout)?
|
||||
.map_err(|error| Http3Error::Io(error.to_string()))?,
|
||||
None => future
|
||||
.await
|
||||
.map_err(|error| Http3Error::Io(error.to_string()))?,
|
||||
};
|
||||
decode_http_datagram(&encoded, self.context_id, self.maximum_datagram)
|
||||
}
|
||||
|
||||
pub async fn close(&self, timeout: Duration) -> Result<(), Http3Error> {
|
||||
self.closed.cancel();
|
||||
tokio::time::timeout(timeout, self.io.close())
|
||||
.await
|
||||
.map_err(|_| Http3Error::Io("session close timeout".into()))?
|
||||
.map_err(|error| Http3Error::Io(error.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Common transport boundary and the Milestone 1 direct UDP transport.
|
||||
|
||||
use std::{
|
||||
collections::HashMap, future::Future, net::SocketAddr, pin::Pin, sync::Arc, time::Duration,
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
io::{split, AsyncRead, AsyncWrite},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
pub mod direct_udp;
|
||||
pub mod http3;
|
||||
pub mod quic;
|
||||
pub mod tls_tcp;
|
||||
|
||||
pub use direct_udp::{DirectUdpConnector, DirectUdpServer, DirectUdpSession};
|
||||
|
||||
pub type TransportFuture<'a, T> =
|
||||
Pin<Box<dyn Future<Output = Result<T, TransportError>> + Send + 'a>>;
|
||||
|
||||
/// Errors which are meaningful to the transport supervisor. In particular,
|
||||
/// authentication rejection is not folded into an ordinary I/O failure.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TransportError {
|
||||
#[error("transport authentication rejected: {0:?}")]
|
||||
AuthenticationRejected(crate::protocol::RejectReason),
|
||||
#[error("transport protocol error: {0}")]
|
||||
Protocol(#[from] crate::protocol::ProtocolError),
|
||||
#[error("transport I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("datagram exceeds transport maximum ({maximum} bytes)")]
|
||||
Oversized { maximum: usize },
|
||||
#[error("transport session is closed")]
|
||||
Closed,
|
||||
#[error("transport handshake timed out")]
|
||||
HandshakeTimeout,
|
||||
#[error("transport is not available: {0}")]
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// Logical datagram session shared by all future carrier implementations.
|
||||
pub trait TransportSession: Send + Sync {
|
||||
fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()>;
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes>;
|
||||
fn close<'a>(&'a self) -> TransportFuture<'a, ()>;
|
||||
fn max_datagram_size(&self) -> usize;
|
||||
}
|
||||
|
||||
/// Connection establishment is separate from an established session.
|
||||
pub trait TransportConnector: Send + Sync {
|
||||
fn connect<'a>(&'a self) -> TransportFuture<'a, Box<dyn TransportSession>>;
|
||||
}
|
||||
|
||||
type TlsRead = tokio::io::ReadHalf<BoxIo>;
|
||||
type TlsWrite = tokio::io::WriteHalf<BoxIo>;
|
||||
trait AsyncIo: AsyncRead + AsyncWrite + Send + Unpin {}
|
||||
impl<T: AsyncRead + AsyncWrite + Send + Unpin> AsyncIo for T {}
|
||||
type BoxIo = Box<dyn AsyncIo>;
|
||||
|
||||
struct TlsTcpSession {
|
||||
reader: Mutex<Option<tls_tcp::FramedReader<TlsRead>>>,
|
||||
writer: Mutex<Option<tls_tcp::FramedWriter<TlsWrite>>>,
|
||||
max_datagram: usize,
|
||||
closed: tokio_util::sync::CancellationToken,
|
||||
}
|
||||
|
||||
impl TlsTcpSession {
|
||||
fn from_halves(
|
||||
reader: TlsRead,
|
||||
writer: TlsWrite,
|
||||
max_datagram: usize,
|
||||
idle_timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
reader: Mutex::new(Some(tls_tcp::FramedReader::new(
|
||||
reader,
|
||||
max_datagram,
|
||||
Some(idle_timeout),
|
||||
))),
|
||||
writer: Mutex::new(Some(tls_tcp::FramedWriter::new(writer, max_datagram))),
|
||||
max_datagram,
|
||||
closed: tokio_util::sync::CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportSession for TlsTcpSession {
|
||||
fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if data.len() > self.max_datagram {
|
||||
return Err(TransportError::Oversized {
|
||||
maximum: self.max_datagram,
|
||||
});
|
||||
}
|
||||
let mut guard = self.writer.lock().await;
|
||||
let Some(writer) = guard.as_mut() else {
|
||||
return Err(TransportError::Closed);
|
||||
};
|
||||
writer
|
||||
.send_datagram(&data)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
tls_tcp::SessionError::Frame(tls_tcp::FrameError::Oversized {
|
||||
maximum,
|
||||
..
|
||||
}) => TransportError::Oversized { maximum },
|
||||
tls_tcp::SessionError::Closed => TransportError::Closed,
|
||||
other => TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
other.to_string(),
|
||||
)),
|
||||
})
|
||||
})
|
||||
}
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
|
||||
Box::pin(async move {
|
||||
let mut guard = self.reader.lock().await;
|
||||
let Some(reader) = guard.as_mut() else {
|
||||
return Err(TransportError::Closed);
|
||||
};
|
||||
reader
|
||||
.recv_datagram()
|
||||
.await
|
||||
.map(Bytes::from)
|
||||
.map_err(|error| match error {
|
||||
tls_tcp::SessionError::Closed => TransportError::Closed,
|
||||
tls_tcp::SessionError::Frame(tls_tcp::FrameError::Oversized {
|
||||
maximum,
|
||||
..
|
||||
}) => TransportError::Oversized { maximum },
|
||||
tls_tcp::SessionError::IdleTimeout => TransportError::Closed,
|
||||
other => TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
other.to_string(),
|
||||
)),
|
||||
})
|
||||
})
|
||||
}
|
||||
fn close<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if self.closed.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.closed.cancel();
|
||||
let writer = self.writer.lock().await.take();
|
||||
if let Some(mut writer) = writer {
|
||||
let _ = writer.send_close().await;
|
||||
writer
|
||||
.shutdown(Duration::from_secs(2))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
error.to_string(),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TlsTcpConnector {
|
||||
config: crate::config::ClientConfig,
|
||||
client_config: Option<Arc<rustls::ClientConfig>>,
|
||||
}
|
||||
impl TlsTcpConnector {
|
||||
pub fn new(config: crate::config::ClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client_config: None,
|
||||
}
|
||||
}
|
||||
pub fn with_client_config(
|
||||
config: crate::config::ClientConfig,
|
||||
client_config: rustls::ClientConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client_config: Some(Arc::new(client_config)),
|
||||
}
|
||||
}
|
||||
async fn connect_session(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let host = self.config.client.server.trim();
|
||||
let stream = TcpStream::connect((host, self.config.transport.tls_tcp.port)).await?;
|
||||
let connector_config = match &self.client_config {
|
||||
Some(config) => config.clone(),
|
||||
None => {
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
for cert in native.certs {
|
||||
roots.add(cert).map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if roots.is_empty() {
|
||||
return Err(TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"no system trust roots available",
|
||||
)));
|
||||
}
|
||||
tls_tcp::client_tls_config(roots)
|
||||
}
|
||||
};
|
||||
let connector = tokio_rustls::TlsConnector::from(connector_config);
|
||||
let server_name =
|
||||
rustls::pki_types::ServerName::try_from(host.to_owned()).map_err(|_| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"invalid TLS server name",
|
||||
))
|
||||
})?;
|
||||
let tls = connector.connect(server_name, stream).await.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionAborted,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let max = usize::from(self.config.network.max_datagram_size);
|
||||
let secret = self
|
||||
.config
|
||||
.client
|
||||
.secret
|
||||
.load_bytes("client")
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let (mut reader, mut writer) = split(Box::new(tls) as BoxIo);
|
||||
tls_tcp::client_authenticate(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&self.config.client.id,
|
||||
&secret,
|
||||
max.saturating_add(512),
|
||||
Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let session = TlsTcpSession::from_halves(reader, writer, max, Duration::from_secs(120));
|
||||
Ok(Box::new(session))
|
||||
}
|
||||
}
|
||||
impl TransportConnector for TlsTcpConnector {
|
||||
fn connect<'a>(&'a self) -> TransportFuture<'a, Box<dyn TransportSession>> {
|
||||
Box::pin(async move { self.connect_session().await })
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TlsTcpServer {
|
||||
listener: TcpListener,
|
||||
tls: Arc<rustls::ServerConfig>,
|
||||
clients: Arc<HashMap<String, Vec<u8>>>,
|
||||
max_datagram: usize,
|
||||
}
|
||||
impl TlsTcpServer {
|
||||
pub async fn bind(config: &crate::config::ServerConfig) -> Result<Self, TransportError> {
|
||||
let listen = if config.server.listen.parse::<SocketAddr>().is_ok() {
|
||||
config.server.listen.clone()
|
||||
} else {
|
||||
format!(
|
||||
"{}:{}",
|
||||
config.server.listen, config.transports.tls_tcp.port
|
||||
)
|
||||
};
|
||||
let listener = TcpListener::bind(listen).await?;
|
||||
let cert = config.tls.certificate.as_ref().ok_or_else(|| {
|
||||
TransportError::Unsupported("TLS/TCP requires tls.certificate/private_key".into())
|
||||
})?;
|
||||
let key = config.tls.private_key.as_ref().ok_or_else(|| {
|
||||
TransportError::Unsupported("TLS/TCP requires tls.certificate/private_key".into())
|
||||
})?;
|
||||
let identity = quic::load_tls_identity(cert, key)?;
|
||||
let tls = tls_tcp::server_tls_config(identity.0, identity.1).map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let mut clients = HashMap::new();
|
||||
for client in &config.auth.clients {
|
||||
clients.insert(
|
||||
client.id.clone(),
|
||||
client
|
||||
.secret
|
||||
.load_bytes(&format!("auth client {}", client.id))
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
e.to_string(),
|
||||
))
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(Self {
|
||||
listener,
|
||||
tls,
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: 1200,
|
||||
})
|
||||
}
|
||||
pub fn local_addr(&self) -> Result<SocketAddr, TransportError> {
|
||||
Ok(self.listener.local_addr()?)
|
||||
}
|
||||
pub async fn accept_session(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
let (stream, _) = self.listener.accept().await?;
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(self.tls.clone());
|
||||
let tls = acceptor.accept(stream).await.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionAborted,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
let (mut reader, mut writer) = split(Box::new(tls) as BoxIo);
|
||||
tls_tcp::server_authenticate(
|
||||
&mut reader,
|
||||
&mut writer,
|
||||
&self.clients,
|
||||
self.max_datagram.saturating_add(512),
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TransportError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
e.to_string(),
|
||||
))
|
||||
})?;
|
||||
Ok(Box::new(TlsTcpSession::from_halves(
|
||||
reader,
|
||||
writer,
|
||||
self.max_datagram,
|
||||
Duration::from_secs(120),
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! QUIC DATAGRAM transport.
|
||||
//!
|
||||
//! A QUIC connection is the transport session. The only stream used here is
|
||||
//! the short, reliable control stream used to authenticate a configured
|
||||
//! client. Once that exchange has completed, application bytes are carried
|
||||
//! exclusively in QUIC DATAGRAM frames.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs, io,
|
||||
net::{SocketAddr, ToSocketAddrs},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use quinn::{
|
||||
ClientConfig as QuinnClientConfig, Connection, Endpoint, ServerConfig as QuinnServerConfig,
|
||||
};
|
||||
use rand::RngCore;
|
||||
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::{
|
||||
config::{ClientConfig, ServerConfig},
|
||||
protocol::{auth, RejectReason, PROTOCOL_VERSION},
|
||||
};
|
||||
|
||||
use super::{TransportConnector, TransportError, TransportFuture, TransportSession};
|
||||
|
||||
const MAGIC: &[u8; 4] = b"WGCQ";
|
||||
const CONTROL_VERSION: u8 = PROTOCOL_VERSION;
|
||||
const MSG_INIT: u8 = 0;
|
||||
const MSG_CHALLENGE: u8 = 1;
|
||||
const MSG_RESPONSE: u8 = 2;
|
||||
const MSG_RESULT: u8 = 3;
|
||||
const CHALLENGE_LEN: usize = 4 + 1 + 1 + 32;
|
||||
const RESULT_LEN: usize = 4 + 1 + 1 + 1 + 2;
|
||||
const MAX_CLIENT_ID: usize = 255;
|
||||
const DEFAULT_MAX_DATAGRAM: usize = 1200;
|
||||
|
||||
fn io_other(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::Other, message.into())
|
||||
}
|
||||
|
||||
fn io_invalid(message: impl Into<String>) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message.into())
|
||||
}
|
||||
|
||||
fn quinn_io<E: std::fmt::Display>(error: E) -> TransportError {
|
||||
TransportError::Io(io_other(error.to_string()))
|
||||
}
|
||||
|
||||
fn transport_config(keepalive_secs: u64) -> Arc<quinn::TransportConfig> {
|
||||
let mut cfg = quinn::TransportConfig::default();
|
||||
if keepalive_secs > 0 {
|
||||
cfg.keep_alive_interval(Some(Duration::from_secs(keepalive_secs)));
|
||||
}
|
||||
// A bounded send queue prevents an application producer from retaining
|
||||
// unbounded memory while the path is congested.
|
||||
cfg.datagram_send_buffer_size(1024 * 1024);
|
||||
Arc::new(cfg)
|
||||
}
|
||||
|
||||
fn apply_transport_config(config: &mut QuinnClientConfig, keepalive_secs: u64) {
|
||||
config.transport_config(transport_config(keepalive_secs));
|
||||
}
|
||||
|
||||
fn apply_server_transport_config(config: &mut QuinnServerConfig, keepalive_secs: u64) {
|
||||
config.transport_config(transport_config(keepalive_secs));
|
||||
}
|
||||
|
||||
/// Read an X.509 chain and private key from normal PEM or DER files.
|
||||
///
|
||||
/// `rustls-pki-types` owns the PEM parser, so no additional PEM dependency is
|
||||
/// needed. DER fallback is useful for deployments that provision `.der`
|
||||
/// files directly.
|
||||
pub fn load_tls_identity(
|
||||
certificate: impl AsRef<Path>,
|
||||
private_key: impl AsRef<Path>,
|
||||
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), TransportError> {
|
||||
let cert_path = certificate.as_ref();
|
||||
let key_path = private_key.as_ref();
|
||||
let cert_bytes = fs::read(cert_path).map_err(TransportError::Io)?;
|
||||
let certs: Vec<CertificateDer<'static>> = if cert_bytes.starts_with(b"-----BEGIN") {
|
||||
CertificateDer::pem_slice_iter(&cert_bytes)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| io_invalid(format!("invalid certificate PEM: {e}")))?
|
||||
} else if cert_bytes.is_empty() {
|
||||
return Err(TransportError::Io(io_invalid("certificate file is empty")));
|
||||
} else {
|
||||
vec![CertificateDer::from(cert_bytes)]
|
||||
};
|
||||
if certs.is_empty() {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"certificate PEM contains no certificate",
|
||||
)));
|
||||
}
|
||||
|
||||
let key_bytes = fs::read(key_path).map_err(TransportError::Io)?;
|
||||
let key = if key_bytes.starts_with(b"-----BEGIN") {
|
||||
PrivateKeyDer::from_pem_slice(&key_bytes)
|
||||
.map_err(|e| io_invalid(format!("invalid private-key PEM: {e}")))?
|
||||
} else {
|
||||
PrivateKeyDer::try_from(key_bytes.as_slice())
|
||||
.map_err(|_| io_invalid("private key is not a supported DER key"))?
|
||||
.clone_key()
|
||||
};
|
||||
Ok((certs, key))
|
||||
}
|
||||
|
||||
fn server_config(
|
||||
config: &ServerConfig,
|
||||
keepalive_secs: u64,
|
||||
) -> Result<QuinnServerConfig, TransportError> {
|
||||
let certificate = config.tls.certificate.as_ref().ok_or_else(|| {
|
||||
TransportError::Io(io_invalid(
|
||||
"QUIC requires tls.certificate and tls.private_key",
|
||||
))
|
||||
})?;
|
||||
let private_key = config.tls.private_key.as_ref().ok_or_else(|| {
|
||||
TransportError::Io(io_invalid(
|
||||
"QUIC requires tls.certificate and tls.private_key",
|
||||
))
|
||||
})?;
|
||||
let (certs, key) = load_tls_identity(certificate, private_key)?;
|
||||
let mut result = QuinnServerConfig::with_single_cert(certs, key)
|
||||
.map_err(|e| io_invalid(format!("invalid TLS identity: {e}")))?;
|
||||
apply_server_transport_config(&mut result, keepalive_secs);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn client_config() -> Result<QuinnClientConfig, TransportError> {
|
||||
// Quinn's default build enables rustls' platform verifier. This uses the
|
||||
// operating system trust store and performs ordinary hostname validation.
|
||||
QuinnClientConfig::try_with_platform_verifier()
|
||||
.map_err(|e| io_invalid(format!("cannot construct TLS verifier: {e}")))
|
||||
.map_err(TransportError::Io)
|
||||
}
|
||||
|
||||
fn resolve_server(host: &str, port: u16) -> Result<SocketAddr, TransportError> {
|
||||
let mut addrs = (host, port).to_socket_addrs().map_err(TransportError::Io)?;
|
||||
addrs
|
||||
.next()
|
||||
.ok_or_else(|| TransportError::Io(io_invalid("server hostname resolved to no address")))
|
||||
}
|
||||
|
||||
fn resolve_listen(host: &str, port: u16) -> Result<SocketAddr, TransportError> {
|
||||
let address = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]:{port}")
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
address
|
||||
.parse::<SocketAddr>()
|
||||
.map_err(|e| TransportError::Io(io_invalid(format!("invalid QUIC listen address: {e}"))))
|
||||
}
|
||||
|
||||
fn write_header(kind: u8, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.push(CONTROL_VERSION);
|
||||
out.push(kind);
|
||||
}
|
||||
|
||||
async fn authenticate_client(
|
||||
connection: &Connection,
|
||||
client_id: &str,
|
||||
secret: &[u8],
|
||||
timeout_duration: Duration,
|
||||
) -> Result<usize, TransportError> {
|
||||
let (mut send, mut recv) = timeout(timeout_duration, connection.open_bi())
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let mut init = Vec::with_capacity(6);
|
||||
write_header(MSG_INIT, &mut init);
|
||||
timeout(timeout_duration, send.write_all(&init))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let mut challenge_msg = [0u8; CHALLENGE_LEN];
|
||||
timeout(timeout_duration, recv.read_exact(&mut challenge_msg))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
if &challenge_msg[..4] != MAGIC
|
||||
|| challenge_msg[4] != CONTROL_VERSION
|
||||
|| challenge_msg[5] != MSG_CHALLENGE
|
||||
{
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"invalid QUIC authentication challenge",
|
||||
)));
|
||||
}
|
||||
let mut challenge = [0u8; 32];
|
||||
challenge.copy_from_slice(&challenge_msg[6..]);
|
||||
let mut nonce = [0u8; 16];
|
||||
rand::rngs::OsRng.fill_bytes(&mut nonce);
|
||||
let id = client_id.as_bytes();
|
||||
if id.is_empty() || id.len() > MAX_CLIENT_ID {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"QUIC client id length out of range",
|
||||
)));
|
||||
}
|
||||
let mut mac_input = Vec::with_capacity(32 + 16 + id.len());
|
||||
mac_input.extend_from_slice(&challenge);
|
||||
mac_input.extend_from_slice(&nonce);
|
||||
mac_input.extend_from_slice(id);
|
||||
let tag = auth::hmac_sha256(secret, &mac_input);
|
||||
let mut response = Vec::with_capacity(8 + id.len() + 16 + 32);
|
||||
write_header(MSG_RESPONSE, &mut response);
|
||||
response.extend_from_slice(&(id.len() as u16).to_be_bytes());
|
||||
response.extend_from_slice(id);
|
||||
response.extend_from_slice(&nonce);
|
||||
response.extend_from_slice(&tag);
|
||||
timeout(timeout_duration, send.write_all(&response))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
send.finish().map_err(quinn_io)?;
|
||||
|
||||
let mut result = [0u8; RESULT_LEN];
|
||||
timeout(timeout_duration, recv.read_exact(&mut result))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
if &result[..4] != MAGIC || result[4] != CONTROL_VERSION || result[5] != MSG_RESULT {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"invalid QUIC authentication result",
|
||||
)));
|
||||
}
|
||||
if result[6] != 0 {
|
||||
return Err(TransportError::AuthenticationRejected(
|
||||
RejectReason::AuthenticationFailed,
|
||||
));
|
||||
}
|
||||
let remote_max = u16::from_be_bytes([result[7], result[8]]) as usize;
|
||||
if remote_max == 0 {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"server reported zero QUIC datagram maximum",
|
||||
)));
|
||||
}
|
||||
Ok(remote_max)
|
||||
}
|
||||
|
||||
/// An authenticated QUIC DATAGRAM session.
|
||||
pub struct QuicDatagramSession {
|
||||
connection: Connection,
|
||||
endpoint: Endpoint,
|
||||
max_datagram: usize,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
impl QuicDatagramSession {
|
||||
fn new(connection: Connection, endpoint: Endpoint, max_datagram: usize) -> Self {
|
||||
Self {
|
||||
connection,
|
||||
endpoint,
|
||||
max_datagram,
|
||||
closed: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Underlying QUIC connection, useful for diagnostics and RTT metrics.
|
||||
pub fn connection(&self) -> &Connection {
|
||||
&self.connection
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportSession for QuicDatagramSession {
|
||||
fn send_datagram<'a>(&'a self, data: Bytes) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if self.closed.is_cancelled() {
|
||||
return Err(TransportError::Closed);
|
||||
}
|
||||
if data.len() > self.max_datagram {
|
||||
return Err(TransportError::Oversized {
|
||||
maximum: self.max_datagram,
|
||||
});
|
||||
}
|
||||
let quic_max = self
|
||||
.connection
|
||||
.max_datagram_size()
|
||||
.unwrap_or(self.max_datagram);
|
||||
if data.len() > quic_max {
|
||||
return Err(TransportError::Oversized { maximum: quic_max });
|
||||
}
|
||||
self.connection.send_datagram(data).map_err(|e| match e {
|
||||
quinn::SendDatagramError::TooLarge => {
|
||||
TransportError::Oversized { maximum: quic_max }
|
||||
}
|
||||
quinn::SendDatagramError::ConnectionLost(_) => TransportError::Closed,
|
||||
other => quinn_io(other),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
let datagram = tokio::select! {
|
||||
_ = self.closed.cancelled() => return Err(TransportError::Closed),
|
||||
result = self.connection.read_datagram() => result.map_err(quinn_io)?,
|
||||
};
|
||||
// A peer may have negotiated a larger QUIC limit than the
|
||||
// project policy advertised during authentication. Enforce
|
||||
// that project limit on receives as well as sends.
|
||||
if datagram.len() <= self.max_datagram {
|
||||
return Ok(datagram);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn close<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
if !self.closed.is_cancelled() {
|
||||
self.closed.cancel();
|
||||
self.connection.close(0u32.into(), b"closed");
|
||||
// Dropping the endpoint handle is intentionally deferred to
|
||||
// session drop; this keeps the connection driver alive while
|
||||
// the close frame is emitted.
|
||||
let _ = &self.endpoint;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side QUIC connector.
|
||||
pub struct QuicClientConnector {
|
||||
config: ClientConfig,
|
||||
client_config: Option<QuinnClientConfig>,
|
||||
}
|
||||
|
||||
/// Short aliases for callers that prefer the role names used by the
|
||||
/// configuration and listener model.
|
||||
pub type QuicClient = QuicClientConnector;
|
||||
pub type QuicSession = QuicDatagramSession;
|
||||
|
||||
impl QuicClientConnector {
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Use a caller-supplied rustls-backed Quinn config (for example a test
|
||||
/// trust store). Production callers should use [`Self::new`], which uses
|
||||
/// the platform trust store and hostname verification.
|
||||
pub fn with_client_config(config: ClientConfig, client_config: QuinnClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client_config: Some(client_config),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_session(&self) -> Result<QuicDatagramSession, TransportError> {
|
||||
let host = self.config.client.server.trim();
|
||||
let addr = resolve_server(host, self.config.transport.quic.port)?;
|
||||
let mut endpoint = Endpoint::client("[::]:0".parse().expect("valid wildcard address"))
|
||||
.map_err(TransportError::Io)?;
|
||||
let mut cfg = match self.client_config.clone() {
|
||||
Some(cfg) => cfg,
|
||||
None => client_config()?,
|
||||
};
|
||||
apply_transport_config(&mut cfg, self.config.transport.quic.keepalive_secs);
|
||||
endpoint.set_default_client_config(cfg);
|
||||
let connecting = endpoint.connect(addr, host).map_err(quinn_io)?;
|
||||
let connection = timeout(
|
||||
Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)),
|
||||
connecting,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let secret = self
|
||||
.config
|
||||
.client
|
||||
.secret
|
||||
.load_bytes("client")
|
||||
.map_err(|e| TransportError::Io(io_invalid(e.to_string())))?;
|
||||
let server_max = authenticate_client(
|
||||
&connection,
|
||||
&self.config.client.id,
|
||||
&secret,
|
||||
Duration::from_millis(self.config.transport.auto.probe_timeout_ms.max(1)),
|
||||
)
|
||||
.await?;
|
||||
let quic_max = connection
|
||||
.max_datagram_size()
|
||||
.ok_or_else(|| TransportError::Io(io_invalid("QUIC DATAGRAM is not negotiated")))?;
|
||||
let max_datagram = usize::from(self.config.network.max_datagram_size)
|
||||
.min(server_max)
|
||||
.min(quic_max);
|
||||
if max_datagram == 0 {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"negotiated QUIC datagram maximum is zero",
|
||||
)));
|
||||
}
|
||||
info!(remote_addr=%addr, max_datagram, "QUIC session authenticated");
|
||||
Ok(QuicDatagramSession::new(connection, endpoint, max_datagram))
|
||||
}
|
||||
|
||||
pub async fn connect(&self) -> Result<Box<dyn TransportSession>, TransportError> {
|
||||
Ok(Box::new(self.connect_session().await?))
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportConnector for QuicClientConnector {
|
||||
fn connect<'a>(&'a self) -> TransportFuture<'a, Box<dyn TransportSession>> {
|
||||
Box::pin(async move { self.connect().await })
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-client QUIC listener. Authentication failures are returned to the
|
||||
/// caller as `AuthenticationRejected`; the endpoint itself remains usable for
|
||||
/// subsequent clients.
|
||||
pub struct QuicServer {
|
||||
endpoint: Endpoint,
|
||||
clients: Arc<HashMap<String, Vec<u8>>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
}
|
||||
|
||||
/// Alias matching the listener terminology used in the specification.
|
||||
pub type QuicListener = QuicServer;
|
||||
|
||||
impl QuicServer {
|
||||
pub async fn bind(config: &ServerConfig) -> Result<Self, TransportError> {
|
||||
let addr = resolve_listen(&config.server.listen, config.transports.quic.port)?;
|
||||
let tls = server_config(config, 15);
|
||||
let endpoint = Endpoint::server(tls?, addr).map_err(TransportError::Io)?;
|
||||
let mut clients = HashMap::new();
|
||||
for client in &config.auth.clients {
|
||||
let secret = client
|
||||
.secret
|
||||
.load_bytes(&format!("auth client {}", client.id))
|
||||
.map_err(|e| TransportError::Io(io_invalid(e.to_string())))?;
|
||||
clients.insert(client.id.clone(), secret);
|
||||
}
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: DEFAULT_MAX_DATAGRAM,
|
||||
handshake_timeout: Duration::from_millis(config.limits.handshake_timeout_ms.max(1)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a listener from an already configured endpoint. This keeps unit
|
||||
/// tests and embedders independent of certificate file provisioning.
|
||||
pub fn from_endpoint(
|
||||
endpoint: Endpoint,
|
||||
clients: HashMap<String, Vec<u8>>,
|
||||
max_datagram: usize,
|
||||
handshake_timeout: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
clients: Arc::new(clients),
|
||||
max_datagram: max_datagram.max(1),
|
||||
handshake_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_addr(&self) -> Result<SocketAddr, TransportError> {
|
||||
self.endpoint.local_addr().map_err(TransportError::Io)
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub async fn accept_session(&self) -> Result<QuicDatagramSession, TransportError> {
|
||||
let incoming = self.endpoint.accept().await.ok_or(TransportError::Closed)?;
|
||||
let connection = timeout(self.handshake_timeout, incoming)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let (mut send, mut recv) = timeout(self.handshake_timeout, connection.accept_bi())
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let mut init = [0u8; 6];
|
||||
timeout(self.handshake_timeout, recv.read_exact(&mut init))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
if &init[..4] != MAGIC || init[4] != CONTROL_VERSION || init[5] != MSG_INIT {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"invalid QUIC authentication preface",
|
||||
)));
|
||||
}
|
||||
let mut challenge = [0u8; 32];
|
||||
rand::rngs::OsRng.fill_bytes(&mut challenge);
|
||||
let mut challenge_msg = Vec::with_capacity(CHALLENGE_LEN);
|
||||
write_header(MSG_CHALLENGE, &mut challenge_msg);
|
||||
challenge_msg.extend_from_slice(&challenge);
|
||||
timeout(self.handshake_timeout, send.write_all(&challenge_msg))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
let response = timeout(
|
||||
self.handshake_timeout,
|
||||
recv.read_to_end(4 + 1 + 1 + 2 + MAX_CLIENT_ID + 16 + 32),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
if response.len() < 8 {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"truncated QUIC authentication response",
|
||||
)));
|
||||
}
|
||||
if &response[..4] != MAGIC || response[4] != CONTROL_VERSION || response[5] != MSG_RESPONSE
|
||||
{
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"invalid QUIC authentication response",
|
||||
)));
|
||||
}
|
||||
let id_len = u16::from_be_bytes([response[6], response[7]]) as usize;
|
||||
if id_len == 0 || id_len > MAX_CLIENT_ID || response.len() != 8 + id_len + 16 + 32 {
|
||||
return Err(TransportError::Io(io_invalid(
|
||||
"invalid QUIC client id length",
|
||||
)));
|
||||
}
|
||||
let id_bytes = &response[8..8 + id_len];
|
||||
let id = std::str::from_utf8(id_bytes).map_err(|_| {
|
||||
TransportError::AuthenticationRejected(RejectReason::AuthenticationFailed)
|
||||
})?;
|
||||
let Some(secret) = self.clients.get(id) else {
|
||||
let _ = send_result(&mut send, 1, 0, self.handshake_timeout).await;
|
||||
// Give the endpoint driver a chance to transmit the rejection
|
||||
// before this per-connection handler drops its stream.
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
return Err(TransportError::AuthenticationRejected(
|
||||
RejectReason::AuthenticationFailed,
|
||||
));
|
||||
};
|
||||
let nonce_at = 8 + id_len;
|
||||
let mut nonce = [0u8; 16];
|
||||
nonce.copy_from_slice(&response[nonce_at..nonce_at + 16]);
|
||||
let mut input = Vec::with_capacity(32 + 16 + id_len);
|
||||
input.extend_from_slice(&challenge);
|
||||
input.extend_from_slice(&nonce);
|
||||
input.extend_from_slice(id_bytes);
|
||||
let expected = auth::hmac_sha256(secret, &input);
|
||||
if !bool::from(expected.as_slice().ct_eq(&response[nonce_at + 16..])) {
|
||||
let _ = send_result(&mut send, 1, 0, self.handshake_timeout).await;
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
return Err(TransportError::AuthenticationRejected(
|
||||
RejectReason::AuthenticationFailed,
|
||||
));
|
||||
}
|
||||
let quic_max = connection
|
||||
.max_datagram_size()
|
||||
.ok_or_else(|| TransportError::Io(io_invalid("QUIC DATAGRAM is not negotiated")))?;
|
||||
let max_datagram = self.max_datagram.min(quic_max).min(u16::MAX as usize);
|
||||
send_result(&mut send, 0, max_datagram as u16, self.handshake_timeout).await?;
|
||||
debug!(client_id=%id, remote_addr=%connection.remote_address(), max_datagram, "QUIC client authenticated");
|
||||
Ok(QuicDatagramSession::new(
|
||||
connection,
|
||||
self.endpoint.clone(),
|
||||
max_datagram,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn accept(&self) -> Result<QuicDatagramSession, TransportError> {
|
||||
self.accept_session().await
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.endpoint.close(0u32.into(), b"server stopped");
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_result(
|
||||
send: &mut quinn::SendStream,
|
||||
status: u8,
|
||||
max_datagram: u16,
|
||||
duration: Duration,
|
||||
) -> Result<(), TransportError> {
|
||||
let mut result = Vec::with_capacity(RESULT_LEN);
|
||||
write_header(MSG_RESULT, &mut result);
|
||||
result.push(status);
|
||||
result.extend_from_slice(&max_datagram.to_be_bytes());
|
||||
timeout(duration, send.write_all(&result))
|
||||
.await
|
||||
.map_err(|_| TransportError::HandshakeTimeout)?
|
||||
.map_err(quinn_io)?;
|
||||
send.finish().map_err(quinn_io)
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
//! TLS/TCP fallback building blocks.
|
||||
//!
|
||||
//! TCP has no datagram boundaries, so this module supplies a bounded, big-endian
|
||||
//! u16 length codec and an authenticated framed session. The framing/session
|
||||
//! code is intentionally independent of the async TLS adapter: the repository
|
||||
//! currently does not depend on `tokio-rustls`. Callers can wrap a Tokio stream
|
||||
//! with `tokio_rustls::TlsConnector`/`TlsAcceptor`, then pass the resulting
|
||||
//! stream halves to these helpers.
|
||||
|
||||
use std::{collections::HashMap, fmt, io, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use rustls::{
|
||||
pki_types::{
|
||||
CertificateDer, PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer, PrivateSec1KeyDer,
|
||||
},
|
||||
ClientConfig, RootCertStore, ServerConfig,
|
||||
};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
const MAX_WIRE_FRAME: usize = u16::MAX as usize;
|
||||
const AUTH_VERSION: u8 = 1;
|
||||
const CHALLENGE_LEN: usize = 32;
|
||||
const CLIENT_NONCE_LEN: usize = 16;
|
||||
const MAC_LEN: usize = 32;
|
||||
const MAX_CLIENT_ID: usize = u8::MAX as usize;
|
||||
const DATA_KIND: u8 = 0x01;
|
||||
const KEEPALIVE_KIND: u8 = 0x02;
|
||||
const CLOSE_KIND: u8 = 0x03;
|
||||
const AUTH_CHALLENGE_KIND: u8 = 0x10;
|
||||
const AUTH_RESPONSE_KIND: u8 = 0x11;
|
||||
const AUTH_ACCEPT_KIND: u8 = 0x12;
|
||||
const AUTH_REJECT_KIND: u8 = 0x13;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FrameError {
|
||||
MaxTooLarge,
|
||||
Oversized { length: usize, maximum: usize },
|
||||
Truncated,
|
||||
Io(String),
|
||||
InvalidControl,
|
||||
}
|
||||
|
||||
impl fmt::Display for FrameError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MaxTooLarge => write!(f, "frame maximum exceeds u16 wire limit"),
|
||||
Self::Oversized { length, maximum } => {
|
||||
write!(
|
||||
f,
|
||||
"frame length {length} exceeds configured maximum {maximum}"
|
||||
)
|
||||
}
|
||||
Self::Truncated => write!(f, "truncated frame"),
|
||||
Self::Io(error) => write!(f, "I/O error: {error}"),
|
||||
Self::InvalidControl => write!(f, "invalid TLS/TCP control frame"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FrameError {}
|
||||
|
||||
impl From<io::Error> for FrameError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Io(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental u16-big-endian frame decoder.
|
||||
///
|
||||
/// It consumes only the declared payload length and rejects an oversized
|
||||
/// header before allocating a payload buffer. Consequently, arbitrary TCP
|
||||
/// chunks, coalesced frames, and a truncated final frame are handled without
|
||||
/// relying on `read()` boundaries.
|
||||
#[derive(Debug)]
|
||||
pub struct FrameCodec {
|
||||
maximum: usize,
|
||||
header: [u8; 2],
|
||||
header_len: usize,
|
||||
payload: Option<(Vec<u8>, usize)>,
|
||||
}
|
||||
|
||||
impl FrameCodec {
|
||||
pub fn new(maximum: usize) -> Result<Self, FrameError> {
|
||||
if maximum > MAX_WIRE_FRAME {
|
||||
return Err(FrameError::MaxTooLarge);
|
||||
}
|
||||
Ok(Self {
|
||||
maximum,
|
||||
header: [0; 2],
|
||||
header_len: 0,
|
||||
payload: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn maximum(&self) -> usize {
|
||||
self.maximum
|
||||
}
|
||||
|
||||
pub fn encode(&self, payload: &[u8]) -> Result<Vec<u8>, FrameError> {
|
||||
if payload.len() > self.maximum || payload.len() > MAX_WIRE_FRAME {
|
||||
return Err(FrameError::Oversized {
|
||||
length: payload.len(),
|
||||
maximum: self.maximum,
|
||||
});
|
||||
}
|
||||
let mut frame = Vec::with_capacity(payload.len() + 2);
|
||||
frame.extend_from_slice(&(payload.len() as u16).to_be_bytes());
|
||||
frame.extend_from_slice(payload);
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, mut input: &[u8]) -> Result<Vec<Vec<u8>>, FrameError> {
|
||||
let mut frames = Vec::new();
|
||||
while !input.is_empty() {
|
||||
if self.header_len < 2 {
|
||||
let count = (2 - self.header_len).min(input.len());
|
||||
self.header[self.header_len..self.header_len + count]
|
||||
.copy_from_slice(&input[..count]);
|
||||
self.header_len += count;
|
||||
input = &input[count..];
|
||||
if self.header_len < 2 {
|
||||
break;
|
||||
}
|
||||
|
||||
let length = u16::from_be_bytes(self.header) as usize;
|
||||
if length > self.maximum {
|
||||
self.reset();
|
||||
return Err(FrameError::Oversized {
|
||||
length,
|
||||
maximum: self.maximum,
|
||||
});
|
||||
}
|
||||
self.payload = Some((vec![0; length], 0));
|
||||
}
|
||||
|
||||
let Some((payload, filled)) = self.payload.as_mut() else {
|
||||
return Err(FrameError::InvalidControl);
|
||||
};
|
||||
let count = (payload.len() - *filled).min(input.len());
|
||||
payload[*filled..*filled + count].copy_from_slice(&input[..count]);
|
||||
*filled += count;
|
||||
input = &input[count..];
|
||||
if *filled == payload.len() {
|
||||
let (payload, _) = self.payload.take().expect("payload exists");
|
||||
frames.push(payload);
|
||||
self.header_len = 0;
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
pub fn finish(&self) -> Result<(), FrameError> {
|
||||
if self.header_len != 0 || self.payload.is_some() {
|
||||
Err(FrameError::Truncated)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.header_len = 0;
|
||||
self.payload = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_frame<R: AsyncRead + Unpin>(
|
||||
reader: &mut R,
|
||||
maximum: usize,
|
||||
) -> Result<Vec<u8>, FrameError> {
|
||||
let codec = FrameCodec::new(maximum)?;
|
||||
let mut header = [0u8; 2];
|
||||
reader
|
||||
.read_exact(&mut header)
|
||||
.await
|
||||
.map_err(map_read_error)?;
|
||||
let length = u16::from_be_bytes(header) as usize;
|
||||
if length > codec.maximum() {
|
||||
return Err(FrameError::Oversized { length, maximum });
|
||||
}
|
||||
let mut payload = vec![0u8; length];
|
||||
reader
|
||||
.read_exact(&mut payload)
|
||||
.await
|
||||
.map_err(map_read_error)?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn map_read_error(error: io::Error) -> FrameError {
|
||||
if error.kind() == io::ErrorKind::UnexpectedEof {
|
||||
FrameError::Truncated
|
||||
} else {
|
||||
FrameError::Io(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_frame<W: AsyncWrite + Unpin>(
|
||||
writer: &mut W,
|
||||
payload: &[u8],
|
||||
maximum: usize,
|
||||
) -> Result<(), FrameError> {
|
||||
let codec = FrameCodec::new(maximum)?;
|
||||
let frame = codec.encode(payload)?;
|
||||
writer.write_all(&frame).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthChallenge {
|
||||
pub bytes: [u8; CHALLENGE_LEN],
|
||||
}
|
||||
|
||||
impl AuthChallenge {
|
||||
pub fn random() -> Self {
|
||||
let mut bytes = [0u8; CHALLENGE_LEN];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
Self { bytes }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthenticatedClient {
|
||||
pub id: String,
|
||||
pub nonce: [u8; CLIENT_NONCE_LEN],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthError {
|
||||
Frame(FrameError),
|
||||
InvalidMessage,
|
||||
Rejected,
|
||||
UnknownClient,
|
||||
InvalidCredentials,
|
||||
}
|
||||
|
||||
impl fmt::Display for AuthError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Frame(error) => error.fmt(f),
|
||||
Self::InvalidMessage => write!(f, "invalid authentication message"),
|
||||
Self::Rejected => write!(f, "server rejected authentication"),
|
||||
Self::UnknownClient => write!(f, "unknown client"),
|
||||
Self::InvalidCredentials => write!(f, "invalid client credentials"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuthError {}
|
||||
|
||||
impl From<FrameError> for AuthError {
|
||||
fn from(error: FrameError) -> Self {
|
||||
Self::Frame(error)
|
||||
}
|
||||
}
|
||||
|
||||
fn challenge_mac(
|
||||
challenge: &[u8; CHALLENGE_LEN],
|
||||
client_id: &[u8],
|
||||
client_nonce: &[u8; CLIENT_NONCE_LEN],
|
||||
secret: &[u8],
|
||||
) -> [u8; MAC_LEN] {
|
||||
let mut input = Vec::with_capacity(1 + CHALLENGE_LEN + 1 + client_id.len() + CLIENT_NONCE_LEN);
|
||||
input.push(AUTH_VERSION);
|
||||
input.extend_from_slice(challenge);
|
||||
input.push(client_id.len() as u8);
|
||||
input.extend_from_slice(client_id);
|
||||
input.extend_from_slice(client_nonce);
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts every key length");
|
||||
mac.update(&input);
|
||||
mac.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
pub fn encode_auth_response(
|
||||
challenge: &AuthChallenge,
|
||||
client_id: &str,
|
||||
client_nonce: &[u8; CLIENT_NONCE_LEN],
|
||||
secret: &[u8],
|
||||
) -> Result<Vec<u8>, AuthError> {
|
||||
let id = client_id.as_bytes();
|
||||
if id.is_empty() || id.len() > MAX_CLIENT_ID {
|
||||
return Err(AuthError::InvalidMessage);
|
||||
}
|
||||
let mac = challenge_mac(&challenge.bytes, id, client_nonce, secret);
|
||||
let mut message = Vec::with_capacity(1 + 1 + id.len() + CLIENT_NONCE_LEN + MAC_LEN);
|
||||
message.push(AUTH_RESPONSE_KIND);
|
||||
message.push(id.len() as u8);
|
||||
message.extend_from_slice(id);
|
||||
message.extend_from_slice(client_nonce);
|
||||
message.extend_from_slice(&mac);
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn decode_auth_response(
|
||||
challenge: &AuthChallenge,
|
||||
message: &[u8],
|
||||
clients: &HashMap<String, Vec<u8>>,
|
||||
) -> Result<AuthenticatedClient, AuthError> {
|
||||
if message.len() < 2 + CLIENT_NONCE_LEN + MAC_LEN || message[0] != AUTH_RESPONSE_KIND {
|
||||
return Err(AuthError::InvalidMessage);
|
||||
}
|
||||
let id_len = message[1] as usize;
|
||||
let expected = 2 + id_len + CLIENT_NONCE_LEN + MAC_LEN;
|
||||
if id_len == 0 || id_len > MAX_CLIENT_ID || expected != message.len() {
|
||||
return Err(AuthError::InvalidMessage);
|
||||
}
|
||||
let id = std::str::from_utf8(&message[2..2 + id_len]).map_err(|_| AuthError::InvalidMessage)?;
|
||||
let mut nonce = [0u8; CLIENT_NONCE_LEN];
|
||||
nonce.copy_from_slice(&message[2 + id_len..2 + id_len + CLIENT_NONCE_LEN]);
|
||||
let mut received = [0u8; MAC_LEN];
|
||||
received.copy_from_slice(&message[2 + id_len + CLIENT_NONCE_LEN..]);
|
||||
let Some(secret) = clients.get(id) else {
|
||||
return Err(AuthError::UnknownClient);
|
||||
};
|
||||
let expected_mac = challenge_mac(&challenge.bytes, id.as_bytes(), &nonce, secret);
|
||||
if !bool::from(expected_mac.ct_eq(&received)) {
|
||||
return Err(AuthError::InvalidCredentials);
|
||||
}
|
||||
Ok(AuthenticatedClient {
|
||||
id: id.to_owned(),
|
||||
nonce,
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the server side challenge/response exchange over an already TLS
|
||||
/// protected stream. Backend allocation must happen only after this returns.
|
||||
pub async fn server_authenticate<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
clients: &HashMap<String, Vec<u8>>,
|
||||
maximum: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<AuthenticatedClient, AuthError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let challenge = AuthChallenge::random();
|
||||
let mut message = Vec::with_capacity(1 + CHALLENGE_LEN);
|
||||
message.push(AUTH_CHALLENGE_KIND);
|
||||
message.extend_from_slice(&challenge.bytes);
|
||||
tokio::time::timeout(timeout, write_frame(writer, &message, maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
let response = tokio::time::timeout(timeout, read_frame(reader, maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
let result = decode_auth_response(&challenge, &response, clients);
|
||||
let outcome = if result.is_ok() {
|
||||
AUTH_ACCEPT_KIND
|
||||
} else {
|
||||
// Keep rejection details on the server; expose only a generic class.
|
||||
AUTH_REJECT_KIND
|
||||
};
|
||||
tokio::time::timeout(timeout, write_frame(writer, &[outcome], maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
result
|
||||
}
|
||||
|
||||
/// Perform the client side challenge/response exchange over an already TLS
|
||||
/// protected stream.
|
||||
pub async fn client_authenticate<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
client_id: &str,
|
||||
secret: &[u8],
|
||||
maximum: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<(), AuthError>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
let challenge = tokio::time::timeout(timeout, read_frame(reader, maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
if challenge.len() != 1 + CHALLENGE_LEN || challenge[0] != AUTH_CHALLENGE_KIND {
|
||||
return Err(AuthError::InvalidMessage);
|
||||
}
|
||||
let mut bytes = [0u8; CHALLENGE_LEN];
|
||||
bytes.copy_from_slice(&challenge[1..]);
|
||||
let challenge = AuthChallenge { bytes };
|
||||
let mut nonce = [0u8; CLIENT_NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let response = encode_auth_response(&challenge, client_id, &nonce, secret)?;
|
||||
tokio::time::timeout(timeout, write_frame(writer, &response, maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
let outcome = tokio::time::timeout(timeout, read_frame(reader, maximum))
|
||||
.await
|
||||
.map_err(|_| AuthError::Frame(FrameError::Io("authentication timeout".into())))??;
|
||||
match outcome.as_slice() {
|
||||
[AUTH_ACCEPT_KIND] => Ok(()),
|
||||
[AUTH_REJECT_KIND] => Err(AuthError::Rejected),
|
||||
_ => Err(AuthError::InvalidMessage),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SessionError {
|
||||
Frame(FrameError),
|
||||
IdleTimeout,
|
||||
Closed,
|
||||
InvalidMessage,
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Frame(error) => error.fmt(f),
|
||||
Self::IdleTimeout => write!(f, "session idle timeout"),
|
||||
Self::Closed => write!(f, "session closed"),
|
||||
Self::InvalidMessage => write!(f, "invalid session message"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SessionError {}
|
||||
|
||||
impl From<FrameError> for SessionError {
|
||||
fn from(error: FrameError) -> Self {
|
||||
Self::Frame(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for SessionError {
|
||||
fn from(error: io::Error) -> Self {
|
||||
Self::Frame(FrameError::from(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read side of an authenticated framed session. Data frames have a one-byte
|
||||
/// kind prefix so zero-length UDP datagrams remain distinguishable from
|
||||
/// keepalive frames.
|
||||
pub struct FramedReader<R> {
|
||||
reader: R,
|
||||
maximum_datagram: usize,
|
||||
idle_timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> FramedReader<R> {
|
||||
pub fn new(reader: R, maximum_datagram: usize, idle_timeout: Option<Duration>) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
maximum_datagram,
|
||||
idle_timeout,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn recv_datagram(&mut self) -> Result<Vec<u8>, SessionError> {
|
||||
loop {
|
||||
let future = read_frame(&mut self.reader, self.maximum_datagram.saturating_add(1));
|
||||
let frame = match self.idle_timeout {
|
||||
Some(timeout) => tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| SessionError::IdleTimeout)??,
|
||||
None => future.await?,
|
||||
};
|
||||
let Some((&kind, payload)) = frame.split_first() else {
|
||||
return Err(SessionError::InvalidMessage);
|
||||
};
|
||||
match kind {
|
||||
DATA_KIND if payload.len() <= self.maximum_datagram => return Ok(payload.to_vec()),
|
||||
DATA_KIND => {
|
||||
return Err(SessionError::Frame(FrameError::Oversized {
|
||||
length: payload.len(),
|
||||
maximum: self.maximum_datagram,
|
||||
}))
|
||||
}
|
||||
KEEPALIVE_KIND if payload.is_empty() => continue,
|
||||
CLOSE_KIND if payload.is_empty() => return Err(SessionError::Closed),
|
||||
_ => return Err(SessionError::InvalidMessage),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> R {
|
||||
self.reader
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FramedWriter<W> {
|
||||
writer: W,
|
||||
maximum_datagram: usize,
|
||||
}
|
||||
|
||||
impl<W: AsyncWrite + Unpin> FramedWriter<W> {
|
||||
pub fn new(writer: W, maximum_datagram: usize) -> Self {
|
||||
Self {
|
||||
writer,
|
||||
maximum_datagram,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_datagram(&mut self, payload: &[u8]) -> Result<(), SessionError> {
|
||||
if payload.len() > self.maximum_datagram {
|
||||
return Err(SessionError::Frame(FrameError::Oversized {
|
||||
length: payload.len(),
|
||||
maximum: self.maximum_datagram,
|
||||
}));
|
||||
}
|
||||
let mut frame = Vec::with_capacity(payload.len() + 1);
|
||||
frame.push(DATA_KIND);
|
||||
frame.extend_from_slice(payload);
|
||||
write_frame(
|
||||
&mut self.writer,
|
||||
&frame,
|
||||
self.maximum_datagram.saturating_add(1),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keepalive(&mut self) -> Result<(), SessionError> {
|
||||
write_frame(
|
||||
&mut self.writer,
|
||||
&[KEEPALIVE_KIND],
|
||||
self.maximum_datagram.saturating_add(1),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_close(&mut self) -> Result<(), SessionError> {
|
||||
write_frame(
|
||||
&mut self.writer,
|
||||
&[CLOSE_KIND],
|
||||
self.maximum_datagram.saturating_add(1),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self, timeout: Duration) -> Result<(), SessionError> {
|
||||
tokio::time::timeout(timeout, self.writer.shutdown())
|
||||
.await
|
||||
.map_err(|_| SessionError::Frame(FrameError::Io("shutdown timeout".into())))??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> W {
|
||||
self.writer
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a client config with ordinary root-certificate verification. The
|
||||
/// caller supplies roots from the host policy or a CA bundle; an empty store
|
||||
/// is intentionally accepted by the type but will reject all certificates.
|
||||
pub fn client_tls_config(roots: RootCertStore) -> Arc<ClientConfig> {
|
||||
Arc::new(
|
||||
ClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn server_tls_config(
|
||||
certificates: Vec<CertificateDer<'static>>,
|
||||
private_key: PrivateKeyDer<'static>,
|
||||
) -> Result<Arc<ServerConfig>, rustls::Error> {
|
||||
Ok(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certificates, private_key)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn load_certificates(path: impl AsRef<Path>) -> io::Result<Vec<CertificateDer<'static>>> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
if bytes.starts_with(b"-----BEGIN") {
|
||||
parse_pem_blocks(&bytes, "CERTIFICATE")
|
||||
.map(|blocks| blocks.into_iter().map(CertificateDer::from).collect())
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
|
||||
} else {
|
||||
Ok(vec![CertificateDer::from(bytes)])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_private_key(path: impl AsRef<Path>) -> io::Result<PrivateKeyDer<'static>> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
if !bytes.starts_with(b"-----BEGIN") {
|
||||
return Ok(PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes)));
|
||||
}
|
||||
if let Ok(blocks) = parse_pem_blocks(&bytes, "PRIVATE KEY") {
|
||||
return blocks
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|block| PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(block)))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty private key PEM"));
|
||||
}
|
||||
if let Ok(blocks) = parse_pem_blocks(&bytes, "RSA PRIVATE KEY") {
|
||||
return blocks
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|block| PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from(block)))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty RSA key PEM"));
|
||||
}
|
||||
if let Ok(blocks) = parse_pem_blocks(&bytes, "EC PRIVATE KEY") {
|
||||
return blocks
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|block| PrivateKeyDer::Sec1(PrivateSec1KeyDer::from(block)))
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty EC key PEM"));
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"unsupported private key PEM (expected PRIVATE KEY, RSA PRIVATE KEY, or EC PRIVATE KEY)",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_pem_blocks(input: &[u8], label: &str) -> Result<Vec<Vec<u8>>, String> {
|
||||
let begin = format!("-----BEGIN {label}-----");
|
||||
let end = format!("-----END {label}-----");
|
||||
let text = std::str::from_utf8(input).map_err(|_| "PEM is not UTF-8".to_owned())?;
|
||||
let mut rest = text;
|
||||
let mut blocks = Vec::new();
|
||||
while let Some(start) = rest.find(&begin) {
|
||||
rest = &rest[start + begin.len()..];
|
||||
let stop = rest
|
||||
.find(&end)
|
||||
.ok_or_else(|| "unterminated PEM block".to_owned())?;
|
||||
let encoded: String = rest[..stop]
|
||||
.chars()
|
||||
.filter(|c| !c.is_ascii_whitespace())
|
||||
.collect();
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| "invalid PEM base64".to_owned())?;
|
||||
blocks.push(decoded);
|
||||
rest = &rest[stop + end.len()..];
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
Err(format!("no {label} PEM block found"))
|
||||
} else {
|
||||
Ok(blocks)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#[path = "../src/control.rs"]
|
||||
mod control;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_commands_and_socket_permissions() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("control.sock");
|
||||
let state = control::ControlState::default();
|
||||
state.set_status("ready").await;
|
||||
state.set_stats_json("{\"packets\":3}").await;
|
||||
let server = control::ControlServer::bind(&path, state.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let task = tokio::spawn(server.run());
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
assert!(control::send_command(&path, "status")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("ready"));
|
||||
assert!(control::send_command(&path, "stats")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("packets"));
|
||||
assert!(control::send_command(&path, "reconnect")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
assert!(control::send_command(&path, "reload")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
assert!(control::send_command(&path, "shutdown")
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("true"));
|
||||
task.await.unwrap().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
#[path = "../src/config.rs"]
|
||||
mod config;
|
||||
#[path = "../src/datagram.rs"]
|
||||
mod datagram;
|
||||
#[path = "../src/protocol/mod.rs"]
|
||||
mod protocol;
|
||||
#[path = "../src/stats.rs"]
|
||||
mod stats;
|
||||
#[path = "../src/transport/mod.rs"]
|
||||
mod transport;
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use config::Config;
|
||||
use tokio::{net::UdpSocket, time::timeout};
|
||||
use transport::{
|
||||
direct_udp::{DirectUdpServer, DirectUdpSession},
|
||||
TransportError, TransportSession,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn configs(port: u16, backend: u16) -> (config::ServerConfig, config::ClientConfig) {
|
||||
let server = Config::from_str(&format!(
|
||||
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n"
|
||||
)).unwrap().into_server().unwrap();
|
||||
let client = Config::from_str(&format!(
|
||||
"[client]\nserver = \"127.0.0.1\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
|
||||
)).unwrap().into_client().unwrap();
|
||||
(server, client)
|
||||
}
|
||||
|
||||
fn multi_configs(
|
||||
port: u16,
|
||||
backend: u16,
|
||||
) -> (
|
||||
config::ServerConfig,
|
||||
config::ClientConfig,
|
||||
config::ClientConfig,
|
||||
) {
|
||||
let server = Config::from_str(&format!(
|
||||
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:{backend}\"\n[transports.direct_udp]\nport = {port}\n[[auth.clients]]\nid = \"one\"\nsecret = \"{SECRET}\"\n[[auth.clients]]\nid = \"two\"\nsecret = \"{SECRET}\"\n"
|
||||
)).unwrap().into_server().unwrap();
|
||||
let client = |id: &str| {
|
||||
Config::from_str(&format!(
|
||||
"[client]\nserver = \"127.0.0.1\"\nid = \"{id}\"\nsecret = \"{SECRET}\"\n[transport.direct_udp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
|
||||
)).unwrap().into_client().unwrap()
|
||||
};
|
||||
(server, client("one"), client("two"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_round_trip_and_auth_rejection() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let port = 45781;
|
||||
let (server_config, client_config) = configs(port, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let stats = server.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
});
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque")
|
||||
);
|
||||
let mut wrong = client_config.clone();
|
||||
wrong.client.secret.secret =
|
||||
Some("BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned());
|
||||
let result = DirectUdpSession::connect(&wrong).await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransportError::AuthenticationRejected(_))
|
||||
));
|
||||
session.close().await.unwrap();
|
||||
backend_task.await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 0);
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_udp_enforces_payload_limit_and_expires_idle_session() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (mut server_config, client_config) = configs(45782, backend_addr.port());
|
||||
server_config.session.idle_timeout_secs = 1;
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let stats = server.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
assert!(matches!(
|
||||
session.send_datagram(Bytes::from(vec![0u8; 1201])).await,
|
||||
Err(TransportError::Oversized { maximum: 1200 })
|
||||
));
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
assert_eq!(stats.snapshot().expired_sessions, 1);
|
||||
session.close().await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_clients_use_independent_backend_sockets() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, mut client_one, mut client_two) = multi_configs(45783, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let stats = server.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
client_one.transport.direct_udp.port = 45783;
|
||||
client_two.transport.direct_udp.port = 45783;
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut peers = Vec::new();
|
||||
let mut buf = [0u8; 2048];
|
||||
for _ in 0..2 {
|
||||
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
|
||||
peers.push(peer);
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
}
|
||||
peers
|
||||
});
|
||||
let first = DirectUdpSession::connect(&client_one).await.unwrap();
|
||||
let second = DirectUdpSession::connect(&client_two).await.unwrap();
|
||||
assert!(stats.snapshot().handshake_attempts >= 2);
|
||||
first
|
||||
.send_datagram(Bytes::from_static(b"first"))
|
||||
.await
|
||||
.unwrap();
|
||||
second
|
||||
.send_datagram(Bytes::from_static(b"second"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), first.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"first")
|
||||
);
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), second.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"second")
|
||||
);
|
||||
let peers = backend_task.await.unwrap();
|
||||
assert_ne!(
|
||||
peers[0], peers[1],
|
||||
"each authenticated client must have its own backend socket"
|
||||
);
|
||||
first.close().await.unwrap();
|
||||
second.close().await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_and_unauthenticated_packets_do_not_kill_server() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, client_config) = configs(45784, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let stats = server.stats();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let raw = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
raw.send_to(&[1, 2, 3], "127.0.0.1:45784").await.unwrap();
|
||||
raw.send_to(&[0u8; 64], "127.0.0.1:45784").await.unwrap();
|
||||
let mut wrong = client_config.clone();
|
||||
wrong.client.secret.secret = Some("B".repeat(64));
|
||||
assert!(matches!(
|
||||
DirectUdpSession::connect(&wrong).await,
|
||||
Err(TransportError::AuthenticationRejected(_))
|
||||
));
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
});
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
session
|
||||
.send_datagram(Bytes::from_static(b"still-alive"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"still-alive")
|
||||
);
|
||||
assert!(stats.snapshot().datagrams_dropped >= 3);
|
||||
session.close().await.unwrap();
|
||||
backend_task.await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_source_port_change_updates_return_peer() {
|
||||
let backend = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let backend_addr = backend.local_addr().unwrap();
|
||||
let (server_config, _) = configs(45785, backend_addr.port());
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let first = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let second = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let secret = base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap();
|
||||
let mut nonce = [0u8; 16];
|
||||
nonce[0] = 1;
|
||||
let hello = protocol::HelloPacket::authenticated(
|
||||
&secret,
|
||||
Bytes::from_static(b"test"),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
nonce,
|
||||
)
|
||||
.unwrap();
|
||||
first
|
||||
.send_to(&hello.encode().unwrap(), "127.0.0.1:45785")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut wire = [0u8; 2048];
|
||||
let (len, _) = timeout(Duration::from_secs(2), first.recv_from(&mut wire))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let accept = match protocol::parse_packet(&wire[..len]).unwrap() {
|
||||
protocol::Packet::Accept(packet) => packet,
|
||||
other => panic!("unexpected handshake response: {other:?}"),
|
||||
};
|
||||
let key =
|
||||
protocol::derive_session_key(&secret, &nonce, &accept.server_nonce, accept.session_id);
|
||||
let data = protocol::DataPacket::authenticated(
|
||||
&key,
|
||||
accept.session_id,
|
||||
1,
|
||||
Bytes::from_static(b"rebound"),
|
||||
);
|
||||
second
|
||||
.send_to(&data.encode().unwrap(), "127.0.0.1:45785")
|
||||
.await
|
||||
.unwrap();
|
||||
let backend_task = tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let (len, peer) = backend.recv_from(&mut buf).await.unwrap();
|
||||
backend.send_to(&buf[..len], peer).await.unwrap();
|
||||
});
|
||||
let (len, _) = timeout(Duration::from_secs(2), second.recv_from(&mut wire))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match protocol::parse_packet(&wire[..len]).unwrap() {
|
||||
protocol::Packet::Data(packet) => {
|
||||
assert_eq!(packet.payload, Bytes::from_static(b"rebound"))
|
||||
}
|
||||
other => panic!("unexpected rebound response: {other:?}"),
|
||||
}
|
||||
backend_task.await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_backend_does_not_terminate_listener() {
|
||||
let (server_config, client_config) = configs(45786, 9);
|
||||
let server = DirectUdpServer::bind(server_config).await.unwrap();
|
||||
let server_task = tokio::spawn(server.run());
|
||||
let session = DirectUdpSession::connect(&client_config).await.unwrap();
|
||||
session
|
||||
.send_datagram(Bytes::from_static(b"backend-may-be-down"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(
|
||||
!server_task.is_finished(),
|
||||
"backend failure must not terminate the public listener"
|
||||
);
|
||||
session.close().await.unwrap();
|
||||
server_task.abort();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#[path = "../src/transport/http3.rs"]
|
||||
mod http3;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use http3::{
|
||||
decode_capsule, decode_http_datagram, encode_auth_challenge, encode_auth_response,
|
||||
encode_capsule, encode_http_datagram, validate_connect_udp_request, AuthChallenge,
|
||||
ConnectUdpRequest, Http3Error, CONNECT_UDP_CONTEXT_ID,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_extended_connect_request() {
|
||||
validate_connect_udp_request("CONNECT", "connect-udp", "/tunnel", "/tunnel").unwrap();
|
||||
ConnectUdpRequest::new("/tunnel")
|
||||
.validate("/tunnel")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
validate_connect_udp_request("POST", "connect-udp", "/tunnel", "/tunnel"),
|
||||
Err(Http3Error::InvalidRequest("method must be CONNECT"))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_connect_udp_request("CONNECT", "webtransport", "/tunnel", "/tunnel"),
|
||||
Err(Http3Error::InvalidRequest(":protocol must be connect-udp"))
|
||||
));
|
||||
assert!(validate_connect_udp_request("CONNECT", "connect-udp", "/other", "/tunnel").is_err());
|
||||
assert!(validate_connect_udp_request("CONNECT", "connect-udp", "tunnel", "tunnel").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_datagram_uses_context_id_and_preserves_opaque_payload() {
|
||||
for context in [0, 63, 64, 16_383, 16_384, (1 << 30) - 1, 1 << 30] {
|
||||
let encoded = encode_http_datagram(context, b"opaque", 1200).unwrap();
|
||||
assert_eq!(
|
||||
decode_http_datagram(&encoded, context, 1200).unwrap(),
|
||||
b"opaque"[..]
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
decode_http_datagram(
|
||||
&encode_http_datagram(7, b"x", 1200).unwrap(),
|
||||
CONNECT_UDP_CONTEXT_ID,
|
||||
1200
|
||||
),
|
||||
Err(Http3Error::ContextMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
encode_http_datagram(CONNECT_UDP_CONTEXT_ID, &[0; 1201], 1200),
|
||||
Err(Http3Error::Oversized {
|
||||
length: 1201,
|
||||
maximum: 1200
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capsule_round_trip_and_truncation_are_checked() {
|
||||
let encoded = encode_capsule(0x2a, b"challenge").unwrap();
|
||||
assert_eq!(
|
||||
decode_capsule(&encoded).unwrap(),
|
||||
(0x2a, bytes::Bytes::from_static(b"challenge"))
|
||||
);
|
||||
assert!(matches!(
|
||||
decode_capsule(&encoded[..encoded.len() - 1]),
|
||||
Err(Http3Error::Truncated)
|
||||
));
|
||||
let mut extra = encoded.to_vec();
|
||||
extra.push(0);
|
||||
assert!(matches!(
|
||||
decode_capsule(&extra),
|
||||
Err(Http3Error::InvalidCapsule)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_completed_before_session_activation() {
|
||||
let challenge = AuthChallenge::random();
|
||||
let nonce = [5u8; 16];
|
||||
let secret = [7u8; 32];
|
||||
let challenge_capsule = encode_auth_challenge(&challenge).unwrap();
|
||||
assert_eq!(
|
||||
decode_capsule(&challenge_capsule).unwrap().0,
|
||||
http3::AUTH_CHALLENGE_CAPSULE
|
||||
);
|
||||
let response = encode_auth_response(&challenge, "laptop", &nonce, &secret).unwrap();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert("laptop".to_owned(), secret.to_vec());
|
||||
let client = http3::verify_auth_response(&challenge, &response, &clients).unwrap();
|
||||
assert_eq!(client.id, "laptop");
|
||||
assert_eq!(client.nonce, nonce);
|
||||
|
||||
let bad = encode_auth_response(&challenge, "laptop", &nonce, &[8u8; 32]).unwrap();
|
||||
assert!(http3::verify_auth_response(&challenge, &bad, &clients).is_err());
|
||||
assert!(http3::is_auth_accept(&http3::encode_auth_accept().unwrap()).unwrap());
|
||||
assert!(!http3::is_auth_accept(&http3::encode_auth_reject().unwrap()).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#[path = "../src/config.rs"]
|
||||
mod config;
|
||||
#[path = "../src/datagram.rs"]
|
||||
mod datagram;
|
||||
#[path = "../src/protocol/mod.rs"]
|
||||
mod protocol;
|
||||
#[path = "../src/stats.rs"]
|
||||
mod stats;
|
||||
#[path = "../src/transport/mod.rs"]
|
||||
mod transport;
|
||||
|
||||
use std::{process::Command, sync::Arc, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use config::Config;
|
||||
use rustls::RootCertStore;
|
||||
use tempfile::tempdir;
|
||||
use tokio::time::timeout;
|
||||
use transport::{TlsTcpConnector, TlsTcpServer, TransportConnector, TransportSession};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_tcp_authenticates_and_round_trips_opaque_datagrams() {
|
||||
if Command::new("openssl").arg("version").output().is_err() {
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
let cert = dir.path().join("cert.pem");
|
||||
let key = dir.path().join("key.pem");
|
||||
let generated = Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-days",
|
||||
"1",
|
||||
"-keyout",
|
||||
])
|
||||
.arg(&key)
|
||||
.args(["-out"])
|
||||
.arg(&cert)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
generated.status.success(),
|
||||
"openssl failed: {:?}",
|
||||
generated
|
||||
);
|
||||
let port = free_port();
|
||||
let server_config = Config::from_str(&format!(
|
||||
"[server]\nlisten = \"127.0.0.1\"\n[backend]\ntarget = \"127.0.0.1:51820\"\n[transports.direct_udp]\nenabled = false\n[transports.quic]\nenabled = false\n[transports.http3]\nenabled = false\n[transports.tls_tcp]\nenabled = true\nport = {port}\n[tls]\ncertificate = \"{}\"\nprivate_key = \"{}\"\n[[auth.clients]]\nid = \"test\"\nsecret = \"{SECRET}\"\n",
|
||||
cert.display(),
|
||||
key.display()
|
||||
))
|
||||
.unwrap()
|
||||
.into_server()
|
||||
.unwrap();
|
||||
let client_config = Config::from_str(&format!(
|
||||
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{SECRET}\"\n[transport]\nmode = \"tls_tcp\"\n[transport.tls_tcp]\nport = {port}\n[network]\nmax_datagram_size = 1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap();
|
||||
let certs = transport::tls_tcp::load_certificates(&cert).unwrap();
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(certs[0].clone()).unwrap();
|
||||
let client_tls = (*transport::tls_tcp::client_tls_config(roots)).clone();
|
||||
let server = Arc::new(TlsTcpServer::bind(&server_config).await.unwrap());
|
||||
let accept = {
|
||||
let server = server.clone();
|
||||
tokio::spawn(async move { server.accept_session().await })
|
||||
};
|
||||
let client = TlsTcpConnector::with_client_config(client_config, client_tls);
|
||||
let client_session = client.connect().await.unwrap();
|
||||
let server_session = accept.await.unwrap().unwrap();
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"tls-opaque"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), server_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"tls-opaque")
|
||||
);
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
#[path = "../src/config.rs"]
|
||||
mod config;
|
||||
#[path = "../src/datagram.rs"]
|
||||
mod datagram;
|
||||
#[path = "../src/protocol/mod.rs"]
|
||||
mod protocol;
|
||||
#[path = "../src/stats.rs"]
|
||||
mod stats;
|
||||
#[path = "../src/transport/mod.rs"]
|
||||
mod transport;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use quinn::{ClientConfig as QuinnClientConfig, Endpoint, ServerConfig as QuinnServerConfig};
|
||||
use rustls::pki_types::{CertificateDer, PrivatePkcs8KeyDer};
|
||||
use tokio::time::timeout;
|
||||
use transport::{
|
||||
quic::{QuicClientConnector, QuicServer},
|
||||
TransportError, TransportSession,
|
||||
};
|
||||
|
||||
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// A test-only localhost certificate, valid for ten years. Keeping the DER
|
||||
// here avoids adding a certificate-generation crate to the application.
|
||||
const CERT_DER_B64: &str = "MIIDHDCCAgSgAwIBAgIUJnI/VlHMWu6j0dXr/t1OmMQrLgowDQYJKoZIhvcNAQELBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDkxNDIwNDEyMFoXDTM2MDkxMTIwNDEyMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm4d4r5Py4GdfQrLit2oytLv20L+y7oM/VTDzzJfFMPmewOh0XyS0BI4lyRbCgOuUh686UPvF/BU8uJq24OO7wt/2CrE3FfwYX7BDjIWMuFevHuv896imabbX4IJ7lzVugI3862QSW09QSEQk3ggYs3z/F16cnSRZm9aIURgdPQsLmMzOwcPtDGwxp2jf0Rky0kFRxhUnqfIWko4hny7GjWL8VG0WzYJY6aUevjPlN/Oq/akr9xUAr2QJenEesP77do4X31LIZmsvB6GqKwaSQ0AbwNHW7rDiaE4dkZMIk5l95YgYnrzyOC8IhUwaSZt0wODxk/euXPYr+RFJv2FuOQIDAQABo2YwZDAdBgNVHQ4EFgQUhViBEXLkp7YUVZaWzLi2SfRmfmYwHwYDVR0jBBgwFoAUhViBEXLkp7YUVZaWzLi2SfRmfmYwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBAEFC7da0bK78IUL0k6lZ82pqFImX2bNteMh/r1IJG0xqB/O2dOLUDHybpF7HToommALgRu6zhlT0d1eHg0cMYEgrKyq4BAAXGHqOt3OdKKL5nAWmI04dlWsrG9aTU3hTQhe0++hnTbVm+YJnYAp+ufOxx858FeothjLWu1Lr81Nm01FnmZbXcFno3nosyl1GPxSRVZl2XH9NsfMSZJUbSU6mHXTO1pEH6mjYmgD362CeJc+0DOzevdLsDozxUoWmdEuqlldauESW6XVK0WxRSJ1i2/B/mtYViHr5R/j5NYgG5STnNnwaNwjYK+0AhxvTtH3jAp0Bsluw4d+xKdsV/YI=";
|
||||
const KEY_DER_B64: &str = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCbh3ivk/LgZ19CsuK3ajK0u/bQv7Lugz9VMPPMl8Uw+Z7A6HRfJLQEjiXJFsKA65SHrzpQ+8X8FTy4mrbg47vC3/YKsTcV/BhfsEOMhYy4V68e6/z3qKZpttfggnuXNW6AjfzrZBJbT1BIRCTeCBizfP8XXpydJFmb1ohRGB09CwuYzM7Bw+0MbDGnaN/RGTLSQVHGFSep8haSjiGfLsaNYvxUbRbNgljppR6+M+U386r9qSv3FQCvZAl6cR6w/vt2jhffUshmay8HoaorBpJDQBvA0dbusOJoTh2RkwiTmX3liBievPI4LwiFTBpJm3TA4PGT965c9iv5EUm/YW45AgMBAAECggEAMs0EtCD4t6i8Ol6Q5EUZa+RrgZLkILkZEjwLnjvCqrUWUD99Z2hnLoe5ut2myZJpcQpg23vpluXe8BOFAZ8RQN2DkcmByzoNQhAieD9VEA+AAXMmrURWmvggulIUiXbn8bCvOLn2rwGQoRI1EpsFanc9IUaDlGZ/qIHiA88DGeFDuchbcgClpXAX4lBzqhLKv/CCIhhGxmyWc1RPlZUef7uTO1Zh7q2HxnO5iBKobvPfMq5VfvzE4rXeSQIHtW+yJFLU2sNjaYry2JTLEqGIZhWMROmzlkW8x/cj0GvjLbY4ltkIpLNSb+WRbY3zZNhQ81kosBZtiYRj3vgr/FzvSwKBgQDQ/dxghryGDqIIOfyZ59+2tpPrrLX4vy7tmAQg6zJIiQVHMZ/Av60QvY7lA0dvDfQmfJuNzTOXtwhgnYkHbg6jlFqpT7P1CZQOzbF0UuFCNvwzE/jgCEKUExckDa+Du7g/VOglB9B4XKqJnQLpZkzz4Hwmuvz4oUtZJCl796HTMwKBgQC+gyPKrNp6AthxZ1gAu+aIjf18vgGZjANQx3cRFQl5BqSyTwZpxIfuiKrT+sU65N2VCWpGH6oTzGruaXBXzG0rlNCMAL5XaJqJehvLAnejUqmXhyi8ouH174Q4azDngPOCFaSjuYJwpX4nUXwMExqs9hwkH9pV3tn6qzEpKJA44wKBgBa+SalHgGSmWwPCK7ckALiFjftrxDHZvGWm0WwzrDkwkB57TANR2KeluVM61B9FyXgWv2bjGeY8yPCatDrWw0kR7/vU7KSR8kbaI6GhE1p9WVzFo6PdwkETAsWtp5gSaa6asti0zP8MNpLbP2He/CjeGSSt3LHB+80rnkgbXsT5AoGAVaTytCxc0zjwQJrqzvmpu33JlQh+A58T5n6lIkLIzWWVKKuqAGZfjXZXBZDYlY9JpbRj2D5n4Rqmf6Na47183ni6ABZde0IFJwSD9hGX4w5IO1yBjbvTxRH16L8XbxtjXPmJ2CiPYUCko9xX3b3A4jyJjhvbtXrHl47aPNyFaW0CgYEAxDOcW5r5pXXkCF/mIqSklGa1jDO9TeMavLo1AguHaUlJEQU8DgGM9Flx3senFASN2EjwwTB3aScVuxe3X29LeZnCNsuTIzCVGpSNyFLUjZVU0fRaf6R9JIcI7dVoflPAPCdrqBVkezK5Eyu8f3NCwnVD+zv+iWA+RE1zUyKy4sM=";
|
||||
|
||||
fn endpoint_and_client(
|
||||
max_datagram: usize,
|
||||
) -> (Endpoint, QuinnClientConfig, CertificateDer<'static>) {
|
||||
let cert_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(CERT_DER_B64)
|
||||
.unwrap();
|
||||
let key_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(KEY_DER_B64)
|
||||
.unwrap();
|
||||
let cert = CertificateDer::from(cert_bytes);
|
||||
let key = PrivatePkcs8KeyDer::from(key_bytes);
|
||||
let mut server_config =
|
||||
QuinnServerConfig::with_single_cert(vec![cert.clone()], key.into()).unwrap();
|
||||
let mut transport_config = quinn::TransportConfig::default();
|
||||
transport_config.keep_alive_interval(Some(Duration::from_secs(1)));
|
||||
transport_config.datagram_send_buffer_size(max_datagram * 8);
|
||||
server_config.transport_config(Arc::new(transport_config));
|
||||
let endpoint = Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(cert.clone()).unwrap();
|
||||
let client = QuinnClientConfig::with_root_certificates(Arc::new(roots)).unwrap();
|
||||
(endpoint, client, cert)
|
||||
}
|
||||
|
||||
fn client_config(port: u16, secret: &str) -> config::ClientConfig {
|
||||
config::Config::from_str(&format!(
|
||||
"[client]\nserver = \"localhost\"\nid = \"test\"\nsecret = \"{secret}\"\n[transport.quic]\nport = {port}\n[transport.auto]\nprobe_timeout_ms = 2000\n[network]\nmax_datagram_size = 1200\n"
|
||||
))
|
||||
.unwrap()
|
||||
.into_client()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_datagram_round_trip_and_limit() {
|
||||
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"test".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
|
||||
let port = server.local_addr().unwrap().port();
|
||||
let client = QuicClientConnector::with_client_config(client_config(port, SECRET), client_tls);
|
||||
let server_task = tokio::spawn(async move { server.accept().await });
|
||||
let client_session = client.connect_session().await.unwrap();
|
||||
let server_session = server_task.await.unwrap().unwrap();
|
||||
client_session
|
||||
.send_datagram(Bytes::from_static(b"opaque"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), server_session.recv_datagram())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
Bytes::from_static(b"opaque")
|
||||
);
|
||||
assert!(matches!(
|
||||
client_session
|
||||
.send_datagram(Bytes::from(vec![0; 1201]))
|
||||
.await,
|
||||
Err(TransportError::Oversized { maximum: 1200 })
|
||||
));
|
||||
client_session.close().await.unwrap();
|
||||
server_session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_secret_is_rejected_as_authentication_error() {
|
||||
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"test".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
|
||||
let port = server.local_addr().unwrap().port();
|
||||
let wrong = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let client = QuicClientConnector::with_client_config(client_config(port, wrong), client_tls);
|
||||
let server_task = tokio::spawn(async move { server.accept().await });
|
||||
let result = client.connect_session().await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransportError::AuthenticationRejected(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
server_task.await.unwrap(),
|
||||
Err(TransportError::AuthenticationRejected(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_hostname_mismatch_is_not_accepted() {
|
||||
let (endpoint, client_tls, _cert) = endpoint_and_client(1200);
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert(
|
||||
"test".to_owned(),
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(SECRET)
|
||||
.unwrap(),
|
||||
);
|
||||
let server = QuicServer::from_endpoint(endpoint, clients, 1200, Duration::from_secs(2));
|
||||
let port = server.local_addr().unwrap().port();
|
||||
let server_task = tokio::spawn(async move { server.accept().await });
|
||||
// The connector's configured hostname is localhost, which matches. This
|
||||
// test changes it to an unrelated name while retaining the localhost root.
|
||||
let mut mismatch = client_config(port, SECRET);
|
||||
mismatch.client.server = "not-localhost".to_owned();
|
||||
let mismatch = QuicClientConnector::with_client_config(mismatch, client_tls);
|
||||
let result = mismatch.connect_session().await;
|
||||
assert!(result.is_err());
|
||||
server_task.abort();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#[path = "../src/config.rs"]
|
||||
mod config;
|
||||
#[path = "../src/datagram.rs"]
|
||||
mod datagram;
|
||||
#[path = "../src/protocol/mod.rs"]
|
||||
mod protocol;
|
||||
#[path = "../src/stats.rs"]
|
||||
mod stats;
|
||||
#[path = "../src/client/supervisor.rs"]
|
||||
mod supervisor;
|
||||
#[path = "../src/transport/mod.rs"]
|
||||
mod transport;
|
||||
|
||||
use bytes::Bytes;
|
||||
use config::{Config, TransportMode};
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use supervisor::{ProbeFuture, TransportProbe, TransportSupervisor};
|
||||
use tokio::{sync::Mutex, time::timeout};
|
||||
use transport::{TransportError, TransportFuture, TransportSession};
|
||||
|
||||
struct Session;
|
||||
impl TransportSession for Session {
|
||||
fn send_datagram<'a>(&'a self, _: Bytes) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn recv_datagram<'a>(&'a self) -> TransportFuture<'a, Bytes> {
|
||||
Box::pin(async { Err(TransportError::Closed) })
|
||||
}
|
||||
fn close<'a>(&'a self) -> TransportFuture<'a, ()> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
1200
|
||||
}
|
||||
}
|
||||
struct Probe {
|
||||
calls: Arc<Mutex<Vec<TransportMode>>>,
|
||||
auth: bool,
|
||||
}
|
||||
impl TransportProbe for Probe {
|
||||
fn connect(&self, mode: TransportMode) -> ProbeFuture {
|
||||
let calls = self.calls.clone();
|
||||
let auth = self.auth;
|
||||
Box::pin(async move {
|
||||
calls.lock().await.push(mode);
|
||||
if auth {
|
||||
Err(TransportError::AuthenticationRejected(
|
||||
protocol::RejectReason::AuthenticationFailed,
|
||||
))
|
||||
} else {
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
fn config(mode: &str) -> config::ClientConfig {
|
||||
Config::from_str(&format!("[client]\nserver=\"127.0.0.1\"\nid=\"c\"\nsecret=\"{}\"\n[transport]\nmode=\"{}\"\n[transport.auto]\norder=[\"direct_udp\",\"quic\"]\nretries=2\nprobe_timeout_ms=20\n", "A".repeat(64), mode)).unwrap().into_client().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selection_order_retries_and_auth_stop() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let supervisor = TransportSupervisor::new(
|
||||
config("auto"),
|
||||
Probe {
|
||||
calls: calls.clone(),
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
supervisor.connect_once().await,
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
));
|
||||
assert_eq!(
|
||||
*calls.lock().await,
|
||||
vec![
|
||||
TransportMode::DirectUdp,
|
||||
TransportMode::DirectUdp,
|
||||
TransportMode::Quic,
|
||||
TransportMode::Quic
|
||||
]
|
||||
);
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let supervisor = TransportSupervisor::new(
|
||||
config("auto"),
|
||||
Probe {
|
||||
calls: calls.clone(),
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
assert!(matches!(
|
||||
supervisor.connect_once().await,
|
||||
Err(TransportError::AuthenticationRejected(_))
|
||||
));
|
||||
assert_eq!(*calls.lock().await, vec![TransportMode::DirectUdp]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_is_bounded_and_resettable() {
|
||||
let mut supervisor = TransportSupervisor::new(
|
||||
config("direct_udp"),
|
||||
Probe {
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
auth: false,
|
||||
},
|
||||
);
|
||||
for _ in 0..30 {
|
||||
let delay = supervisor.backoff_delay();
|
||||
assert!((250..=30_000).contains(&delay.as_millis()));
|
||||
}
|
||||
supervisor.reset_backoff();
|
||||
assert_eq!(supervisor.backoff_delay(), Duration::from_millis(250));
|
||||
}
|
||||
|
||||
struct LifecycleProbe {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl TransportProbe for LifecycleProbe {
|
||||
fn connect(&self, _mode: TransportMode) -> ProbeFuture {
|
||||
let calls = self.calls.clone();
|
||||
Box::pin(async move {
|
||||
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
Ok(Box::new(Session) as Box<dyn TransportSession>)
|
||||
} else {
|
||||
Err(TransportError::HandshakeTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_session_failure<'a>(
|
||||
&'a self,
|
||||
_session: &'a dyn TransportSession,
|
||||
) -> supervisor::SessionWatchFuture<'a> {
|
||||
Box::pin(async { Err(TransportError::Closed) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn established_failure_reprobes_without_reading_datagrams() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let supervisor = TransportSupervisor::new(
|
||||
config("direct_udp"),
|
||||
LifecycleProbe {
|
||||
calls: calls.clone(),
|
||||
},
|
||||
);
|
||||
let shutdown = supervisor.shutdown_token();
|
||||
let task = tokio::spawn(supervisor.run());
|
||||
// The failed established session uses the normal minimum reconnect
|
||||
// backoff before the second probe.
|
||||
tokio::time::sleep(Duration::from_millis(320)).await;
|
||||
shutdown.cancel();
|
||||
let result = timeout(Duration::from_secs(2), task)
|
||||
.await
|
||||
.expect("supervisor should stop")
|
||||
.unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert!(calls.load(Ordering::SeqCst) >= 2);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#[path = "../src/transport/tls_tcp.rs"]
|
||||
mod tls_tcp;
|
||||
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use tls_tcp::{
|
||||
client_authenticate, server_authenticate, AuthChallenge, FrameCodec, FrameError, FramedReader,
|
||||
FramedWriter,
|
||||
};
|
||||
use tokio::io::{duplex, split};
|
||||
|
||||
#[test]
|
||||
fn codec_handles_split_and_coalesced_frames() {
|
||||
let codec = FrameCodec::new(32).expect("valid maximum");
|
||||
let mut wire = codec.encode(b"first").unwrap();
|
||||
wire.extend_from_slice(&codec.encode(b"second").unwrap());
|
||||
let mut decoder = FrameCodec::new(32).unwrap();
|
||||
assert!(decoder.feed(&wire[..1]).unwrap().is_empty());
|
||||
assert!(decoder.feed(&wire[1..4]).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
decoder.feed(&wire[4..]).unwrap(),
|
||||
vec![b"first".to_vec(), b"second".to_vec()]
|
||||
);
|
||||
decoder.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_rejects_oversized_before_payload() {
|
||||
let mut decoder = FrameCodec::new(4).unwrap();
|
||||
let error = decoder.feed(&[0, 5]).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
FrameError::Oversized {
|
||||
length: 5,
|
||||
maximum: 4
|
||||
}
|
||||
);
|
||||
assert!(decoder.finish().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_reports_truncated_header_or_payload() {
|
||||
let mut decoder = FrameCodec::new(32).unwrap();
|
||||
decoder.feed(&[0]).unwrap();
|
||||
assert_eq!(decoder.finish(), Err(FrameError::Truncated));
|
||||
let mut decoder = FrameCodec::new(32).unwrap();
|
||||
decoder.feed(&[0, 3, b'x']).unwrap();
|
||||
assert_eq!(decoder.finish(), Err(FrameError::Truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_response_is_bound_to_challenge_and_secret() {
|
||||
let challenge = AuthChallenge::random();
|
||||
let nonce = [9u8; 16];
|
||||
let message = tls_tcp::encode_auth_response(&challenge, "laptop", &nonce, &[7u8; 32]).unwrap();
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert("laptop".to_owned(), vec![7u8; 32]);
|
||||
// Exercise the full asynchronous exchange below; this assertion also
|
||||
// ensures the generated challenge is not the all-zero sentinel.
|
||||
assert_ne!(challenge.bytes, [0u8; 32]);
|
||||
assert_eq!(message[0], 0x11);
|
||||
assert_eq!(clients["laptop"].len(), 32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tls_stream_authentication_exchange_uses_challenge_response() {
|
||||
let (left, right) = duplex(4096);
|
||||
let (mut server_reader, mut server_writer) = split(left);
|
||||
let (mut client_reader, mut client_writer) = split(right);
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert("laptop".to_owned(), vec![7u8; 32]);
|
||||
let server_clients = clients.clone();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
server_authenticate(
|
||||
&mut server_reader,
|
||||
&mut server_writer,
|
||||
&server_clients,
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let client = tokio::spawn(async move {
|
||||
client_authenticate(
|
||||
&mut client_reader,
|
||||
&mut client_writer,
|
||||
"laptop",
|
||||
&[7u8; 32],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
});
|
||||
assert!(client.await.unwrap().is_ok());
|
||||
let authenticated = server.await.unwrap().unwrap();
|
||||
assert_eq!(authenticated.id, "laptop");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_secret_is_rejected_without_authenticating() {
|
||||
let (left, right) = duplex(4096);
|
||||
let (mut server_reader, mut server_writer) = split(left);
|
||||
let (mut client_reader, mut client_writer) = split(right);
|
||||
let mut clients = HashMap::new();
|
||||
clients.insert("laptop".to_owned(), vec![7u8; 32]);
|
||||
let server_clients = clients.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
server_authenticate(
|
||||
&mut server_reader,
|
||||
&mut server_writer,
|
||||
&server_clients,
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
});
|
||||
let client = tokio::spawn(async move {
|
||||
client_authenticate(
|
||||
&mut client_reader,
|
||||
&mut client_writer,
|
||||
"laptop",
|
||||
&[8u8; 32],
|
||||
1024,
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.await
|
||||
});
|
||||
assert!(matches!(
|
||||
client.await.unwrap(),
|
||||
Err(tls_tcp::AuthError::Rejected)
|
||||
));
|
||||
assert!(matches!(
|
||||
server.await.unwrap(),
|
||||
Err(tls_tcp::AuthError::InvalidCredentials)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn framed_session_preserves_empty_datagrams_and_heartbeats() {
|
||||
let (left, right) = duplex(4096);
|
||||
let (mut reader, _unused_writer) = split(left);
|
||||
let (unused_reader, writer) = split(right);
|
||||
drop(unused_reader);
|
||||
let mut writer = FramedWriter::new(writer, 1200);
|
||||
let mut reader = FramedReader::new(&mut reader, 1200, Some(Duration::from_secs(1)));
|
||||
// Write frames using the writer half and read them from the other side.
|
||||
// The opposite halves are intentionally dropped only after construction;
|
||||
// duplex keeps the active directions independent.
|
||||
let send = async {
|
||||
writer.send_keepalive().await.unwrap();
|
||||
writer.send_datagram(b"").await.unwrap();
|
||||
writer.send_datagram(b"payload").await.unwrap();
|
||||
};
|
||||
tokio::pin!(send);
|
||||
send.await;
|
||||
assert_eq!(reader.recv_datagram().await.unwrap(), b"");
|
||||
assert_eq!(reader.recv_datagram().await.unwrap(), b"payload");
|
||||
}
|
||||
Reference in new issue
Block a user