Skip to content

Commit 5cbfd9c

Browse files
authored
Implement native Rust peer proxy (#1)
Adds the production-shaped Rust peer proxy, injectable Freddie signaling, WebRTC and egress datagram relay, supervisor lifecycle, DTLS fingerprint randomization, and validated server-side QUIC migration behavior.
1 parent c40745e commit 5cbfd9c

14 files changed

Lines changed: 4895 additions & 362 deletions

Cargo.lock

Lines changed: 2702 additions & 356 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,36 @@ rust-version = "1.85"
66
license = "MIT OR Apache-2.0"
77
description = "Rust implementation of the native Unbounded peer-proxy protocol"
88

9+
[features]
10+
default = ["native-client"]
11+
native-client = ["reqwest-client", "dep:env_logger"]
12+
reqwest-client = ["dep:reqwest", "dep:url"]
13+
914
[dependencies]
1015
base64 = "0.22"
16+
async-trait = "0.1"
1117
bytes = "1"
18+
env_logger = { version = "0.11", optional = true }
19+
futures-util = "0.3"
1220
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] }
21+
reqwest = { version = "0.12", optional = true, default-features = false, features = ["http2", "rustls-tls-webpki-roots"] }
22+
rand = "0.9"
1323
serde = { version = "1", features = ["derive"] }
1424
serde_repr = "0.1"
1525
serde_json = "1"
1626
thiserror = "2"
17-
tokio = { version = "1", features = ["io-util", "macros", "rt", "sync", "time"] }
27+
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
28+
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
29+
tokio-util = "0.7"
30+
url = { version = "2", optional = true }
31+
webrtc = { git = "https://github.com/myleshorton/webrtc", rev = "a6a6d703037ca277df030ec7f882965c9da14577" }
32+
33+
[[bin]]
34+
name = "peer-proxy"
35+
path = "src/bin/peer-proxy.rs"
36+
required-features = ["native-client"]
1837

1938
[dev-dependencies]
2039
futures = "0.3"
2140
rcgen = "0.14"
41+
axum = "0.8"

README.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,76 @@ churn; the Rust consumer only needs Quinn's server-side migration support.
2020

2121
## Status
2222

23-
The initial milestone establishes the exact wire contracts and the stable
24-
virtual datagram socket that Quinn will run on. See
25-
[`docs/wire-protocol.md`](docs/wire-protocol.md).
23+
The protocol and migration foundation is complete. The native peer-proxy path
24+
now includes Freddie signaling, a Pion-compatible unreliable/unordered WebRTC
25+
DataChannel, the CSID-authenticated egress WebSocket, and bidirectional packet
26+
relay. See [`docs/wire-protocol.md`](docs/wire-protocol.md).
2627

2728
```sh
2829
cargo test
2930
```
3031

32+
The native peer proxy runs continuously, returning to Freddie discovery after
33+
each completed or failed sharing session. It shuts down cleanly on Ctrl-C and
34+
uses bounded exponential backoff with ±20% jitter between attempts:
35+
36+
```sh
37+
UNBOUNDED_FREDDIE_ENDPOINT=http://localhost:9000/v1/signal \
38+
UNBOUNDED_EGRESS_URL=ws://localhost:8000/ws \
39+
UNBOUNDED_STUN_URLS=stun:stun.example.org:3478 \
40+
cargo run --bin peer-proxy
41+
```
42+
43+
Operational settings are environment variables:
44+
45+
| Variable | Default | Purpose |
46+
| --- | ---: | --- |
47+
| `UNBOUNDED_CONCURRENT_SESSIONS` | 5 | Independent consumer slots, matching the Go widget default |
48+
| `UNBOUNDED_NAT_TIMEOUT_SECONDS` | 10 | Time allowed for the WebRTC DataChannel to open |
49+
| `UNBOUNDED_RETRY_INITIAL_SECONDS` | 1 | Initial retry delay |
50+
| `UNBOUNDED_RETRY_MAX_SECONDS` | 30 | Maximum retry delay, including jitter |
51+
| `UNBOUNDED_STABLE_SESSION_SECONDS` | 30 | Session duration that resets retry backoff |
52+
| `UNBOUNDED_COVERT_DTLS` | unset (randomize) | Unset randomizes; truthy/`randomize` keeps it on, falsey/`disable` is diagnostic-only |
53+
| `UNBOUNDED_ENABLE_IPV6` | unset | Set to `1`, `true`, `yes`, or `on` to gather IPv6 ICE candidates |
54+
55+
Library embedders can consume slot-tagged `PoolEvent` and `SupervisorEvent`
56+
values for attempt, session, failure, backoff, and shutdown reporting.
57+
Cancellation closes every active WebRTC peer connection before the pool exits.
58+
Third-party logs default to `error` to avoid exposing ICE candidate addresses;
59+
operators can opt into more detail with `RUST_LOG`.
60+
61+
Embedders that already own an HTTP stack can exclude the native CLI and its
62+
`reqwest`/`env_logger` dependencies, then supply Freddie signaling through the
63+
object-safe `Signaler` trait:
64+
65+
```toml
66+
lantern-unbounded = { version = "0.1", default-features = false }
67+
```
68+
69+
The default `native-client` feature retains the standalone `peer-proxy` binary
70+
and the `FreddieClient` implementation used by the command above.
71+
72+
DTLS ClientHello randomization is enabled by default. Cipher suites and
73+
extensions retain their negotiated contents but are independently reordered on
74+
each ClientHello flight, preventing the stable library-default fingerprint that
75+
has been filtered in deployed censored networks. The pinned WebRTC dependency
76+
contains the typed hook proposed upstream in
77+
[`webrtc-rs/webrtc#814`](https://github.com/webrtc-rs/webrtc/pull/814).
78+
79+
The peer proxy uses IPv4 ICE candidates by default. This avoids advertising
80+
unroutable link-local IPv6 candidates on hosts such as macOS. Set
81+
`UNBOUNDED_ENABLE_IPV6=1` only where IPv6 routing is known to work.
82+
83+
## Go interoperability
84+
85+
The peer-proxy path has been exercised end to end against the Go Freddie,
86+
consumer, and egress implementations. A proxied 20 MiB HTTP response remained
87+
open while peer proxy A was stopped and peer proxy B joined with the same
88+
consumer session ID. The Go egress migrated its QUIC client connection to B,
89+
the transfer resumed, and the client received the exact response length with
90+
HTTP 200.
91+
92+
This is the intended ownership boundary: Rust provides replacement datagram
93+
paths, while the infrastructure-owned Go egress decides when to probe and
94+
switch paths. See [`docs/interoperability.md`](docs/interoperability.md) for the
95+
validation contract.

docs/interoperability.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Go interoperability contract
2+
3+
The native peer proxy is compatible when it can replace the Go sharing peer
4+
without changing Freddie, the censored-user consumer, or the egress service.
5+
The essential live validation is stronger than a successful WebRTC handshake:
6+
an already-open QUIC stream must survive a peer change.
7+
8+
## Migration scenario
9+
10+
1. Start the Go Freddie and egress services.
11+
2. Start the Go consumer and wait for its SOCKS proxy.
12+
3. Start Rust peer proxy A and establish a proxied HTTP transfer large enough
13+
to remain active during migration.
14+
4. Stop A and start Rust peer proxy B.
15+
5. Confirm B receives the same `ConsumerSessionID` and uses it as the egress
16+
WebSocket subprotocol.
17+
6. Confirm the Go egress probes the replacement path and reports a successful
18+
QUIC migration.
19+
7. Confirm the original HTTP request completes without reconnecting and its
20+
status and byte count are unchanged.
21+
22+
On 2026-07-11 this scenario completed with HTTP 200 and exactly 20 MiB received.
23+
The Go egress reported a successful migration after a 25.145-second probe, and
24+
the open stream resumed over peer B within the existing QUIC idle timeout.
25+
26+
## Compatibility details found live
27+
28+
- Pion encodes the ICE candidate `protocol` field numerically: `1` for UDP and
29+
`2` for TCP. Rust converts those values before producing a W3C candidate.
30+
- The DataChannel label is `data`, with ordering disabled and zero retransmits.
31+
- The egress WebSocket subprotocols are ordered as `un80und3d`, the consumer
32+
session ID, and `v2.3.0`.
33+
- IPv4-only ICE is the safe peer-proxy default. Link-local IPv6 interfaces on
34+
macOS can produce candidates that pass signaling but never nominate.
35+
36+
## Process lifecycle
37+
38+
The Go producer treats each sharing slot as a resetting state machine. The Rust
39+
supervisor preserves that behavior: signaling errors, NAT timeouts, relay
40+
closure, and egress failures all return the slot to discovery. Retries use
41+
bounded exponential backoff with jitter, and a session that remains active for
42+
the configured stable interval resets the backoff.
43+
44+
The process runs five independent slots by default, matching the Go widget's
45+
consumer table size. Each slot owns its WebRTC and egress lifecycles; only the
46+
shutdown token and event sink are shared.
47+
48+
Cancellation is distinct from failure. It interrupts signaling, NAT traversal,
49+
an active relay, or a retry delay; closes the current WebRTC peer connection;
50+
and exits without incrementing the failed-attempt count.
51+
52+
## DTLS fingerprint resistance
53+
54+
The sharing peer is the WebRTC answerer and therefore the active DTLS endpoint
55+
that sends the ClientHello. The Rust peer randomizes cipher-suite and extension
56+
ordering for every ClientHello flight, including the retry carrying a
57+
HelloVerifyRequest cookie. This preserves the negotiated values and the DTLS
58+
state machine while avoiding a stable library-default fingerprint.
59+
60+
The current Rust DTLS stack is DTLS 1.2. It deliberately does not claim to
61+
mimic current Chrome DTLS 1.3 fingerprints, which include unsupported cipher
62+
suites and post-quantum key-share structures. Browser mimicry should only be
63+
added when those messages can be represented faithfully end to end.

docs/wire-protocol.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,17 @@ The parent signaling envelope uses Go field names and a numeric message type:
9191
| Answer | 2 | WebRTC session description |
9292
| ICE | 3 | `ICEMsg`, including `ConsumerSessionID` |
9393

94-
The detailed HTTP exchange, SDP/ICE vectors, timeout behavior, and Freddie
95-
request identifiers are the next compatibility checkpoint.
94+
Every request carries `X-BF-Version: v2.3.0`. Signaling messages are posted as
95+
`application/x-www-form-urlencoded` with `data`, `send-to`, and numeric `type`
96+
fields. A `418` rejects the protocol version, `404` means the response request
97+
has expired, and `200` with an empty body means no peer replied before the
98+
server TTL. Consumer advertisement streams are newline-delimited signaling
99+
envelopes returned by `GET /v1/signal`.
100+
101+
The peer-proxy exchange is:
102+
103+
1. POST Genesis to `genesis`; wait for an Offer response.
104+
2. Apply the consumer's offer and gather local ICE candidates.
105+
3. POST the complete Answer to the offer's `ReplyTo`; wait for ICE.
106+
4. Convert the Pion candidate objects to W3C candidate strings and add them.
107+
5. Use `ConsumerSessionID` from the ICE payload when opening the egress socket.

src/bin/peer-proxy.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
use std::env;
2+
use std::sync::Arc;
3+
use std::time::Duration;
4+
5+
use lantern_unbounded::peer_proxy::PeerProxyConfig;
6+
use lantern_unbounded::signaling::FreddieClient;
7+
use lantern_unbounded::supervisor::{
8+
supervise_peer_proxy_pool, PoolEvent, SupervisorConfig, SupervisorEvent,
9+
};
10+
use tokio::sync::mpsc;
11+
use tokio_util::sync::CancellationToken;
12+
13+
fn required(name: &str) -> String {
14+
env::var(name).unwrap_or_else(|_| panic!("{name} must be set"))
15+
}
16+
17+
fn seconds(name: &str, default: u64) -> Duration {
18+
Duration::from_secs(
19+
env::var(name)
20+
.ok()
21+
.and_then(|value| value.parse().ok())
22+
.unwrap_or(default),
23+
)
24+
}
25+
26+
fn count(name: &str, default: usize) -> usize {
27+
env::var(name)
28+
.ok()
29+
.and_then(|value| value.parse().ok())
30+
.filter(|value| *value > 0)
31+
.unwrap_or(default)
32+
}
33+
34+
fn boolean(name: &str, default: bool) -> bool {
35+
env::var(name)
36+
.map(|value| parse_boolean(&value, default))
37+
.unwrap_or(default)
38+
}
39+
40+
fn parse_boolean(value: &str, default: bool) -> bool {
41+
match value.trim().to_ascii_lowercase().as_str() {
42+
"1" | "true" | "yes" | "on" | "enable" | "enabled" | "randomize" => true,
43+
"0" | "false" | "no" | "off" | "disable" | "disabled" => false,
44+
_ => default,
45+
}
46+
}
47+
48+
#[tokio::main(flavor = "multi_thread")]
49+
async fn main() {
50+
let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("error"))
51+
.try_init();
52+
let stun_urls = env::var("UNBOUNDED_STUN_URLS")
53+
.unwrap_or_default()
54+
.split(',')
55+
.map(str::trim)
56+
.filter(|value| !value.is_empty())
57+
.map(str::to_owned)
58+
.collect();
59+
let cancellation = CancellationToken::new();
60+
let signal_cancellation = cancellation.clone();
61+
tokio::spawn(async move {
62+
if tokio::signal::ctrl_c().await.is_ok() {
63+
signal_cancellation.cancel();
64+
}
65+
});
66+
67+
let (events_tx, mut events_rx) = mpsc::unbounded_channel();
68+
let reporter = tokio::spawn(async move {
69+
while let Some(PoolEvent { slot, event }) = events_rx.recv().await {
70+
match event {
71+
SupervisorEvent::AttemptStarted { attempt } => {
72+
eprintln!("slot {slot}: starting peer proxy attempt {attempt}");
73+
}
74+
SupervisorEvent::SessionEnded {
75+
attempt,
76+
outcome,
77+
duration,
78+
retry_in,
79+
} => eprintln!(
80+
"slot {slot}: attempt {attempt} session {} ended {:?} after {duration:?}; retrying in {retry_in:?}",
81+
outcome.consumer_session_id, outcome.relay_end
82+
),
83+
SupervisorEvent::AttemptFailed {
84+
attempt,
85+
error,
86+
duration,
87+
retry_in,
88+
} => eprintln!(
89+
"slot {slot}: attempt {attempt} failed after {duration:?}: {error}; retrying in {retry_in:?}"
90+
),
91+
SupervisorEvent::Stopped { summary } => eprintln!(
92+
"slot {slot}: stopped after {} attempts ({} sessions, {} failures)",
93+
summary.attempts, summary.completed_sessions, summary.failed_attempts
94+
),
95+
}
96+
}
97+
});
98+
99+
let slots = count("UNBOUNDED_CONCURRENT_SESSIONS", 5);
100+
let signaler = Arc::new(
101+
FreddieClient::new(required("UNBOUNDED_FREDDIE_ENDPOINT"))
102+
.unwrap_or_else(|error| panic!("invalid Freddie configuration: {error}")),
103+
);
104+
let summary = supervise_peer_proxy_pool(
105+
SupervisorConfig {
106+
peer_proxy: PeerProxyConfig {
107+
signaler,
108+
egress_url: required("UNBOUNDED_EGRESS_URL"),
109+
stun_urls,
110+
nat_timeout: seconds("UNBOUNDED_NAT_TIMEOUT_SECONDS", 10),
111+
enable_ipv6: boolean("UNBOUNDED_ENABLE_IPV6", false),
112+
randomize_dtls: boolean("UNBOUNDED_COVERT_DTLS", true),
113+
},
114+
initial_backoff: seconds("UNBOUNDED_RETRY_INITIAL_SECONDS", 1),
115+
max_backoff: seconds("UNBOUNDED_RETRY_MAX_SECONDS", 30),
116+
stable_session: seconds("UNBOUNDED_STABLE_SESSION_SECONDS", 30),
117+
},
118+
slots,
119+
cancellation,
120+
Some(events_tx),
121+
)
122+
.await;
123+
reporter.await.expect("event reporter failed");
124+
eprintln!(
125+
"peer proxy pool stopped: {slots} slots, {} attempts, {} sessions, {} failures",
126+
summary.attempts(),
127+
summary.completed_sessions(),
128+
summary.failed_attempts()
129+
);
130+
}
131+
132+
#[cfg(test)]
133+
mod tests {
134+
use super::parse_boolean;
135+
136+
#[test]
137+
fn parses_boolean_aliases_and_preserves_default_for_unknown_values() {
138+
for value in ["1", "true", "YES", "on", "enable", "randomize"] {
139+
assert!(parse_boolean(value, false), "{value}");
140+
}
141+
for value in ["0", "false", "NO", "off", "disable", "disabled"] {
142+
assert!(!parse_boolean(value, true), "{value}");
143+
}
144+
assert!(parse_boolean("unexpected", true));
145+
assert!(!parse_boolean("unexpected", false));
146+
}
147+
}

0 commit comments

Comments
 (0)