checkpoint: implement wgcloak v1 foundations

This commit is contained in:
h3cx committed 2026-09-14 22:55:07 +02:00
commit 313ab00765
45 files changed
+12058

No files matched your search

+23
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -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"
+21
View File
@@ -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.
+98
View File
@@ -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.
+73
View File
@@ -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.
+109
View File
@@ -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.
+60
View File
@@ -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.
+93
View File
@@ -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.
+72
View File
@@ -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.
+49
View File
@@ -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.
+65
View File
@@ -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.
+39
View File
@@ -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"
+13
View File
@@ -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"
+49
View File
@@ -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"
+22
View File
@@ -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"
+39
View File
@@ -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
+44
View File
@@ -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
+2390
View File
File diff suppressed because it is too large. Load diff
+92
View File
@@ -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
));
}
}
+143
View File
@@ -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;
}
}
+257
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
+23
View File
@@ -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
View File
@@ -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(())
}
+309
View File
@@ -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(&timestamp.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
));
}
}
+950
View File
@@ -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)));
}
}
+21
View File
@@ -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,
};
+191
View File
@@ -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));
}
}
+93
View File
@@ -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?;
}
}
}
}
+52
View File
@@ -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();
}
}
+67
View File
@@ -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,
}
+662
View File
@@ -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;
}
}
+428
View File
@@ -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()))
}
}
+353
View File
@@ -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),
)))
}
}
+600
View File
@@ -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)
}
+643
View File
@@ -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)
}
}
+45
View File
@@ -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();
}
+295
View File
@@ -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();
}
+96
View File
@@ -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());
}
+102
View File
@@ -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
View File
@@ -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();
}
+166
View File
@@ -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);
}
+159
View File
@@ -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");
}