diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8bec75a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Keep Docker build contexts (tests/docker/Dockerfile.*) small — these +# build with `context: ../../` (the repo root) so `COPY . .` in +# Dockerfile.rust-peer must not drag in local build artifacts or +# unrelated tooling directories. +/target/ +tests/node/node_modules/ +.git/ +.claude/ +.sisyphus/ +.vogon_poetry/ +docs/book/ diff --git a/AGENTS.md b/AGENTS.md index 1885979..25aa37a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,17 @@ peeroxide — topic-based peer discovery + connection management - Adding optional parameters via a new companion function - Internal implementation changes with no public surface effect +### New public API surface — always apply VISIBILITY_POLICY.md + +Adding new "does NOT count as breaking" surface above is still not free of process: any new `pub` type, function, or module in `libudx`, `peeroxide-dht`, or `peeroxide` must be checked against [`VISIBILITY_POLICY.md`](./VISIBILITY_POLICY.md) **in the same change**, not as an optional follow-up — do this automatically, without waiting to be asked: + +- Decide `pub` vs `pub(crate)` per the policy's Axis 1 rubric. +- If `pub`, classify it into a type-role (Handle / Config / Event-Result / Error / Primitive / Wire-envelope) and apply that role's `#[non_exhaustive]` default. The burden of proof is on exemptions — don't skip `#[non_exhaustive]` on a Config/Result/Error/Event-role type without a documented reason. +- `#[non_exhaustive]` Config/Options types must ship a `::new(...)`/builder/`Default` impl in the same commit (consumers must be able to construct them). +- New `pub mod` needs real module-level docs in the same commit, or it should be `pub(crate) mod` instead. + +This check belongs in the same pass as writing the code, before it's ever proposed as done — treat it like running clippy, not like a separate audit someone else requests later. + ### The boundary that matters `peeroxide-cli` is a binary consumer. Constraints it places on how it uses library internals (e.g. needing to share a socket across tasks) must be solved **inside `peeroxide-cli` or by adding new non-breaking API**, never by altering existing library signatures. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 193622b..515c8d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,8 @@ These three library crates are published at `>=1.0` and have external users. When in doubt, add a new function rather than changing an existing one. +For *new* public types/functions/modules, see [`VISIBILITY_POLICY.md`](./VISIBILITY_POLICY.md) for the visibility (`pub` vs `pub(crate)`) and `#[non_exhaustive]` rubric — it exists precisely so future additive changes (new fields, new variants) don't become breaking changes later. + ## PR Checklist ### Before opening a PR @@ -64,6 +66,7 @@ When in doubt, add a new function rather than changing an existing one. - [ ] Both test suites pass locally (see Testing above) - [ ] Clippy clean - [ ] No public API breaking changes without maintainer approval +- [ ] New/changed public API in `libudx`, `peeroxide-dht`, or `peeroxide` reviewed against `VISIBILITY_POLICY.md` (visibility + `#[non_exhaustive]` per the type-role taxonomy) - [ ] CHANGELOG updated for each affected crate - [ ] Version bumps are correct per semver (patch / minor / major) - [ ] New public API items have `///` doc comments diff --git a/Cargo.lock b/Cargo.lock index 5e5366a..89a0bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -683,6 +683,16 @@ dependencies = [ "cc", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -760,7 +770,7 @@ dependencies = [ [[package]] name = "libudx" -version = "1.3.1" +version = "1.4.1" dependencies = [ "rand", "serde_json", @@ -913,7 +923,7 @@ dependencies = [ [[package]] name = "peeroxide" -version = "1.3.1" +version = "1.3.2" dependencies = [ "hex", "libudx", @@ -928,7 +938,7 @@ dependencies = [ [[package]] name = "peeroxide-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "arc-swap", "assert_cmd", @@ -966,7 +976,7 @@ dependencies = [ [[package]] name = "peeroxide-dht" -version = "1.3.1" +version = "1.7.2" dependencies = [ "blake2", "chacha20", @@ -974,6 +984,7 @@ dependencies = [ "curve25519-dalek", "ed25519-dalek", "hex", + "if-addrs", "libudx", "poly1305", "rand", diff --git a/VISIBILITY_POLICY.md b/VISIBILITY_POLICY.md new file mode 100644 index 0000000..41818ac --- /dev/null +++ b/VISIBILITY_POLICY.md @@ -0,0 +1,122 @@ +# Visibility Policy + +How to decide whether a new type, function, or module in `libudx`, `peeroxide-dht`, or `peeroxide` should be `pub`, `pub(crate)`, or `#[non_exhaustive]`. + +`peeroxide-cli` is a binary consumer of these crates; it adapts to the library API, not the other way around. + +## Why this exists + +Routine bug fixes and feature gap-fills should not trigger SemVer-breaking diffs. They do whenever something is `pub` that shouldn't be, or whenever a public Config/Result/Event lacks `#[non_exhaustive]` and we want to add a field. This policy is the rubric we apply to keep the published API surface intentional and additively extensible. + +## Goals + +1. Identify the complete public API surface — every reachable item. +2. Keep non-surface entities `pub(crate)` so internal changes don't trigger SemVer churn. +3. Enable additive evolution of the surviving surface (new fields, new variants, new methods) without breaking the SemVer contract. +4. Stay compatible with the broader Hyperswarm / Hypercore / Holepunch ecosystem (see "Reference ecosystem pins" below). + +## Non-goals + +- Preserving struct-literal constructability for downstream users. +- Backward compatibility for items we explicitly demote. +- Wire-protocol changes (envelopes' Rust shape is downstream of the wire format, not the API contract). + +## The two-axis decision + +For every public-ish item, make two independent decisions. + +### Axis 1 — visibility (`pub` vs `pub(crate)`) + +In order of precedence: + +1. Is it reached from outside its defining crate today (workspace-internal cross-crate use counts)? → `pub`. +2. Is it on the **pin list** below? → `pub`. +3. Is it returned by, or required as a parameter of, a public function in the same crate? → `pub` (cascade reachability). +4. Is it a wire-format envelope used cross-crate within the workspace? → `pub`. +5. Otherwise → `pub(crate)`. + +### Axis 2 — `#[non_exhaustive]` (only if Axis 1 = `pub`) + +1. The role default from the **type-role taxonomy** below applies unless explicitly carved out. +2. Default is **apply**. The burden of proof is on exemptions. + +**Constructor rule**: every `#[non_exhaustive]` type that users construct (Configs, Options, Params, HandlerReply-shaped types) must ship a `::new(...)` / builder / `Default` impl in the same commit. Otherwise consumers can't construct it. + +## Type-role taxonomy + +Classify every public-ish item into exactly one role. The role drives both defaults. + +| Role | Visibility default | `#[non_exhaustive]` default | Examples | +|---|---|---|---| +| **Handle** — opaque internal-state-with-methods, factory-constructed, never struct-literal'd | `pub` | no (adds nothing; consumers can't struct-literal a private-field type anyway) | `HyperDhtHandle`, `SwarmHandle`, `DhtHandle`, `Io`, `CongestionWindow`, `Holepuncher`, `SecretStream`, `Push`, `Pull`, `SecurePayload`, `SocketPool`, `NoiseWrap`, `BlindRelayClient`, `Persistent`, `Router`, `UdxRuntime`, `UdxStream`, `UdxSocket`, `UdxAsyncStream` | +| **Config / Options / Params** — user-constructed for input | `pub` | **yes** (forward-compat for new knobs) | `SwarmConfig`, `JoinOpts`, `HyperDhtConfig`, `ServerConfig`, `ConnectOpts`, `IoConfig`, `PersistentConfig`, `WireCounters` | +| **Event / Result / Reply** — produced by the library, matched/destructured by the user | `pub` | **yes** (forward-compat for new fields/variants) | `LookupResult`, `AnnounceResult`, `ConnectResult`, `ImmutablePutResult`, `MutablePutResult`, `MutableGetResult`, `ServerEvent`, `IoEvent`, `TimeoutEvent`, `HolepunchEvent`, `ChannelEvent`, `PairResponse`, `HandshakeResult`, `PeerConnection`, `SwarmConnection`, `PeerInfo` | +| **Error** — matched by the user | `pub` | **yes** (forward-compat for new variants) | `HyperDhtError`, `SwarmError`, `UdxError`, `SecretStreamError`, `SecretstreamError`, `RelayError`, `ProtomuxError`, `IoError`, `NoiseError`, `EncodingError` | +| **Primitive / value type** — small, widely-constructed, semantics stable | `pub` | no (would block legitimate value construction) | `KeyPair`, `PeerAddr`, `noise::Keypair`, `Datagram`, `Priority` | +| **Wire-format envelope** — Rust shape mirrors a serialized protocol message | `pub` if cross-crate, else `pub(crate)` | **no** (struct-literal construction IS the protocol implementation) | All of `messages::*`, `hyperdht_messages::*`, `protomux::{ControlFrame, BatchItem, DecodedFrame}`, `blind_relay::{PairMessage, UnpairMessage}`, `noise::{Handshake, HandshakeIK}`, `compact_encoding::State` | +| **Public free function** | `pub` only if part of public contract; else `pub(crate)` | n/a (functions don't take `#[non_exhaustive]` — use signature discipline) | `discovery_key`, `crypto::{hash, hash_batch, sign_detached, verify_detached, namespace}` | +| **Trait** | case-by-case; use sealed-trait pattern (private supertrait or `Sealed` marker) if no out-of-crate impls are intended | n/a | — | +| **Internal helper** — never reached from outside the defining crate | `pub(crate)` | n/a | NAT state machine, routing-table internals, encoder primitives in `compact_encoding`, demoted `blind_relay::encode_*`/`decode_*` helpers | + +## Reference ecosystem pins + +These types MUST remain `pub`. They are the surface a future Rust port of any Hyperswarm-family project (hyperbeam, `@hyperswarm/rpc`, hypercore replication, etc.) would need to import. Verified against ~150 holepunchto-org projects. + +**peeroxide**: +- `spawn()`, `discovery_key()` +- `SwarmConfig`, `JoinOpts`, `SwarmHandle`, `SwarmConnection` +- `SwarmHandle::{join, leave, dht, key_pair}` + +**peeroxide-dht**: +- `spawn` (in `hyperdht` and `rpc`) +- `HyperDhtConfig`, `ServerConfig`, `ConnectOpts` +- `KeyPair` (with `from_seed` / `generate`) +- `HyperDhtHandle`, `PeerConnection`, `Holepuncher` +- `HyperDhtHandle::{lookup, announce, connect, connect_with_options, connect_with_nodes, connect_to, find_peer, mutable_put, mutable_get, immutable_put, immutable_get}` +- `LookupResult`, `AnnounceResult`, `ConnectResult`, `ImmutablePutResult`, `MutablePutResult`, `MutableGetResult`, `ServerEvent` +- `secret_stream::SecretStream`, `noise_wrap::NoiseWrap`, `protomux::*` +- `crypto::{discovery_key, hash, hash_batch, sign_detached, verify_detached, namespace}` + +**libudx**: +- `UdxRuntime`, `UdxSocket`, `UdxStream`, `UdxAsyncStream` +- `UdxRuntime::{create_socket, create_stream}` +- `UdxSocket::{bind, send_to, recv_start, close}` +- `Header` and the wire flag constants (`FLAG_DATA`, `FLAG_END`, etc.) + +## When the rubric is ambiguous + +- **Adding a new public type?** Apply the role taxonomy. Default to `#[non_exhaustive]` for Config / Result / Event / Error roles. Skip for Handles, Primitives, and wire-format envelopes. +- **Adding a new module?** Default `pub(crate) mod`. Promote to `pub mod` only if it's a documented advanced-use surface — and only if it gets real module-level docs in the same commit. +- **Tempted to use `#[doc(hidden)] pub mod`?** Don't. It's a footgun: the module is publicly reachable but absent from rustdoc, so reviewers miss leaks. Either it's `pub mod` (with real docs) or it's `pub(crate) mod`. +- **Removing an item?** Breaking change. Requires explicit human approval per AGENTS.md HARD STOP. +- **Adding a field to a non-`#[non_exhaustive]` struct?** Breaking change. Requires approval. (Reconsider whether the struct should have been `#[non_exhaustive]` in the first place.) +- **Changing an existing public signature?** Breaking change. Requires approval. + +## How to extend this policy + +If a new use case doesn't fit cleanly into an existing role, prefer either: + +1. **Carve-out**: prove it's an exception to a clear role and document the carve-out inline in the relevant role row above. +2. **New role**: introduce a new row in the type-role taxonomy with explicit defaults. + +Don't ad-hoc decide visibility per-type without updating this document. + +## Auditing the current state + +When the question "is the public surface still consistent with this policy?" comes up: + +1. Generate the public-surface inventory: + ```bash + RUSTC_BOOTSTRAP=1 cargo rustdoc -p -- \ + -Z unstable-options --output-format json --document-hidden-items + ``` + (The `--document-hidden-items` flag is required to surface `#[doc(hidden)] pub mod` content if any has crept back in.) +2. For each public `struct` / `enum`, confirm it's covered by the role taxonomy above (either `#[non_exhaustive]` is applied per its role's default, or its role exempts it). +3. For each `pub mod`, confirm it has real module-level documentation. No `#[doc(hidden)] pub mod`. +4. For cross-crate reachability questions, use qualified-path grep (`use peeroxide_dht::`, `peeroxide_dht::::`). Do NOT rely on name-only graph queries — common method names like `destroy`, `add`, `update` create false positives. + +## Hard constraints (from AGENTS.md, restated here for convenience) + +- **No `git push`** without explicit user direction. +- **API breaking change HARD STOP**: visibility demotions (`pub` → `pub(crate)`) and removal of items are breaking changes. They require explicit human approval — they are not part of "routine maintenance." +- **MSRV**: Rust 1.85 (2024 edition). diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index b87b1f0..9a3825c 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -42,3 +42,4 @@ - [Security Model](./appendices/security-model.md) - [Limits and Performance](./appendices/limits-and-performance.md) +- [Tracing and Logging](./appendices/tracing.md) diff --git a/docs/src/appendices/tracing.md b/docs/src/appendices/tracing.md new file mode 100644 index 0000000..157a3ba --- /dev/null +++ b/docs/src/appendices/tracing.md @@ -0,0 +1,138 @@ +# Tracing and Logging + +Peeroxide uses the [`tracing`](https://docs.rs/tracing) crate for all +diagnostic output across its four crates (`libudx`, `peeroxide-dht`, +`peeroxide`, `peeroxide-cli`). This appendix documents the conventions +operators and developers can rely on when filtering, capturing, or +extending log output. + +## Target conventions + +Every tracing call has a *target* — a string used by the +[`EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) +to decide whether to emit the event. Peeroxide uses two kinds of +targets: + +**1. Module-path targets (default).** Most calls inherit their target +from the Rust module path: `peeroxide_dht::holepuncher`, +`peeroxide_dht::hyperdht`, `libudx::native::stream`, and so on. These +are the natural granularity for developers debugging a specific +subsystem. EnvFilter directives prefix-match, so +`peeroxide_dht::holepuncher=trace` enables that one module and its +children. + +**2. Reserved `peeroxide::_events::*` lifecycle targets.** A curated set +of high-signal operator-facing events use a stable, hand-picked target +under `peeroxide::_events::`. These are not Rust modules — they are +fixed labels that survive refactors. Examples: + +```text +peeroxide::_events::swarm::started +peeroxide::_events::dht::bootstrapped +peeroxide::_events::peer::connected +peeroxide::_events::peer::connect_failed +peeroxide::_events::holepunch::probe_received +peeroxide::_events::holepunch::passive_reflected +peeroxide::_events::holepunch::nat_settled +peeroxide::_events::holepunch::final_punch_sent +peeroxide::_events::holepunch::connected +peeroxide::_events::holepunch::aborted +peeroxide::_events::holepunch::failed_no_verified_addr +``` + +Operators tail these to get a clean lifecycle stream without developer +noise: + +```sh +RUST_LOG=peeroxide::_events=info peeroxide cp send ./file +``` + +## Level discipline + +| Level | Used for | Default visibility | +|---|---|---| +| `error` | Fatal conditions; the operation cannot proceed | always | +| `warn` | Recoverable anomalies, validation failures, retries | always | +| `info` | Lifecycle events (`peeroxide::_events::*`) + startup | `-v` and above | +| `debug` | Per-connection / per-round state transitions | `-vv` and above | +| `trace` | Per-packet, per-loop iteration | only with explicit `RUST_LOG` | + +Anything that fires more than once per significant operation lives at +`debug` or below. Per-packet paths live at `trace`. The `info` level is +reserved for the `_events::*` subtree plus a small handful of true +startup events. + +## CLI verbosity + +The `peeroxide` CLI exposes three verbosity levels via the `-v` flag, +each composing a default `EnvFilter`: + +| Flag | Default filter | What you see | +|---|---|---| +| _(none)_ | `warn,peeroxide::_events=info` | Warnings + lifecycle events | +| `-v` | `peeroxide=info,peeroxide_dht=info,peeroxide::_events=info,warn` | Info-level developer events across both swarm and DHT crates | +| `-vv` | `peeroxide=debug,peeroxide_dht=debug,libudx=debug,peeroxide::_events=info,info` | Full debug stream across all peeroxide crates | + +The `RUST_LOG` environment variable always overrides the default. Any +EnvFilter directive syntax is supported: + +```sh +RUST_LOG=peeroxide_dht::holepuncher=trace peeroxide cp send ./file +RUST_LOG=peeroxide::_events=info,libudx=warn peeroxide cp recv - +RUST_LOG=peeroxide_dht::hyperdht=trace,peeroxide_dht::io=debug peeroxide node +``` + +## Subsystem map + +The 8 natural subsystems and the targets that feed them: + +| Subsystem | Module path | Event subtree | +|---|---|---| +| holepunch | `peeroxide_dht::holepuncher` | `peeroxide::_events::holepunch::*` | +| nat | `peeroxide_dht::nat` | _(none currently)_ | +| socket_pool | `peeroxide_dht::socket_pool` | _(none currently)_ | +| relay | `peeroxide_dht::blind_relay` | _(none currently)_ | +| discovery | `peeroxide_dht::query`, `peeroxide::peer_discovery` | _(none currently)_ | +| swarm | `peeroxide::swarm` | `peeroxide::_events::swarm::*`, `peeroxide::_events::peer::*` | +| dht_rpc | `peeroxide_dht::rpc`, `peeroxide_dht::io` | `peeroxide::_events::dht::*` | +| udx | `libudx::native::*` | _(none currently)_ | + +New `_events::*` labels should be added sparingly, only when an event +represents an operator-visible lifecycle transition (something a +production operator would want in a clean default-level log). + +## Adding a new lifecycle event + +When wiring a new high-signal event, use an explicit target: + +```rust +tracing::info!( + target: "peeroxide::_events::holepunch::nat_settled", + round, + "NAT settled + verified remote, transitioning to final punch round" +); +``` + +When the same site also wants developer-level detail at debug level, +emit two separate calls or include enough structured fields in the +single `info!` so it serves both audiences (the latter is preferred). + +## Anti-patterns + +- **`eprintln!` for telemetry.** Bypasses level filtering and structured + fields. Always use a tracing macro. +- **Emitting at `info` from a per-packet path.** Demote to `debug` or + `trace`; the `info` level is reserved for lifecycle events. +- **Inventing many ad-hoc targets.** Stick to module-path defaults + unless the call belongs to a curated `_events::*` lifecycle category. +- **Putting expensive computation outside the tracing macro.** The + `tracing` macros short-circuit on the level filter before evaluating + field expressions, so inline `format!()` / `.collect()` calls inside + the macro are gated. The same code as a `let` outside the macro + always runs. + +## Reference + +- [`tracing` crate](https://docs.rs/tracing) +- [`tracing-subscriber::EnvFilter`](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html) +- Inspired by [iroh's `iroh::_events::*` pattern](https://github.com/n0-computer/iroh) diff --git a/libudx/CHANGELOG.md b/libudx/CHANGELOG.md index 01881e2..dbb178f 100644 --- a/libudx/CHANGELOG.md +++ b/libudx/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Fixed a `process_incoming` ordering bug: the relay fast-path (packet forwarding once `UdxStream::relay_to` is configured) was checked before the firewall-hook gate, permanently starving hook-based 4-tuple address adoption whenever `relay_to` was pre-wired on both sides of a pairing before either side's first packet arrived. The relay-forwarding check now runs after the firewall-hook gate. + ## [1.3.1](https://github.com/Rightbracket/peeroxide/compare/libudx-v1.3.0...libudx-v1.3.1) - 2026-05-14 ### Other diff --git a/libudx/Cargo.toml b/libudx/Cargo.toml index f59f26a..0238da1 100644 --- a/libudx/Cargo.toml +++ b/libudx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libudx" -version = "1.3.1" +version = "1.4.1" edition.workspace = true license.workspace = true rust-version.workspace = true diff --git a/libudx/src/native/socket.rs b/libudx/src/native/socket.rs index 8f8830b..ef362be 100644 --- a/libudx/src/native/socket.rs +++ b/libudx/src/native/socket.rs @@ -137,6 +137,24 @@ impl UdxSocket { Ok(udp.local_addr()?) } + /// Set the IP time-to-live (IPv4 `IP_TTL` / IPv6 hop limit) used for + /// outgoing datagrams sent on this socket. + /// + /// A low TTL is used by NAT-holepunching probes that intentionally + /// expire before reaching the remote peer (birthday-attack probes that + /// must not trigger a "connection refused" ICMP response visible to the + /// remote NAT), while the default TTL is used for regular traffic. + pub fn set_ttl(&self, ttl: u32) -> Result<()> { + let udp = self.inner.udp_arc()?; + Ok(udp.set_ttl(ttl)?) + } + + /// Return the currently configured IP time-to-live for this socket. + pub fn ttl(&self) -> Result { + let udp = self.inner.udp_arc()?; + Ok(udp.ttl()?) + } + /// Get a shared reference to the underlying UDP socket. pub(crate) fn udp_arc(&self) -> Result> { self.inner.udp_arc() diff --git a/libudx/src/native/stream.rs b/libudx/src/native/stream.rs index e4fd7cf..24b41d2 100644 --- a/libudx/src/native/stream.rs +++ b/libudx/src/native/stream.rs @@ -72,6 +72,36 @@ fn seq_after(a: u32, b: u32) -> bool { (a.wrapping_sub(b)) as i32 > 0 } +/// Decide whether a UDX packet is plausible as the *first* arrival on an +/// unconnected stream awaiting its firewall-hook 4-tuple commit. +/// +/// This is a defence-in-depth check; the socket-layer demux already enforces +/// header magic + version. Here we additionally reject flag combinations that +/// no legitimate fresh stream would lead with: +/// - `type_flags == 0`: a flagless packet carries no meaningful UDX intent +/// and looks like an unauthenticated probe. +/// - `FLAG_DESTROY`: a peer cannot legitimately tear down a stream that has +/// not yet been agreed (no prior packets, no negotiated state). +/// - `FLAG_SACK` in isolation: SACK ranges are meaningless before we have +/// sent any data to be selectively acknowledged. +/// +/// Returns `false` to indicate "drop this packet, leave the hook armed for +/// the next inbound arrival". Returns `true` to permit hook firing and +/// 4-tuple commit. +#[inline] +fn is_plausible_first_packet(header: &Header) -> bool { + if header.type_flags == 0 { + return false; + } + if header.has_flag(FLAG_DESTROY) { + return false; + } + if header.type_flags == FLAG_SACK { + return false; + } + true +} + // ── Packet types ───────────────────────────────────────────────────────────── pub(crate) struct IncomingPacket { @@ -131,6 +161,8 @@ pub(crate) enum StreamNotify { type WriteSenders = Vec>>>; type AckedRateInfo = Vec<(u32, super::congestion::rate::PacketRateInfo)>; type StreamMap = Arc>>>; +type FirewallHook = + Box bool + Send>; // ── StreamInner ────────────────────────────────────────────────────────────── @@ -185,6 +217,15 @@ pub(crate) struct StreamInner { // ── Relay ── relay_target: Option>>, + + // ── Firewall hook (set_firewall_hook path) ── + /// Single-fire hook invoked on the first incoming packet when the stream + /// starts in listening mode (via `set_firewall_hook`). On `true` the stream + /// adopts the packet's source as `remote_addr` and transitions to connected. + firewall_hook: Option, + /// Retained socket clone supplied to `set_firewall_hook`; forwarded to the + /// hook and cleared after the hook fires. + firewall_socket: Option, } impl StreamInner { @@ -272,14 +313,19 @@ impl StreamInner { &mut self, data_len: usize, ) -> Result { - if !self.connected { + if !self.connected && self.firewall_hook.is_none() { return Err(UdxError::Io(std::io::Error::other("stream not connected"))); } if self.ended { return Err(UdxError::Io(std::io::Error::other("stream already shut down"))); } let udp = self.udp.clone().ok_or(UdxError::StreamClosed)?; - let remote_addr = self.remote_addr.ok_or(UdxError::StreamClosed)?; + // When the firewall hook hasn't fired yet, remote_addr is not known. + // Use the unspecified sentinel (0.0.0.0:0); the processor will update + // queued packets once the hook fires and remote_addr is resolved. + let remote_addr = self + .remote_addr + .unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()); let remote_id = self.remote_id; let max_payload = self.max_payload(); @@ -787,6 +833,8 @@ impl UdxStream { ), send_queue: VecDeque::new(), relay_target: None, + firewall_hook: None, + firewall_socket: None, }; Self { @@ -950,6 +998,85 @@ impl UdxStream { Ok(()) } + /// Register a single-fire firewall hook for deferred 4-tuple commitment. + /// + /// The stream is registered with `socket` for demux immediately (so incoming + /// packets addressed to `self.local_id` are routed here), but `connected` + /// stays `false` until the hook fires. On the first incoming packet the hook + /// is called with `(socket, src_port, src_ip)`; if it returns `true` the + /// stream adopts that source as its remote address and becomes connected, + /// then processes the packet normally. If it returns `false` the processor + /// exits and the stream is permanently closed. + /// + /// `remote_id` is the remote stream's local ID (known from the noise handshake + /// payload) and is required for correct ACK construction. + /// + // NOTE: this hook is single-fire (`FnOnce`) to match the Node + // Hyperswarm reference. A re-armable variant (`Fn`, fired on every + // unknown-source packet) would let the stream re-bind its 4-tuple + // mid-stream — useful for NAT rebinding (when a NAT's external + // mapping for the local port changes during a long-lived connection, + // typically after an idle period or under aggressive NAT timers) + // and for follow-the-mobile-peer scenarios (a peer that roams + // between networks/interfaces without tearing the stream down). + // We don't ship that variant today — the reference doesn't, and + // rebinding mid-stream has subtle interactions with congestion + // control, RTT estimation, and the secret-stream cipher state. If + // we ever need it, add a sibling `set_firewall_hook_rearmable` that + // takes an `Fn` and re-arms after each fire; do NOT change this + // method's signature. + pub fn set_firewall_hook( + &self, + socket: &super::socket::UdxSocket, + remote_id: u32, + hook: F, + ) -> Result<()> + where + F: FnOnce(&super::socket::UdxSocket, u16, std::net::IpAddr) -> bool + Send + 'static, + { + let udp = socket.udp_arc()?; + let (notify_tx, notify_rx) = mpsc::unbounded_channel(); + + { + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.remote_id = remote_id; + inner.udp = Some(udp); + inner.notify_tx = Some(notify_tx); + inner.firewall_hook = Some(Box::new(hook)); + inner.firewall_socket = Some(socket.clone()); + } + + let tx = self + .incoming_tx + .as_ref() + .ok_or_else(|| UdxError::Io(std::io::Error::other("stream already consumed")))? + .clone(); + socket.register_stream(self.local_id, tx)?; + + *self.socket_streams.lock().unwrap_or_else(|e| e.into_inner()) = + Some(socket.streams_ref()); + + let incoming_rx = self + .incoming_rx + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + .ok_or_else(|| UdxError::Io(std::io::Error::other("processor already started")))?; + let inner = Arc::clone(&self.inner); + let streams_for_cleanup = socket.streams_ref(); + let local_id_for_cleanup = self.local_id; + let handle = tokio::spawn(async move { + process_incoming(inner, incoming_rx, notify_rx).await; + if let Ok(mut map) = streams_for_cleanup.lock() { + map.remove(&local_id_for_cleanup); + } + }); + *self.processor.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle); + + tracing::debug!(local_id = self.local_id, remote_id, "stream listening (firewall hook set)"); + Ok(()) + } + /// Destroy the stream, sending a DESTROY packet to the remote peer. pub async fn destroy(mut self) -> Result<()> { let destroy_info = { @@ -1048,7 +1175,105 @@ async fn process_incoming( packet = incoming_rx.recv() => { let Some(packet) = packet else { break }; + // ── Header decode (early — defense in depth) ───────── + // The socket-layer demux (`UdxSocket::ensure_recv_loop`) only + // routes packets whose header magic+version pass `Header::decode`, + // so reaching this point implies a well-formed UDX header. We + // re-decode here regardless: it is cheap (a 20-byte parse), it + // makes the firewall-hook gate immediately below independent of + // the socket-layer contract, and it lets the gate inspect the + // first packet's flag bits before committing the 4-tuple. + let header = match Header::decode(&packet.data) { + Ok(h) => h, + Err(_) => continue, + }; + + // ── Firewall hook: single-fire 4-tuple commit ──────────── + // NOTE: this hook is single-fire (`FnOnce`) to match the Node + // Hyperswarm reference. A re-armable variant (`Fn`, fired on every + // unknown-source packet) would let the stream re-bind its 4-tuple + // mid-stream — useful for NAT rebinding (when a NAT's external + // mapping for the local port changes during a long-lived connection, + // typically after an idle period or under aggressive NAT timers) + // and for follow-the-mobile-peer scenarios (a peer that roams + // between networks/interfaces without tearing the stream down). + // We don't ship that variant today — the reference doesn't, and + // rebinding mid-stream has subtle interactions with congestion + // control, RTT estimation, and the secret-stream cipher state. If + // we ever need it, add a sibling `set_firewall_hook_rearmable` that + // takes an `Fn` and re-arms after each fire; do NOT change this + // method's signature. + { + let is_connected = inner.lock().unwrap_or_else(|e| e.into_inner()).connected; + if !is_connected { + // Defense in depth: drop implausible first packets + // without firing the hook. Leaves the hook armed for + // the legitimate first arrival. UDX header magic+version + // are already enforced by the socket demux; here we + // additionally reject flag combinations that cannot + // legitimately begin a fresh stream. + if !is_plausible_first_packet(&header) { + tracing::debug!( + addr = ?packet.addr, + flags = header.type_flags, + "firewall hook: dropping implausible first packet (hook left armed)" + ); + continue; + } + let hook_and_socket = { + let mut guard = inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.firewall_hook.take().zip(guard.firewall_socket.take()) + }; + match hook_and_socket { + Some((hook, sock)) => { + if hook(&sock, packet.addr.port(), packet.addr.ip()) { + let mut guard = inner.lock().unwrap_or_else(|e| e.into_inner()); + guard.remote_addr = Some(packet.addr); + guard.connected = true; + // Patch any packets queued before the hook fired + // (they were staged with the 0.0.0.0:0 sentinel). + let sentinel: std::net::SocketAddr = + "0.0.0.0:0".parse().unwrap(); + for qp in &mut guard.send_queue { + if qp.remote_addr == sentinel { + qp.remote_addr = packet.addr; + } + } + if let Some(ref tx) = guard.notify_tx { + let _ = tx.send(StreamNotify::DataQueued); + } + tracing::debug!(addr = ?packet.addr, "firewall hook: accepted"); + } else { + tracing::debug!(addr = ?packet.addr, "firewall hook: rejected"); + break; + } + } + None => continue, + } + } + } + // ── Relay fast-path ────────────────────────────── + // + // Deliberately placed *after* the firewall-hook gate above, + // not before it: a relay-bridged stream is typically wired + // up with both `set_firewall_hook` (to learn its own real + // 4-tuple from the first packet its owner sends) and + // `relay_to` (to forward everything to its paired stream) + // configured before any packets arrive. If this fast-path + // ran first unconditionally, it would `continue` past the + // firewall-hook gate on every packet forever — the hook + // would never fire, `remote_addr`/`connected` would never + // be set, and (since the *destination* stream in a pair has + // the exact same problem simultaneously) neither side of a + // relayed pairing could ever adopt its real address, so + // nothing would ever forward (observed empirically: both + // ends of a blind-relay pairing retransmitted until RTO + // exhaustion with zero bytes ever bridged). Running the + // hook gate first lets the triggering packet commit this + // stream's 4-tuple, and *this same packet* still gets + // relayed below rather than being absorbed into normal + // (unread) stream processing. let relay_info = { let guard = inner.lock().unwrap_or_else(|e| e.into_inner()); guard.relay_target.as_ref().map(|target| { @@ -1076,11 +1301,6 @@ async fn process_incoming( continue; } - let header = match Header::decode(&packet.data) { - Ok(h) => h, - Err(_) => continue, - }; - tracing::trace!( flags = header.type_flags, seq = header.seq, @@ -1598,6 +1818,8 @@ mod mtu_tests { send_queue: VecDeque::new(), relay_target: None, ended: false, + firewall_hook: None, + firewall_socket: None, } } @@ -1707,3 +1929,82 @@ mod mtu_tests { assert_eq!(inner.mtu, MTU_BASE + MTU_STEP, "MTU frozen at last successful value"); } } + +#[cfg(test)] +mod firewall_gate_tests { + use super::*; + + fn header_with_flags(type_flags: u8) -> Header { + Header { + type_flags, + data_offset: 0, + remote_id: 42, + recv_window: DEFAULT_RWND, + seq: 1, + ack: 0, + } + } + + #[test] + fn first_packet_with_data_flag_is_plausible() { + let hdr = header_with_flags(FLAG_DATA); + assert!(is_plausible_first_packet(&hdr)); + } + + #[test] + fn first_packet_with_heartbeat_is_plausible() { + let hdr = header_with_flags(FLAG_HEARTBEAT); + assert!(is_plausible_first_packet(&hdr)); + } + + #[test] + fn first_packet_with_data_and_sack_is_plausible() { + let hdr = header_with_flags(FLAG_DATA | FLAG_SACK); + assert!(is_plausible_first_packet(&hdr)); + } + + #[test] + fn first_packet_with_end_is_plausible() { + let hdr = header_with_flags(FLAG_END); + assert!( + is_plausible_first_packet(&hdr), + "a zero-length stream may legitimately lead with FLAG_END" + ); + } + + #[test] + fn bare_first_packet_is_rejected() { + let hdr = header_with_flags(0); + assert!( + !is_plausible_first_packet(&hdr), + "type_flags == 0 carries no meaningful intent and must not commit the 4-tuple" + ); + } + + #[test] + fn destroy_only_first_packet_is_rejected() { + let hdr = header_with_flags(FLAG_DESTROY); + assert!( + !is_plausible_first_packet(&hdr), + "FLAG_DESTROY before any agreed state is illegitimate" + ); + } + + #[test] + fn destroy_combined_with_data_is_still_rejected() { + let hdr = header_with_flags(FLAG_DESTROY | FLAG_DATA); + assert!( + !is_plausible_first_packet(&hdr), + "FLAG_DESTROY in any combination is illegitimate for a first packet" + ); + } + + #[test] + fn sack_only_first_packet_is_rejected() { + let hdr = header_with_flags(FLAG_SACK); + assert!( + !is_plausible_first_packet(&hdr), + "SACK-only first packet is meaningless before we have sent any data" + ); + } +} diff --git a/libudx/tests/firewall_hook.rs b/libudx/tests/firewall_hook.rs new file mode 100644 index 0000000..6f30863 --- /dev/null +++ b/libudx/tests/firewall_hook.rs @@ -0,0 +1,43 @@ +#![deny(clippy::all)] + +mod common; + +use common::{create_bound_socket, create_runtime, with_timeout}; +use std::time::Duration; + +#[tokio::test] +async fn firewall_hook_accepts_first_packet() { + let _ = tracing_subscriber::fmt::try_init(); + + with_timeout(Duration::from_secs(10), async { + let rt = create_runtime(); + + let (socket_a, addr_a) = create_bound_socket(&rt).await; + let (socket_b, _addr_b) = create_bound_socket(&rt).await; + + let id_a = 10u32; + let id_b = 20u32; + + let mut stream_a = rt.create_stream(id_a).await.expect("create stream_a"); + stream_a + .set_firewall_hook(&socket_a, id_b, |_, _, _| true) + .expect("set_firewall_hook"); + + let stream_b = rt.create_stream(id_b).await.expect("create stream_b"); + stream_b + .connect(&socket_b, id_a, addr_a) + .await + .expect("stream_b connect"); + + let msg = b"hello from firewall hook test"; + stream_b.write(msg).await.expect("stream_b write"); + + let data = stream_a + .read() + .await + .expect("stream_a read") + .expect("stream_a unexpected EOF"); + assert_eq!(&data[..], msg.as_ref()); + }) + .await; +} diff --git a/peeroxide-cli/CHANGELOG.md b/peeroxide-cli/CHANGELOG.md index f259277..39ae3cc 100644 --- a/peeroxide-cli/CHANGELOG.md +++ b/peeroxide-cli/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `peeroxide relay` subcommand: run a standalone blind-relay server (no topic discovery or DHT-node duties beyond serving relay requests). +- `peeroxide node --relay`: opt-in courtesy blind-relay alongside normal DHT routing duties. Both surfaces expose `--max-sessions`/`--max-pairings-per-session`/session-timeout flags backed by the same `peeroxide-dht::relay_service` engine. + +### Changed + +- Updated `peeroxide_dht::io::WireCounters` construction sites in the deaddrop progress reporter to use the new `WireCounters::from_counters(bytes_sent, bytes_received)` constructor, replacing struct-literal construction after `WireCounters` gained `#[non_exhaustive]` in `peeroxide-dht`. + ## [0.2.1](https://github.com/Rightbracket/peeroxide/compare/peeroxide-cli-v0.2.0...peeroxide-cli-v0.2.1) - 2026-05-14 ### Other diff --git a/peeroxide-cli/Cargo.toml b/peeroxide-cli/Cargo.toml index 38f64e5..bd9a00f 100644 --- a/peeroxide-cli/Cargo.toml +++ b/peeroxide-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peeroxide-cli" -version = "0.2.1" +version = "0.3.0" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -19,9 +19,9 @@ name = "peeroxide" path = "src/main.rs" [dependencies] -peeroxide = { path = "../peeroxide", version = "1.3.1" } -peeroxide-dht = { path = "../peeroxide-dht", version = "1.3.1" } -libudx = { path = "../libudx", version = "1.3.1" } +peeroxide = { path = "../peeroxide", version = "1.3.2" } +peeroxide-dht = { path = "../peeroxide-dht", version = "1.7.0" } +libudx = { path = "../libudx", version = "1.4.1" } clap = { version = "4", features = ["derive"] } clap_mangen = "0.2" tokio = { version = "1", features = ["full", "signal"] } diff --git a/peeroxide-cli/src/cmd/cp.rs b/peeroxide-cli/src/cmd/cp.rs index 648fafe..4670242 100644 --- a/peeroxide-cli/src/cmd/cp.rs +++ b/peeroxide-cli/src/cmd/cp.rs @@ -7,7 +7,7 @@ use tokio::signal; use tokio::io::AsyncWriteExt; use crate::config::ResolvedConfig; -use super::{build_dht_config, parse_topic, to_hex}; +use super::{apply_force_relay_env, build_dht_config, parse_topic, to_hex}; const CHUNK_SIZE: usize = 65536; @@ -122,6 +122,14 @@ async fn run_send(args: SendArgs, cfg: &ResolvedConfig) -> i32 { let dht_config = build_dht_config(cfg); let mut swarm_config = SwarmConfig::default(); swarm_config.dht = dht_config; + // PEEROXIDE_LOCAL_CONNECTION=false disables the same-NAT LAN-shortcut. + // Used by `test_live_cp_send_recv_no_lan` to force the real network path. + if std::env::var("PEEROXIDE_LOCAL_CONNECTION").as_deref() == Ok("false") { + swarm_config.local_connection = false; + } + if !apply_force_relay_env(&mut swarm_config) { + return 1; + } let (task, handle, mut conn_rx) = match spawn(swarm_config).await { Ok(v) => v, @@ -349,6 +357,14 @@ async fn run_recv(args: RecvArgs, cfg: &ResolvedConfig) -> i32 { let dht_config = build_dht_config(cfg); let mut swarm_config = SwarmConfig::default(); swarm_config.dht = dht_config; + // PEEROXIDE_LOCAL_CONNECTION=false disables the same-NAT LAN-shortcut. + // Used by `test_live_cp_send_recv_no_lan` to force the real network path. + if std::env::var("PEEROXIDE_LOCAL_CONNECTION").as_deref() == Ok("false") { + swarm_config.local_connection = false; + } + if !apply_force_relay_env(&mut swarm_config) { + return 1; + } let (task, handle, mut conn_rx) = match spawn(swarm_config).await { Ok(v) => v, diff --git a/peeroxide-cli/src/cmd/deaddrop/progress/reporter.rs b/peeroxide-cli/src/cmd/deaddrop/progress/reporter.rs index 908562a..565d0c7 100644 --- a/peeroxide-cli/src/cmd/deaddrop/progress/reporter.rs +++ b/peeroxide-cli/src/cmd/deaddrop/progress/reporter.rs @@ -237,10 +237,10 @@ impl OperationFactory { filename, version, } => { - let wire = peeroxide_dht::io::WireCounters { - bytes_sent: wire_sent.clone(), - bytes_received: wire_received.clone(), - }; + let wire = peeroxide_dht::io::WireCounters::from_counters( + wire_sent.clone(), + wire_received.clone(), + ); let state = ProgressState::new_with_wire( Phase::Put, *version, diff --git a/peeroxide-cli/src/cmd/deaddrop/progress/state.rs b/peeroxide-cli/src/cmd/deaddrop/progress/state.rs index 7df9fdd..8c7d815 100644 --- a/peeroxide-cli/src/cmd/deaddrop/progress/state.rs +++ b/peeroxide-cli/src/cmd/deaddrop/progress/state.rs @@ -117,10 +117,10 @@ mod tests { // Verify that incrementing the WireCounters' atomics is visible from // the ProgressState (i.e. the Arcs are shared, not cloned by value). use std::sync::atomic::AtomicU64; - let wire = peeroxide_dht::io::WireCounters { - bytes_sent: Arc::new(AtomicU64::new(0)), - bytes_received: Arc::new(AtomicU64::new(0)), - }; + let wire = peeroxide_dht::io::WireCounters::from_counters( + Arc::new(AtomicU64::new(0)), + Arc::new(AtomicU64::new(0)), + ); let state = ProgressState::new_with_wire( Phase::Put, 2, diff --git a/peeroxide-cli/src/cmd/mod.rs b/peeroxide-cli/src/cmd/mod.rs index 0fc6b17..bc99585 100644 --- a/peeroxide-cli/src/cmd/mod.rs +++ b/peeroxide-cli/src/cmd/mod.rs @@ -6,6 +6,7 @@ pub mod init; pub mod lookup; pub mod node; pub mod ping; +pub mod relay; use peeroxide_dht::hyperdht::HyperDhtConfig; use peeroxide_dht::rpc::DhtConfig; @@ -78,6 +79,77 @@ pub fn to_hex(bytes: &[u8]) -> String { hex::encode(bytes) } +/// Parse the `PEEROXIDE_FORCE_RELAY` env var value as `@` +/// and return the resulting `(pubkey, addr)` pair. +/// +/// Returns `Ok(None)` if the env var is unset or empty. Returns `Err(message)` if +/// the var is set but malformed — callers should surface this to the user and exit. +pub fn parse_force_relay_env() -> Result, String> { + let raw = match std::env::var("PEEROXIDE_FORCE_RELAY") { + Ok(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + + let (pk_hex, addr_str) = raw.split_once('@').ok_or_else(|| { + format!( + "PEEROXIDE_FORCE_RELAY: expected @, got {raw:?}" + ) + })?; + + if pk_hex.len() != 64 { + return Err(format!( + "PEEROXIDE_FORCE_RELAY: pubkey must be 64 hex chars (got {} chars)", + pk_hex.len() + )); + } + + let pk_bytes = hex::decode(pk_hex).map_err(|e| { + format!("PEEROXIDE_FORCE_RELAY: invalid hex in pubkey: {e}") + })?; + if pk_bytes.len() != 32 { + return Err(format!( + "PEEROXIDE_FORCE_RELAY: decoded pubkey is {} bytes, expected 32", + pk_bytes.len() + )); + } + let mut pk = [0u8; 32]; + pk.copy_from_slice(&pk_bytes); + + let addr: std::net::SocketAddr = addr_str + .parse() + .map_err(|e| format!("PEEROXIDE_FORCE_RELAY: invalid socket addr {addr_str:?}: {e}"))?; + + Ok(Some((pk, addr))) +} + +/// Apply `PEEROXIDE_FORCE_RELAY` to a `SwarmConfig`. On malformed values, prints to +/// stderr and returns `false` — callers should treat this as fatal. +/// +/// Protocol effect: only the side acting as a noise-handshake server (e.g. `cp send`, +/// which announces a topic and accepts connections) advertises `relay_through` to +/// its peer. Applying it on the client side (`cp recv`) is currently a no-op on the +/// wire, but the helper accepts it uniformly so the env var can be set symmetrically +/// on both processes for operator clarity. +pub fn apply_force_relay_env(swarm_config: &mut peeroxide::SwarmConfig) -> bool { + match parse_force_relay_env() { + Ok(None) => true, + Ok(Some((pk, addr))) => { + tracing::info!( + pubkey = %hex::encode(&pk[..8]), + addr = %addr, + "PEEROXIDE_FORCE_RELAY: advertising relay_through", + ); + swarm_config.relay_through = Some(pk); + swarm_config.relay_address = Some(addr); + true + } + Err(msg) => { + eprintln!("error: {msg}"); + false + } + } +} + #[cfg(unix)] pub async fn sigterm_recv() { use tokio::signal::unix::{signal, SignalKind}; @@ -549,4 +621,94 @@ mod tests { assert!(!should_direct_connect(true, FIREWALL_RANDOM, true, false)); } + + fn with_force_relay_env(value: Option<&str>, f: F) { + use std::sync::Mutex; + static LOCK: Mutex<()> = Mutex::new(()); + let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var("PEEROXIDE_FORCE_RELAY").ok(); + unsafe { + match value { + Some(v) => std::env::set_var("PEEROXIDE_FORCE_RELAY", v), + None => std::env::remove_var("PEEROXIDE_FORCE_RELAY"), + } + } + f(); + unsafe { + match prev { + Some(v) => std::env::set_var("PEEROXIDE_FORCE_RELAY", v), + None => std::env::remove_var("PEEROXIDE_FORCE_RELAY"), + } + } + } + + #[test] + fn force_relay_env_unset_returns_none() { + with_force_relay_env(None, || { + assert!(matches!(parse_force_relay_env(), Ok(None))); + }); + } + + #[test] + fn force_relay_env_empty_returns_none() { + with_force_relay_env(Some(""), || { + assert!(matches!(parse_force_relay_env(), Ok(None))); + }); + } + + #[test] + fn force_relay_env_packed_parses() { + let pk_hex = "0123456789abcdef".repeat(4); + let value = format!("{pk_hex}@1.2.3.4:49737"); + with_force_relay_env(Some(&value), || { + let (pk, addr) = parse_force_relay_env() + .expect("ok") + .expect("some"); + assert_eq!(hex::encode(pk), pk_hex); + assert_eq!(addr.to_string(), "1.2.3.4:49737"); + }); + } + + #[test] + fn force_relay_env_placeholder_8888_parses() { + let pk_hex = "0".repeat(64); + let value = format!("{pk_hex}@8.8.8.8:49737"); + with_force_relay_env(Some(&value), || { + let (pk, addr) = parse_force_relay_env() + .expect("ok") + .expect("some"); + assert_eq!(pk, [0u8; 32]); + assert_eq!(addr.to_string(), "8.8.8.8:49737"); + }); + } + + #[test] + fn force_relay_env_missing_at_errors() { + with_force_relay_env(Some("abc"), || { + assert!(parse_force_relay_env().is_err()); + }); + } + + #[test] + fn force_relay_env_short_pubkey_errors() { + with_force_relay_env(Some("dead@1.2.3.4:1234"), || { + assert!(parse_force_relay_env().is_err()); + }); + } + + #[test] + fn force_relay_env_non_hex_pubkey_errors() { + let value = format!("{}@1.2.3.4:1234", "z".repeat(64)); + with_force_relay_env(Some(&value), || { + assert!(parse_force_relay_env().is_err()); + }); + } + + #[test] + fn force_relay_env_bad_addr_errors() { + let value = format!("{}@not-an-addr", "0".repeat(64)); + with_force_relay_env(Some(&value), || { + assert!(parse_force_relay_env().is_err()); + }); + } } diff --git a/peeroxide-cli/src/cmd/node.rs b/peeroxide-cli/src/cmd/node.rs index d8b25f6..dc9bcd7 100644 --- a/peeroxide-cli/src/cmd/node.rs +++ b/peeroxide-cli/src/cmd/node.rs @@ -1,13 +1,15 @@ use clap::Args; use libudx::UdxRuntime; -use peeroxide_dht::hyperdht::{self, HyperDhtConfig}; +use peeroxide_dht::hyperdht::{self, HyperDhtConfig, KeyPair}; +use peeroxide_dht::blind_relay::BlindRelayServerConfig; use peeroxide_dht::persistent::PersistentConfig; +use peeroxide_dht::relay_service::{self, RelayServiceConfig}; use peeroxide_dht::rpc::DhtConfig; use tokio::signal; use std::time::Duration; use crate::config::ResolvedConfig; -use super::resolve_bootstrap; +use super::{resolve_bootstrap, to_hex}; #[derive(Args)] pub struct NodeArgs { @@ -42,6 +44,33 @@ pub struct NodeArgs { /// TTL for LRU cache entries in seconds #[arg(long)] max_lru_age: Option, + + /// Also serve as a courtesy blind-relay (see `peeroxide relay` for a + /// dedicated relay-only process) + #[arg(long)] + relay: bool, + + /// Hex-encoded 32-byte seed for a deterministic relay identity key pair + /// (only used with --relay; default: a fresh random key pair each run) + #[arg(long)] + relay_key_seed: Option, + + /// Maximum concurrently accepted relay sessions (only used with --relay; default: 10000) + #[arg(long)] + relay_max_sessions: Option, + + /// Maximum concurrent pending+active pairings per relay session (only used with --relay; default: 256) + #[arg(long)] + relay_max_pairings_per_session: Option, + + /// Drop an unmatched relay pairing after this many seconds (only used with --relay; default: 300) + #[arg(long)] + relay_pairing_timeout: Option, + + /// Close a relay session idle for this many seconds (only used with --relay; default: 600; + /// not yet enforced — reserved for a follow-up idle-sweep pass) + #[arg(long)] + relay_idle_session_timeout: Option, } pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { @@ -93,7 +122,7 @@ pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { } }; - let (task, handle, _server_rx) = match hyperdht::spawn(&runtime, dht_config).await { + let (task, handle, server_rx) = match hyperdht::spawn(&runtime, dht_config).await { Ok(v) => v, Err(e) => { eprintln!("error: failed to start DHT node: {e}"); @@ -109,6 +138,21 @@ pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { } }; + let relay_key_pair = if args.relay { + match parse_relay_key_seed(args.relay_key_seed.as_deref()) { + Ok(kp) => Some(kp), + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + } + } else { + None + }; + + if let Some(kp) = &relay_key_pair { + println!("relay public key: {}", to_hex(&kp.public_key)); + } println!("{host}:{listen_port}"); if let Err(e) = handle.bootstrapped().await { @@ -124,6 +168,40 @@ pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { tracing::info!("Node ready (isolated mode) — listening for incoming peers"); } + let relay = if let Some(key_pair) = relay_key_pair { + let mut relay_config = BlindRelayServerConfig::default(); + if let Some(v) = args.relay_max_sessions { + relay_config.max_sessions = v; + } + if let Some(v) = args.relay_max_pairings_per_session { + relay_config.max_pairings_per_session = v; + } + if let Some(v) = args.relay_pairing_timeout { + relay_config.pairing_timeout = Duration::from_secs(v); + } + if let Some(v) = args.relay_idle_session_timeout { + relay_config.idle_session_timeout = Duration::from_secs(v); + } + let mut relay_service_config = RelayServiceConfig::default(); + relay_service_config.relay = relay_config; + + tracing::info!( + pubkey = %to_hex(&key_pair.public_key), + "Courtesy blind-relay ready alongside DHT node" + ); + + let (relay, _relay_task) = relay_service::run_relay_server( + runtime.handle(), + handle.clone(), + key_pair, + server_rx, + relay_service_config, + ); + Some(relay) + } else { + None + }; + let mut stats_timer = tokio::time::interval(Duration::from_secs(stats_interval)); stats_timer.tick().await; // skip first immediate tick @@ -143,11 +221,30 @@ pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { ticks_since_bootstrap += 1; let size = handle.table_size().await.unwrap_or(0); let pstats = handle.persistent_stats().await.unwrap_or_default(); - tracing::info!( + tracing::debug!( "Routing table: {size} peers | Records: {} ({} topics) | Mutables: {} | Immutables: {} | Router: {}", pstats.records, pstats.record_topics, pstats.mutables, pstats.immutables, pstats.router_entries ); + if let Some(relay) = &relay { + let rstats = relay.stats(); + tracing::info!( + "Relay — sessions: {} accepted / {} active | \ + pairings: {} requested, {} matched, {} pending, {} active, {} cancelled | \ + streams: {} opened, {} closed, {} errors", + rstats.sessions_accepted, + rstats.sessions_active, + rstats.pairings_requested, + rstats.pairings_matched, + rstats.pairings_pending, + rstats.pairings_active, + rstats.pairings_cancelled, + rstats.streams_opened, + rstats.streams_closed, + rstats.streams_errors, + ); + } + if is_networked && size == 0 { if ticks_since_bootstrap == 1 { let elapsed = stats_interval; @@ -174,3 +271,16 @@ pub async fn run(args: NodeArgs, cfg: &ResolvedConfig) -> i32 { 0 } + +fn parse_relay_key_seed(seed_hex: Option<&str>) -> Result { + match seed_hex { + None => Ok(KeyPair::generate()), + Some(hex_str) => { + let bytes = hex::decode(hex_str).map_err(|e| format!("invalid --relay-key-seed hex: {e}"))?; + let seed: [u8; 32] = bytes + .try_into() + .map_err(|_| "--relay-key-seed must be exactly 32 bytes (64 hex chars)".to_string())?; + Ok(KeyPair::from_seed(seed)) + } + } +} diff --git a/peeroxide-cli/src/cmd/relay.rs b/peeroxide-cli/src/cmd/relay.rs new file mode 100644 index 0000000..3ed78ec --- /dev/null +++ b/peeroxide-cli/src/cmd/relay.rs @@ -0,0 +1,196 @@ +use clap::Args; +use libudx::UdxRuntime; +use peeroxide_dht::hyperdht::{self, HyperDhtConfig, KeyPair}; +use peeroxide_dht::relay_service::{self, RelayServiceConfig}; +use peeroxide_dht::blind_relay::BlindRelayServerConfig; +use peeroxide_dht::rpc::DhtConfig; +use std::time::Duration; +use tokio::signal; + +use crate::config::ResolvedConfig; +use super::{resolve_bootstrap, to_hex}; + +#[derive(Args)] +pub struct RelayArgs { + /// Bind port (default: 49737) + #[arg(long)] + port: Option, + + /// Bind address (default: 0.0.0.0) + #[arg(long)] + host: Option, + + /// Hex-encoded 32-byte seed for a deterministic identity key pair + /// (default: a fresh random key pair each run) + #[arg(long)] + key_seed: Option, + + /// How often to log relay/routing stats in seconds (default: 60) + #[arg(long)] + stats_interval: Option, + + /// Maximum concurrently accepted relay sessions (default: 10000) + #[arg(long)] + max_sessions: Option, + + /// Maximum concurrent pending+active pairings per session (default: 256) + #[arg(long)] + max_pairings_per_session: Option, + + /// Drop an unmatched pairing after this many seconds (default: 300) + #[arg(long)] + pairing_timeout: Option, + + /// Close a session idle for this many seconds (default: 600; not yet + /// enforced — reserved for a follow-up idle-sweep pass) + #[arg(long)] + idle_session_timeout: Option, +} + +pub async fn run(args: RelayArgs, cfg: &ResolvedConfig) -> i32 { + let port = args.port.or(cfg.node.port).unwrap_or(49737); + let host = args + .host + .or_else(|| cfg.node.host.clone()) + .unwrap_or_else(|| "0.0.0.0".to_string()); + let stats_interval = args.stats_interval.unwrap_or(60); + + if stats_interval == 0 { + eprintln!("error: --stats-interval must be greater than 0"); + return 1; + } + + let key_pair = match parse_key_seed(args.key_seed.as_deref()) { + Ok(kp) => kp, + Err(e) => { + eprintln!("error: {e}"); + return 1; + } + }; + + let mut relay_config = BlindRelayServerConfig::default(); + if let Some(v) = args.max_sessions { + relay_config.max_sessions = v; + } + if let Some(v) = args.max_pairings_per_session { + relay_config.max_pairings_per_session = v; + } + if let Some(v) = args.pairing_timeout { + relay_config.pairing_timeout = Duration::from_secs(v); + } + if let Some(v) = args.idle_session_timeout { + relay_config.idle_session_timeout = Duration::from_secs(v); + } + + let bootstrap = resolve_bootstrap(cfg); + + let mut dht_cfg = DhtConfig::default(); + dht_cfg.bootstrap = bootstrap; + dht_cfg.port = port; + dht_cfg.host = host.clone(); + dht_cfg.ephemeral = Some(false); + dht_cfg.firewalled = false; + let mut dht_config = HyperDhtConfig::default(); + dht_config.dht = dht_cfg; + + let runtime = match UdxRuntime::new() { + Ok(r) => r, + Err(e) => { + eprintln!("error: failed to create UDP runtime: {e}"); + return 1; + } + }; + + let (task, handle, server_rx) = match hyperdht::spawn(&runtime, dht_config).await { + Ok(v) => v, + Err(e) => { + eprintln!("error: failed to start DHT node: {e}"); + return 1; + } + }; + + let listen_port = match handle.local_port().await { + Ok(p) => p, + Err(e) => { + eprintln!("error: failed to get local port: {e}"); + return 1; + } + }; + + println!("relay public key: {}", to_hex(&key_pair.public_key)); + println!("{host}:{listen_port}"); + + if let Err(e) = handle.bootstrapped().await { + eprintln!("error: bootstrap failed: {e}"); + return 1; + } + + tracing::info!( + pubkey = %to_hex(&key_pair.public_key), + "Blind-relay server ready" + ); + + let mut relay_service_config = RelayServiceConfig::default(); + relay_service_config.relay = relay_config; + + let (relay, _relay_task) = relay_service::run_relay_server( + runtime.handle(), + handle.clone(), + key_pair, + server_rx, + relay_service_config, + ); + + let mut stats_timer = tokio::time::interval(Duration::from_secs(stats_interval)); + stats_timer.tick().await; // skip first immediate tick + + loop { + tokio::select! { + _ = signal::ctrl_c() => { + tracing::info!("Shutdown signal received"); + break; + } + _ = super::sigterm_recv() => { + tracing::info!("SIGTERM received"); + break; + } + _ = stats_timer.tick() => { + let table_size = handle.table_size().await.unwrap_or(0); + let stats = relay.stats(); + tracing::info!( + "Routing table: {table_size} peers | sessions: {}/active {} | \ + pairings: {} requested, {} matched, {} pending, {} active, {} cancelled | \ + streams: {} opened, {} closed, {} errors", + stats.sessions_accepted, + stats.sessions_active, + stats.pairings_requested, + stats.pairings_matched, + stats.pairings_pending, + stats.pairings_active, + stats.pairings_cancelled, + stats.streams_opened, + stats.streams_closed, + stats.streams_errors, + ); + } + } + } + + let _ = handle.destroy().await; + let _ = task.await; + + 0 +} + +fn parse_key_seed(seed_hex: Option<&str>) -> Result { + match seed_hex { + None => Ok(KeyPair::generate()), + Some(hex_str) => { + let bytes = hex::decode(hex_str).map_err(|e| format!("invalid --key-seed hex: {e}"))?; + let seed: [u8; 32] = bytes + .try_into() + .map_err(|_| "--key-seed must be exactly 32 bytes (64 hex chars)".to_string())?; + Ok(KeyPair::from_seed(seed)) + } + } +} diff --git a/peeroxide-cli/src/main.rs b/peeroxide-cli/src/main.rs index d343afb..0b6c8ab 100644 --- a/peeroxide-cli/src/main.rs +++ b/peeroxide-cli/src/main.rs @@ -59,6 +59,8 @@ enum Commands { Lookup(cmd::lookup::LookupArgs), /// Announce presence on a topic Announce(cmd::announce::AnnounceArgs), + /// Run a standalone blind-relay server + Relay(cmd::relay::RelayArgs), /// Diagnose reachability of a DHT node or peer Ping(cmd::ping::PingArgs), /// Copy files between peers over the swarm @@ -94,9 +96,11 @@ fn init_tracing(verbose: u8) { EnvFilter::from_default_env() } else { match verbose { - 0 => EnvFilter::new("warn"), - 1 => EnvFilter::new("peeroxide=info,warn"), - _ => EnvFilter::new("peeroxide=debug,info"), + 0 => EnvFilter::new("warn,peeroxide::_events=info"), + 1 => EnvFilter::new("peeroxide=info,peeroxide_dht=info,peeroxide::_events=info,warn"), + _ => EnvFilter::new( + "peeroxide=debug,peeroxide_dht=debug,libudx=debug,peeroxide::_events=info,info", + ), } }; tracing_subscriber::fmt() @@ -159,6 +163,7 @@ fn main() { Commands::Node(args) => cmd::node::run(args, &cfg).await, Commands::Lookup(args) => cmd::lookup::run(args, &cfg).await, Commands::Announce(args) => cmd::announce::run(args, &cfg).await, + Commands::Relay(args) => cmd::relay::run(args, &cfg).await, Commands::Ping(args) => cmd::ping::run(args, &cfg).await, Commands::Cp { command } => cmd::cp::run(command, &cfg).await, Commands::Dd { command } => cmd::deaddrop::run(command, &cfg).await, diff --git a/peeroxide-cli/tests/live_commands.rs b/peeroxide-cli/tests/live_commands.rs index ab4ae76..f09c155 100644 --- a/peeroxide-cli/tests/live_commands.rs +++ b/peeroxide-cli/tests/live_commands.rs @@ -251,3 +251,217 @@ async fn test_live_cp_send_recv() { assert!(result.is_ok(), "test_live_cp_send_recv timed out after 60s"); } + +/// Honest non-LAN gate: forces `local_connection=false` so the receiver +/// cannot fall back to the same-host loopback shortcut. With Phase 3 hole- +/// punching landed, two peers on the same host must complete the transfer +/// via the holepunched path over the receiver's puncher socket. This test +/// verifies the toggle engaged AND the transfer succeeded AND the received +/// bytes match the sent bytes — proving the Phase 3 holepunch flow works +/// end-to-end on the public DHT. +#[tokio::test] +#[ignore = "requires internet — non-LAN holepunch gate, Phase 3"] +async fn test_live_cp_send_recv_no_lan() { + let result = tokio::time::timeout(Duration::from_secs(90), async { + let dir = tempfile::tempdir().unwrap(); + let send_path = dir.path().join("testfile.dat"); + let content = b"peeroxide cp live no-LAN gate content"; + std::fs::write(&send_path, content).unwrap(); + + let recv_path = dir.path().join("received.dat"); + let send_path_str = send_path.to_str().unwrap().to_string(); + + let mut send_child = Command::new(bin_path()) + .args([ + "--no-default-config", "--public", + "cp", "send", &send_path_str, + ]) + .env("PEEROXIDE_LOCAL_CONNECTION", "false") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cp send"); + + let stdout = send_child.stdout.take().unwrap(); + let topic = tokio::task::spawn_blocking(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let line = line.unwrap_or_default(); + let trimmed = line.trim(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(trimmed.to_string()); + } + } + None + }) + .await + .unwrap(); + + let topic = topic.expect("cp send did not output topic"); + + // Wait longer than the LAN-shortcut variant because the no-LAN + // path can't fast-resolve via loopback; we need the sender's + // announce to fully propagate before recv attempts lookup. + tokio::time::sleep(Duration::from_secs(15)).await; + + let recv_path_str = recv_path.to_str().unwrap().to_string(); + let recv_path_for_assert = recv_path.clone(); + let recv_output = tokio::task::spawn_blocking(move || { + Command::new(bin_path()) + .args([ + "--no-default-config", "--public", + "cp", "recv", &topic, &recv_path_str, + "--yes", + "--timeout", "45", + ]) + .env("PEEROXIDE_LOCAL_CONNECTION", "false") + .env("RUST_LOG", "peeroxide_dht=debug") + .env("NO_COLOR", "1") + .output() + .expect("failed to run cp recv") + }) + .await + .unwrap(); + + kill_child(&mut send_child); + + let recv_stderr = String::from_utf8_lossy(&recv_output.stderr); + + // The recv must have rejected the loopback shortcut. Look for + // either `same_host=false` (LAN-shortcut explicitly disabled) or + // a non-loopback dial target. NOT seeing these means the toggle + // didn't take effect. + let disabled_lan_shortcut = recv_stderr.contains("same_host=false"); + let attempted_non_loopback = recv_stderr.contains("connect_addr=") + && !recv_stderr.contains("connect_addr=127.0.0.1:"); + + assert!( + disabled_lan_shortcut || attempted_non_loopback, + "local_connection=false toggle did not engage; expected `same_host=false` \ + or non-loopback `connect_addr` in stderr.\n--- stderr ---\n{recv_stderr}" + ); + + // Phase 3 holepunch landed — recv MUST succeed. + assert!( + recv_output.status.success(), + "live cp recv (no-LAN, Phase 3 holepunch) failed.\n--- stderr ---\n{recv_stderr}" + ); + + let received = std::fs::read(&recv_path_for_assert) + .expect("recv output file not found despite recv exiting 0"); + assert_eq!( + received, content, + "received file content does not match sent content.\n--- stderr ---\n{recv_stderr}" + ); + }) + .await; + + assert!(result.is_ok(), "test_live_cp_send_recv_no_lan timed out after 90s"); +} + +/// Force the data path through a blind-relay by advertising `relay_through` from the +/// send (server) side via the `PEEROXIDE_FORCE_RELAY` env var. The placeholder uses a +/// zero pubkey + `8.8.8.8:49737`, which is intentionally unreachable as a blind-relay +/// endpoint — the test asserts that: +/// +/// 1. The recv (client) honors the advertised `relay_through` (does NOT successfully +/// fall through to direct/holepunch when relay is mandated). +/// 2. The recv exits non-zero — failure mode is the relay attempt, not topic lookup +/// or some unrelated path. +/// +/// This wires the env var end-to-end. A real public relay pubkey + addr (once we find +/// one via the hunt script) can be substituted to flip this from "fail cleanly" to +/// "succeed via relay." +#[tokio::test] +#[ignore = "requires internet — force-relay wiring against placeholder, expected fail"] +async fn test_live_cp_send_recv_force_relay() { + let placeholder_pk = "0".repeat(64); + let placeholder_relay = format!("{placeholder_pk}@8.8.8.8:49737"); + + let result = tokio::time::timeout(Duration::from_secs(90), async { + let dir = tempfile::tempdir().unwrap(); + let send_path = dir.path().join("testfile.dat"); + let content = b"peeroxide cp force-relay placeholder content"; + std::fs::write(&send_path, content).unwrap(); + + let recv_path = dir.path().join("received.dat"); + let send_path_str = send_path.to_str().unwrap().to_string(); + + let mut send_child = Command::new(bin_path()) + .args([ + "--no-default-config", "--public", + "cp", "send", &send_path_str, + ]) + .env("PEEROXIDE_FORCE_RELAY", &placeholder_relay) + .env("NO_COLOR", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cp send"); + + let stdout = send_child.stdout.take().unwrap(); + let topic = tokio::task::spawn_blocking(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let line = line.unwrap_or_default(); + let trimmed = line.trim(); + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(trimmed.to_string()); + } + } + None + }) + .await + .unwrap(); + + let topic = topic.expect("cp send did not output topic"); + + tokio::time::sleep(Duration::from_secs(15)).await; + + let recv_path_str = recv_path.to_str().unwrap().to_string(); + let recv_output = tokio::task::spawn_blocking(move || { + Command::new(bin_path()) + .args([ + "--no-default-config", "--public", + "cp", "recv", &topic, &recv_path_str, + "--yes", + "--timeout", "30", + ]) + .env("RUST_LOG", "peeroxide_dht=debug,peeroxide=debug") + .env("NO_COLOR", "1") + .output() + .expect("failed to run cp recv") + }) + .await + .unwrap(); + + kill_child(&mut send_child); + + let recv_stderr = String::from_utf8_lossy(&recv_output.stderr); + + assert!( + !recv_output.status.success(), + "recv unexpectedly succeeded against 8.8.8.8 placeholder relay; \ + either the relay path was bypassed (a regression) or 8.8.8.8 ran a \ + blind-relay (extremely unlikely).\n--- stderr ---\n{recv_stderr}" + ); + + let relay_path_engaged = recv_stderr.contains("relay_through") + || recv_stderr.contains("relay_connection") + || recv_stderr.contains("BlindRelayClient") + || recv_stderr.contains("8.8.8.8"); + + assert!( + relay_path_engaged, + "recv stderr shows no evidence of relay-path engagement against the \ + placeholder; PEEROXIDE_FORCE_RELAY may not be plumbed through to the \ + handshake.\n--- stderr ---\n{recv_stderr}" + ); + }) + .await; + + assert!( + result.is_ok(), + "test_live_cp_send_recv_force_relay timed out after 90s" + ); +} diff --git a/peeroxide-dht/CHANGELOG.md b/peeroxide-dht/CHANGELOG.md index 47e7d28..112b24a 100644 --- a/peeroxide-dht/CHANGELOG.md +++ b/peeroxide-dht/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `BlindRelayServer`/`BlindRelaySession` (`blind_relay` module): a real blind-relay server implementation. Previously peeroxide only implemented the blind-relay *client* side; this adds the pairing-table/session-limit/stats engine mirroring Node's `blind-relay` `Server`/`BlindRelaySession`/`BlindRelayPair`. +- `BlindRelayServerConfig`: configurable `max_sessions`, `max_pairings_per_session`, `pairing_timeout`, `idle_session_timeout`. Node's reference `blind-relay` has none of these; defaults are generous so a default relay behaves like the unthrottled Node reference in practice. +- `relay_service::run_relay_server`: standalone accept-and-bridge entry point wiring `BlindRelayServer` to real UDX transport (`UdxStream::relay_to` for blind, packet-level forwarding). Deliberately independent of `peeroxide::Swarm`. +- Idle-session-timeout enforcement and stream teardown on unpair/session-close for active pairings (peeroxide-specific hardening; the latter has 1:1 precedent in Node's `blind-relay` `_onclose`/`_onunpair`). +- `hyperdht::alloc_stream_id()`: exposes the same stream-id counter used internally by `connect_to`, for callers (e.g. `peeroxide::swarm`) that need to allocate a stream id on the same counter as an existing control connection. + +### Fixed + +- The relay service now self-announces its own identity (`hash(public_key)`) to the DHT at startup and on a periodic refresh, mirroring Node's `Server.listen()` (which internally starts an `Announcer` for the server's own target). Without this, `register_server` alone only made the relay answer inbound `PEER_HANDSHAKE` requests locally — nothing told the rest of the network the relay existed, so a Node.js `hyperdht` client's `dht.connect(pubkey)` (which resolves candidates via a LOOKUP-style `findPeer` query for an announced record, not a raw `FIND_NODE` walk) could never discover it and failed with `PEER_NOT_FOUND`. +- `RelayStats` is now `#[non_exhaustive]`, matching its `RelayStatsSnapshot` counterpart and the crate's Event/Result visibility policy (`VISIBILITY_POLICY.md`), so future counter fields can be added without a breaking change. + +## [1.4.0](https://github.com/Rightbracket/peeroxide/compare/peeroxide-dht-v1.3.1...peeroxide-dht-v1.4.0) - 2026-05-18 + +### Added + +- `peeroxide_dht::State` re-export at the crate root (alongside the existing `EncodingError` re-export). The compact-encoding `State` type was used in many public encode/decode function signatures; the re-export makes it nameable from out-of-crate consumers without depending on the `compact_encoding` module path. +- `peeroxide_dht::QueryReply` and `peeroxide_dht::QueryResult` re-exports at the crate root. These types are returned by `DhtHandle::find_node` / `DhtHandle::query`; the re-exports preserve external reachability after the `query` module was demoted to `pub(crate)`. +- `peeroxide_dht::{Router, HandshakeAction, HolepunchAction, ForwardEntry, HandshakeResult, HolepunchResult, RouterError}` re-exports at the crate root. These types are returned by `HyperDhtHandle::router()` and related public methods; the re-exports preserve external reachability after the `router` module was demoted to `pub(crate)`. +- `WireCounters::from_counters(bytes_sent, bytes_received)` constructor. Allows building a `WireCounters` snapshot from externally-owned `Arc` counters (e.g. a CLI progress reporter that already holds atomic byte counters wants a `WireCounters` view sharing those atomics). +- Module-level documentation for `holepuncher`, `io`, `peer`, `persistent`, `secretstream`, `secure_payload`, `socket_pool`, which are now documented public API rather than `#[doc(hidden)]`. + +### Changed + +- `holepuncher`, `io`, `peer`, `persistent`, `secretstream`, `secure_payload`, `socket_pool` are now documented public modules. They were previously `#[doc(hidden)] pub mod` (publicly reachable but absent from rustdoc); the new state is fully public and intended as the advanced-use surface for consumers building custom DHT clients, server nodes, or hole-punch orchestration. +- `compact_encoding`, `nat`, `query`, `router`, `routing_table` are now `pub(crate)`. They were previously `pub` (some `#[doc(hidden)]`); none had cross-crate usage beyond the types now re-exported at the crate root (`EncodingError`, `State`, `QueryReply`, `QueryResult`, `Router` family) or the `peeroxide` integration test surface. Their items are accessible via the re-exports listed above where needed; direct `peeroxide_dht::::*` paths are no longer reachable. +- `Holepuncher.nat: Nat` field demoted from `pub` to `pub(crate)`. The `nat` module is now `pub(crate)`; the field continues to be read by internal callers but is no longer reachable from outside the crate. +- `blind_relay::encode_pair`, `encode_unpair`, `decode_pair`, `decode_unpair`, `preencode_pair`, `preencode_unpair`, `encode_pair_to_vec`, `encode_unpair_to_vec`, `decode_pair_from_slice`, `decode_unpair_from_slice` demoted from `pub` to `pub(crate)`. These helpers are used internally by `BlindRelayClient` and have no documented external consumers. +- Applied `#[non_exhaustive]` to additional Config / Result / Event types whose role in the public API admits forward-compatible field/variant additions: `io::WireCounters`, `io::IoConfig`, `io::IoStats`, `io::IoEvent`, `io::TimeoutEvent`, `protomux::Channel`, `protomux::Mux`, `blind_relay::PairResponse`, `noise::HandshakeResult`, `socket_pool::HolepunchEvent`. Out-of-crate consumers can no longer use struct-literal construction or exhaustive enum matches on these types; readers and pattern-matching with a wildcard arm continue to work. + ## [1.3.0](https://github.com/Rightbracket/peeroxide/compare/peeroxide-dht-v1.2.0...peeroxide-dht-v1.3.0) - 2026-05-13 ### Added diff --git a/peeroxide-dht/Cargo.toml b/peeroxide-dht/Cargo.toml index eead6fe..ad3e1d0 100644 --- a/peeroxide-dht/Cargo.toml +++ b/peeroxide-dht/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peeroxide-dht" -version = "1.3.1" +version = "1.7.2" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -15,7 +15,7 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -libudx = { path = "../libudx", version = "1.3.1" } +libudx = { path = "../libudx", version = "1.4.1" } tokio = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } @@ -28,6 +28,7 @@ chacha20 = "0.9" poly1305 = "0.8" rand = "0.9" xsalsa20poly1305 = "0.9" +if-addrs = "0.13" [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/peeroxide-dht/src/blind_relay.rs b/peeroxide-dht/src/blind_relay.rs index 54dbc1a..6fcbfc2 100644 --- a/peeroxide-dht/src/blind_relay.rs +++ b/peeroxide-dht/src/blind_relay.rs @@ -1,30 +1,18 @@ //! blind-relay protocol messages — wire-compatible with Node.js `blind-relay@1.4.0`. //! //! The blind-relay protocol uses Protomux with protocol name `"blind-relay"`. -//! It has exactly two message types: -//! -//! - **Pair** (type 0): Request to pair two connections through the relay. -//! - **Unpair** (type 1): Cancel a previous pair request. -//! -//! # Wire Format -//! -//! ## Pair (message type 0) -//! ```text -//! [bitfield(7): flags, bit0=isInitiator] [fixed32: token] [uint: id] [uint: seq] -//! ``` -//! -//! ## Unpair (message type 1) -//! ```text -//! [bitfield(7): flags, all zero] [fixed32: token] -//! ``` -//! -//! The `bitfield(7)` from `compact-encoding-bitfield` is a single byte holding -//! up to 7 boolean flags. Only bit 0 (`is_initiator`) is used. + +#![allow(dead_code)] use crate::compact_encoding::{self as c, State}; use crate::protomux::{self, Channel, ChannelEvent, Mux}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use thiserror::Error; -use tracing::debug; +use tokio::sync::mpsc; +use tracing::{debug, trace}; /// Pair message — requests relay pairing with a 32-byte token. #[derive(Debug, Clone, PartialEq)] @@ -64,7 +52,7 @@ pub fn preencode_pair(state: &mut State, msg: &PairMessage) { } /// Encodes a [`PairMessage`] into the state buffer. -pub fn encode_pair(state: &mut State, msg: &PairMessage) { +pub(crate) fn encode_pair(state: &mut State, msg: &PairMessage) { let flags: u8 = if msg.is_initiator { 1 } else { 0 }; c::encode_uint8(state, flags); c::encode_fixed32(state, &msg.token); @@ -73,7 +61,7 @@ pub fn encode_pair(state: &mut State, msg: &PairMessage) { } /// Decodes a [`PairMessage`] from the state buffer. -pub fn decode_pair(state: &mut State) -> c::Result { +pub(crate) fn decode_pair(state: &mut State) -> c::Result { let flags = c::decode_uint8(state)?; let is_initiator = flags & 1 != 0; let token = c::decode_fixed32(state)?; @@ -94,20 +82,20 @@ pub fn preencode_unpair(state: &mut State, _msg: &UnpairMessage) { } /// Encodes a [`UnpairMessage`] into the state buffer. -pub fn encode_unpair(state: &mut State, msg: &UnpairMessage) { +pub(crate) fn encode_unpair(state: &mut State, msg: &UnpairMessage) { c::encode_uint8(state, 0); // flags = 0 c::encode_fixed32(state, &msg.token); } /// Decodes a [`UnpairMessage`] from the state buffer. -pub fn decode_unpair(state: &mut State) -> c::Result { +pub(crate) fn decode_unpair(state: &mut State) -> c::Result { let _flags = c::decode_uint8(state)?; let token = c::decode_fixed32(state)?; Ok(UnpairMessage { token }) } /// Encode a pair message to bytes (preencode + allocate + encode). -pub fn encode_pair_to_vec(msg: &PairMessage) -> Vec { +pub(crate) fn encode_pair_to_vec(msg: &PairMessage) -> Vec { let mut state = State::new(); preencode_pair(&mut state, msg); state.alloc(); @@ -116,7 +104,7 @@ pub fn encode_pair_to_vec(msg: &PairMessage) -> Vec { } /// Encode an unpair message to bytes. -pub fn encode_unpair_to_vec(msg: &UnpairMessage) -> Vec { +pub(crate) fn encode_unpair_to_vec(msg: &UnpairMessage) -> Vec { let mut state = State::new(); preencode_unpair(&mut state, msg); state.alloc(); @@ -125,13 +113,13 @@ pub fn encode_unpair_to_vec(msg: &UnpairMessage) -> Vec { } /// Decode a pair message from bytes. -pub fn decode_pair_from_slice(data: &[u8]) -> c::Result { +pub(crate) fn decode_pair_from_slice(data: &[u8]) -> c::Result { let mut state = State::from_buffer(data); decode_pair(&mut state) } /// Decode an unpair message from bytes. -pub fn decode_unpair_from_slice(data: &[u8]) -> c::Result { +pub(crate) fn decode_unpair_from_slice(data: &[u8]) -> c::Result { let mut state = State::from_buffer(data); decode_unpair(&mut state) } @@ -165,6 +153,7 @@ pub enum RelayError { /// Response from a successful relay pairing. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct PairResponse { /// Relay-assigned remote stream identifier. pub remote_id: u64, @@ -263,6 +252,893 @@ impl BlindRelayClient { } } +// ── Server ─────────────────────────────────────────────────────────────────── +// +// Protocol-only implementation of the relay-matching engine (Node's +// `blind-relay` `Server`/`BlindRelaySession`/`BlindRelayPair` from +// `index.js`). This module has no dependency on libudx or UdxRuntime: it +// tracks the shared pairing table, per-session bookkeeping, and configurable +// limits, and hands a [`MatchedPairing`] to the caller once both sides of a +// token have registered. The caller (see `peeroxide-dht::relay_service`) is +// responsible for creating the two raw UDX streams and bridging them with +// `UdxStream::relay_to` — this module never touches transport. +// +// Node has no equivalent of `max_sessions`/`max_pairings_per_session`/ +// `pairing_timeout`/`idle_session_timeout` — its `blind-relay` package is +// fully unthrottled (confirmed by source inspection). These are a +// peeroxide-specific hardening addition; defaults are generous so an +// out-of-the-box relay behaves like Node's unthrottled reference. + +/// Configuration limits for a [`BlindRelayServer`]. +/// +/// Node's `blind-relay`/`blind-relay-service` has none of these — see the +/// module-level notes above. Defaults are intentionally generous so a +/// default-configured relay is, in practice, unthrottled. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct BlindRelayServerConfig { + /// Maximum number of concurrently accepted sessions. New connections + /// past this cap are rejected by the caller (see `relay_service`). + pub max_sessions: usize, + /// Maximum number of concurrent pending+active pairings a single + /// session may hold. Guards against one peer exhausting the relay's + /// pairing table. + pub max_pairings_per_session: usize, + /// How long an unmatched (pending) pairing may wait for its other side + /// before being dropped. Node has no such timeout; a pending pairing + /// there lives forever until `unpair`/session close. + pub pairing_timeout: Duration, + /// How long a session may go without any activity (pair/unpair + /// messages) before it is closed. **No Node precedent** — confirmed + /// against `blind-relay`'s source, a session and its streams there + /// live until the channel actually closes or `unpair`/`destroy` is + /// called explicitly; this is a deliberate peeroxide-only hardening + /// addition, not a protocol requirement. + pub idle_session_timeout: Duration, +} + +impl Default for BlindRelayServerConfig { + fn default() -> Self { + Self { + max_sessions: 10_000, + max_pairings_per_session: 256, + pairing_timeout: Duration::from_secs(300), + idle_session_timeout: Duration::from_secs(600), + } + } +} + +/// Relay statistics, named to mirror `blind-relay-service`'s `relay.stats` +/// Prometheus fields 1:1 for easy cross-reference against the Node +/// reference implementation. +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct RelayStats { + /// Total sessions ever accepted. + pub sessions_accepted: AtomicU64, + /// Currently active (open) sessions. + pub sessions_active: AtomicI64, + /// Total pairing requests ever received. + pub pairings_requested: AtomicU64, + /// Total pairings that successfully matched both sides. + pub pairings_matched: AtomicU64, + /// Total pairings cancelled (via `unpair`, timeout, or session close + /// before a match). + pub pairings_cancelled: AtomicU64, + /// Currently pending (registered, unmatched) pairings. + pub pairings_pending: AtomicI64, + /// Currently active (matched, bridging) pairings. + pub pairings_active: AtomicI64, + /// Total data-plane streams ever opened (2 per matched pairing). + pub streams_opened: AtomicU64, + /// Total data-plane streams ever closed. + pub streams_closed: AtomicU64, + /// Total data-plane stream errors. + pub streams_errors: AtomicU64, +} + +/// Point-in-time snapshot of [`RelayStats`], for logging/inspection. +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub struct RelayStatsSnapshot { + /// See [`RelayStats::sessions_accepted`]. + pub sessions_accepted: u64, + /// See [`RelayStats::sessions_active`]. + pub sessions_active: i64, + /// See [`RelayStats::pairings_requested`]. + pub pairings_requested: u64, + /// See [`RelayStats::pairings_matched`]. + pub pairings_matched: u64, + /// See [`RelayStats::pairings_cancelled`]. + pub pairings_cancelled: u64, + /// See [`RelayStats::pairings_pending`]. + pub pairings_pending: i64, + /// See [`RelayStats::pairings_active`]. + pub pairings_active: i64, + /// See [`RelayStats::streams_opened`]. + pub streams_opened: u64, + /// See [`RelayStats::streams_closed`]. + pub streams_closed: u64, + /// See [`RelayStats::streams_errors`]. + pub streams_errors: u64, +} + +impl RelayStats { + /// Take a point-in-time snapshot of all counters. + pub fn snapshot(&self) -> RelayStatsSnapshot { + RelayStatsSnapshot { + sessions_accepted: self.sessions_accepted.load(Ordering::Relaxed), + sessions_active: self.sessions_active.load(Ordering::Relaxed), + pairings_requested: self.pairings_requested.load(Ordering::Relaxed), + pairings_matched: self.pairings_matched.load(Ordering::Relaxed), + pairings_cancelled: self.pairings_cancelled.load(Ordering::Relaxed), + pairings_pending: self.pairings_pending.load(Ordering::Relaxed), + pairings_active: self.pairings_active.load(Ordering::Relaxed), + streams_opened: self.streams_opened.load(Ordering::Relaxed), + streams_closed: self.streams_closed.load(Ordering::Relaxed), + streams_errors: self.streams_errors.load(Ordering::Relaxed), + } + } +} + +/// One side of a matched pairing, handed to the caller so it can create the +/// data-plane raw stream for that side and reply on the wire. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct MatchedSide { + /// The session that registered this side of the pairing. + pub session_id: u64, + /// Whether this side identified itself as the initiator. + pub is_initiator: bool, + /// The `id` field from this side's `pair` message — the *client's own* + /// local stream id, used by the caller as `remote_id` when connecting + /// the relay's raw stream for this side (mirrors Node's + /// `BlindRelayLink.remoteId`). + pub client_stream_id: u64, + /// Channel back to this side's session-driver task; send a + /// [`SessionOutbound::PairMatched`] once the caller has created and + /// bridged the raw streams, so the session can reply on the wire. + pub outbound_tx: mpsc::UnboundedSender, +} + +/// Both sides of a pairing that just matched — the caller must create two +/// raw data-plane streams (one per side) and bridge them (e.g. via +/// `UdxStream::relay_to`, both directions), then notify each side via its +/// `outbound_tx`. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct MatchedPairing { + /// The relay token that was matched. + pub token: [u8; 32], + /// The side that registered first. + pub first: MatchedSide, + /// The side that registered second (completed the match). + pub second: MatchedSide, +} + +/// Instructions from the server engine to a session's driver task. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum SessionOutbound { + /// Send a `pair` reply on the wire with this relay-assigned local + /// stream id (mirrors Node's `session._pair.send({ isInitiator, token, + /// id: stream.id, seq: 0 })`). + PairMatched { + /// The token that matched. + token: [u8; 32], + /// This side's `is_initiator` flag, echoed back. + is_initiator: bool, + /// The relay's own newly-created local stream id for this side. + local_stream_id: u64, + }, + /// Close this session (e.g. it was swept for being idle past + /// `idle_session_timeout` — a peeroxide-only addition with no Node + /// precedent; see [`BlindRelayServerConfig::idle_session_timeout`]). + /// [`BlindRelaySession::run`] treats this the same as a natural + /// remote close. + Close, +} + +/// Outcome of registering a `pair` request with [`BlindRelayServer::try_pair`]. +#[derive(Debug)] +#[non_exhaustive] +pub enum PairOutcome { + /// Registered; waiting for the other side of the token. + Pending, + /// This session already has a pairing registered for this token + /// (mirrors Node's silent no-op `if (pair.links[+isInitiator]) return`). + AlreadyPairing, + /// Both sides are now present — caller must wire the data plane. + Matched(MatchedPairing), + /// `max_pairings_per_session` or `max_sessions` limits were exceeded. + LimitExceeded, +} + +/// Outcome of [`BlindRelayServer::unpair`]. +#[derive(Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum UnpairOutcome { + /// A pending (unmatched) pairing was cancelled. + Cancelled, + /// An already-matched (active) pairing was destroyed. Mirrors Node's + /// `_onunpair`, which — when the token isn't found in the pending + /// table — looks it up in `this._streams` and calls + /// `.destroy(errors.PAIRING_CANCELLED())` on it. + Destroyed, + /// No pairing (pending or active) was found for this token. + NotFound, +} + +struct PendingLink { + session_id: u64, + is_initiator: bool, + client_stream_id: u64, + outbound_tx: mpsc::UnboundedSender, + created_at: Instant, +} + +struct PendingPairing { + links: [Option; 2], +} + +impl PendingPairing { + fn empty() -> Self { + Self { links: [None, None] } + } + + fn slot(&self, is_initiator: bool) -> &Option { + &self.links[usize::from(is_initiator)] + } +} + +/// An already-matched pairing whose data-plane streams the caller has +/// wired up. Held so [`BlindRelayServer::unpair`] (on an active token) and +/// [`BlindRelayServer::release_session`] can signal the caller to tear +/// the streams down — mirrors Node's `session._streams` map. +struct ActivePairing { + session_ids: [u64; 2], + teardown_tx: mpsc::UnboundedSender<()>, +} + +struct ServerState { + config: BlindRelayServerConfig, + pairing: Mutex>, + active_pairings: Mutex>, + session_count: AtomicU64, + next_session_id: AtomicU64, + /// Last-activity timestamp per session, for [`BlindRelayServer::sweep_idle_sessions`]. + /// Peeroxide-only — see [`BlindRelayServerConfig::idle_session_timeout`]. + session_activity: Mutex>, + /// Outbound channel per session, so the idle-sweep task can reach a + /// specific session to send [`SessionOutbound::Close`]. + session_outbound: Mutex>>, + stats: RelayStats, +} + +/// Shared pairing/session-limit engine for a blind-relay server. +/// +/// Protocol-only — see the module-level docs above. Cloning shares the same +/// underlying state (cheap `Arc` clone), so every accepted +/// [`BlindRelaySession`] holds its own clone. +#[derive(Clone)] +pub struct BlindRelayServer { + inner: Arc, +} + +impl BlindRelayServer { + /// Create a new relay server with the given configuration. + pub fn new(config: BlindRelayServerConfig) -> Self { + Self { + inner: Arc::new(ServerState { + config, + pairing: Mutex::new(HashMap::new()), + active_pairings: Mutex::new(HashMap::new()), + session_count: AtomicU64::new(0), + next_session_id: AtomicU64::new(1), + session_activity: Mutex::new(HashMap::new()), + session_outbound: Mutex::new(HashMap::new()), + stats: RelayStats::default(), + }), + } + } + + /// Current statistics snapshot. + pub fn stats(&self) -> RelayStatsSnapshot { + self.inner.stats.snapshot() + } + + /// The configured limits. + pub fn config(&self) -> &BlindRelayServerConfig { + &self.inner.config + } + + /// Reserve a new session slot, returning its id, or `None` if + /// `max_sessions` is already at capacity. + pub fn try_accept_session(&self) -> Option { + loop { + let current = self.inner.session_count.load(Ordering::Acquire); + if current as usize >= self.inner.config.max_sessions { + return None; + } + if self + .inner + .session_count + .compare_exchange( + current, + current + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + let id = self.inner.next_session_id.fetch_add(1, Ordering::Relaxed); + self.inner.stats.sessions_accepted.fetch_add(1, Ordering::Relaxed); + self.inner.stats.sessions_active.fetch_add(1, Ordering::Relaxed); + return Some(id); + } + } + } + + /// Register a newly-accepted session's outbound channel and seed its + /// activity baseline, so a session with zero `pair`/`unpair` traffic + /// doesn't immediately look infinitely idle to + /// [`Self::sweep_idle_sessions`]. Call once, right after + /// [`Self::try_accept_session`] succeeds. + pub fn register_session(&self, session_id: u64, outbound_tx: mpsc::UnboundedSender) { + self.inner + .session_outbound + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session_id, outbound_tx); + self.touch_session(session_id); + } + + /// Record activity for `session_id` (called on every `pair`/`unpair` + /// message processed), resetting its idle clock. + pub fn touch_session(&self, session_id: u64) { + self.inner + .session_activity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session_id, Instant::now()); + } + + /// Release a session slot (call when a session's connection closes). + /// Also cancels any pending pairings still held by that session, and + /// tears down any *active* (already-matched) pairings it was part of + /// (mirrors Node's `_onclose`, which destroys every stream in + /// `this._streams` when a session's channel closes). + pub fn release_session(&self, session_id: u64) { + self.inner.session_count.fetch_sub(1, Ordering::AcqRel); + self.inner.stats.sessions_active.fetch_sub(1, Ordering::Relaxed); + self.inner + .session_activity + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&session_id); + self.inner + .session_outbound + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&session_id); + + let mut pairing = self.inner.pairing.lock().unwrap_or_else(|e| e.into_inner()); + pairing.retain(|_token, pair| { + let mut touched = false; + for slot in &mut pair.links { + if slot.as_ref().is_some_and(|l| l.session_id == session_id) { + *slot = None; + touched = true; + } + } + if touched && pair.links.iter().all(Option::is_none) { + self.inner.stats.pairings_cancelled.fetch_add(1, Ordering::Relaxed); + self.inner.stats.pairings_pending.fetch_sub(1, Ordering::Relaxed); + false // remove entry + } else { + true + } + }); + drop(pairing); + + // Mirrors Node's `_onclose`: destroy every active stream this + // session was part of. + let mut active = self.inner.active_pairings.lock().unwrap_or_else(|e| e.into_inner()); + active.retain(|_token, pair| { + if pair.session_ids.contains(&session_id) { + let _ = pair.teardown_tx.send(()); + self.inner.stats.pairings_active.fetch_sub(1, Ordering::Relaxed); + false // remove entry + } else { + true + } + }); + } + + /// Register a `pair` request from a session. See [`PairOutcome`]. + #[allow(clippy::too_many_arguments)] + pub fn try_pair( + &self, + session_id: u64, + is_initiator: bool, + token: [u8; 32], + client_stream_id: u64, + session_pairing_count: usize, + outbound_tx: mpsc::UnboundedSender, + ) -> PairOutcome { + self.touch_session(session_id); + + if session_pairing_count >= self.inner.config.max_pairings_per_session { + return PairOutcome::LimitExceeded; + } + + self.inner.stats.pairings_requested.fetch_add(1, Ordering::Relaxed); + + let mut pairing = self.inner.pairing.lock().unwrap_or_else(|e| e.into_inner()); + let pair = pairing.entry(token).or_insert_with(PendingPairing::empty); + + if pair.slot(is_initiator).is_some() { + // Mirrors Node: a duplicate pair message for a slot already + // occupied by this session is a silent no-op. + return PairOutcome::AlreadyPairing; + } + + let link = PendingLink { + session_id, + is_initiator, + client_stream_id, + outbound_tx, + created_at: Instant::now(), + }; + pair.links[usize::from(is_initiator)] = Some(link); + + let other = &pair.links[usize::from(!is_initiator)]; + let Some(other_link) = other else { + self.inner.stats.pairings_pending.fetch_add(1, Ordering::Relaxed); + return PairOutcome::Pending; + }; + + // Both sides present: matched. Take ownership of both links and + // remove the pairing table entry. + let first = MatchedSide { + session_id: other_link.session_id, + is_initiator: other_link.is_initiator, + client_stream_id: other_link.client_stream_id, + outbound_tx: other_link.outbound_tx.clone(), + }; + let second = MatchedSide { + session_id, + is_initiator, + client_stream_id, + outbound_tx: pair.links[usize::from(is_initiator)] + .as_ref() + .expect("just inserted") + .outbound_tx + .clone(), + }; + pairing.remove(&token); + + // Only the first side ever incremented `pairings_pending`. + self.inner.stats.pairings_pending.fetch_sub(1, Ordering::Relaxed); + self.inner.stats.pairings_matched.fetch_add(1, Ordering::Relaxed); + self.inner.stats.pairings_active.fetch_add(1, Ordering::Relaxed); + + debug!( + token = %format_args!("{:02x?}", &token[..4]), + "blind-relay pairing matched" + ); + + PairOutcome::Matched(MatchedPairing { token, first, second }) + } + + /// Register an already-matched pairing's data-plane as active, so a + /// later [`Self::unpair`] or [`Self::release_session`] can signal + /// `teardown_tx` to tear the bridged streams down. Call once the + /// caller (see `relay_service::bridge_one_pairing`) has created and + /// wired the two raw streams for a [`MatchedPairing`] returned from + /// [`Self::try_pair`]. + pub fn mark_active( + &self, + token: [u8; 32], + session_ids: [u64; 2], + teardown_tx: mpsc::UnboundedSender<()>, + ) { + self.inner + .active_pairings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert( + token, + ActivePairing { + session_ids, + teardown_tx, + }, + ); + } + + /// Cancel a pairing for `session_id`/`token`. See [`UnpairOutcome`]. + /// + /// Checks the *pending* (unmatched) table first; if not found there, + /// checks *active* (already-matched) pairings and signals their + /// `teardown_tx` — mirrors Node's `_onunpair`, which does the same + /// two-step lookup and calls `.destroy(errors.PAIRING_CANCELLED())` on + /// an active stream it finds in `this._streams`. + pub fn unpair(&self, session_id: u64, token: [u8; 32]) -> UnpairOutcome { + self.touch_session(session_id); + + let mut pairing = self.inner.pairing.lock().unwrap_or_else(|e| e.into_inner()); + let Some(pair) = pairing.get_mut(&token) else { + drop(pairing); + return self.unpair_active(token); + }; + + let mut found = false; + for slot in &mut pair.links { + if slot.as_ref().is_some_and(|l| l.session_id == session_id) { + *slot = None; + found = true; + } + } + + if !found { + return UnpairOutcome::NotFound; + } + + if pair.links.iter().all(Option::is_none) { + pairing.remove(&token); + } + + self.inner.stats.pairings_cancelled.fetch_add(1, Ordering::Relaxed); + self.inner.stats.pairings_pending.fetch_sub(1, Ordering::Relaxed); + + UnpairOutcome::Cancelled + } + + /// Look up `token` in the active-pairings table and, if found, signal + /// its `teardown_tx` and remove the entry. Shared tail of + /// [`Self::unpair`] for the "already matched" case. + fn unpair_active(&self, token: [u8; 32]) -> UnpairOutcome { + let mut active = self.inner.active_pairings.lock().unwrap_or_else(|e| e.into_inner()); + let Some(pair) = active.remove(&token) else { + return UnpairOutcome::NotFound; + }; + let _ = pair.teardown_tx.send(()); + self.inner.stats.pairings_active.fetch_sub(1, Ordering::Relaxed); + UnpairOutcome::Destroyed + } + + /// Sweep sessions idle (no `pair`/`unpair` activity) longer than + /// `idle_session_timeout`, returning their ids so the caller can send + /// each a [`SessionOutbound::Close`]. Peeroxide-only — see + /// [`BlindRelayServerConfig::idle_session_timeout`]. Callers should + /// invoke this periodically (see `relay_service`), alongside + /// [`Self::sweep_expired_pairings`]. + pub fn sweep_idle_sessions(&self) -> Vec { + let timeout = self.inner.config.idle_session_timeout; + let activity = self.inner.session_activity.lock().unwrap_or_else(|e| e.into_inner()); + let idle: Vec = activity + .iter() + .filter(|(_, last)| last.elapsed() > timeout) + .map(|(id, _)| *id) + .collect(); + drop(activity); + + if idle.is_empty() { + return idle; + } + + let outbound = self.inner.session_outbound.lock().unwrap_or_else(|e| e.into_inner()); + let closed: Vec = idle + .into_iter() + .filter(|id| { + if let Some(tx) = outbound.get(id) { + let _ = tx.send(SessionOutbound::Close); + true + } else { + false + } + }) + .collect(); + if !closed.is_empty() { + trace!(count = closed.len(), "swept idle blind-relay sessions"); + } + closed + } + + /// Sweep pending pairings older than `pairing_timeout`, cancelling them. + /// Callers should invoke this periodically (see `relay_service`). + /// Returns the number of pairings dropped. + pub fn sweep_expired_pairings(&self) -> usize { + let timeout = self.inner.config.pairing_timeout; + let mut pairing = self.inner.pairing.lock().unwrap_or_else(|e| e.into_inner()); + let before = pairing.len(); + pairing.retain(|_token, pair| { + let oldest = pair + .links + .iter() + .filter_map(|l| l.as_ref().map(|l| l.created_at)) + .min(); + !matches!(oldest, Some(created_at) if created_at.elapsed() > timeout) + }); + let dropped = before - pairing.len(); + if dropped > 0 { + self.inner + .stats + .pairings_cancelled + .fetch_add(dropped as u64, Ordering::Relaxed); + self.inner + .stats + .pairings_pending + .fetch_sub(dropped as i64, Ordering::Relaxed); + trace!(dropped, "swept expired blind-relay pairings"); + } + dropped + } +} + +/// Drives one accepted connection's `pair`/`unpair` traffic against a shared +/// [`BlindRelayServer`]. Reactive counterpart to [`BlindRelayClient`]: +/// instead of sending requests and awaiting replies, it listens for +/// inbound `pair`/`unpair` messages and registers them with the server. +/// +/// The caller is responsible for opening the underlying [`Channel`] (same +/// `protocol: "blind-relay"`, `id: ` +/// convention the client uses — Protomux pairs the two ends by matching +/// `(protocol, id)`), driving [`Self::run`] to completion, and consuming the +/// `matched_tx` channel to wire up the actual data-plane streams. +pub struct BlindRelaySession { + session_id: u64, + server: BlindRelayServer, + channel: Channel, + pairing_count: usize, + outbound_rx: mpsc::UnboundedReceiver, + outbound_tx: mpsc::UnboundedSender, +} + +impl BlindRelaySession { + /// Wrap an already-open (or opening) `"blind-relay"` channel for a + /// newly-accepted connection. Returns `None` if the server is at + /// `max_sessions` capacity. + pub fn new(server: BlindRelayServer, channel: Channel) -> Option { + let session_id = server.try_accept_session()?; + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + server.register_session(session_id, outbound_tx.clone()); + Some(Self { + session_id, + server, + channel, + pairing_count: 0, + outbound_rx, + outbound_tx, + }) + } + + /// This session's id, for logging/correlation. + pub fn session_id(&self) -> u64 { + self.session_id + } + + /// Wait for the remote side to open the channel. + pub async fn wait_opened(&mut self) -> Result<(), RelayError> { + self.channel.wait_opened().await?; + Ok(()) + } + + /// Drive this session until the channel closes, forwarding matched + /// pairings to `matched_tx` for the caller to wire up the data plane. + /// + /// Returns when the underlying channel closes (remote close, error, or + /// server shutdown). Always releases the session slot on return. + pub async fn run(&mut self, matched_tx: &mpsc::UnboundedSender) { + loop { + tokio::select! { + biased; + + outbound = self.outbound_rx.recv() => { + match outbound { + Some(SessionOutbound::PairMatched { token, is_initiator, local_stream_id }) => { + let msg = PairMessage { + is_initiator, + token, + id: local_stream_id, + seq: 0, + }; + if self.channel.send(MSG_TYPE_PAIR, &encode_pair_to_vec(&msg)).is_err() { + break; + } + } + Some(SessionOutbound::Close) => break, + None => break, + } + } + + event = self.channel.recv() => { + match event { + Some(ChannelEvent::Message { message_type, data }) => { + self.on_message(message_type, &data, matched_tx); + } + Some(ChannelEvent::Opened { .. }) => {} + Some(ChannelEvent::Closed { .. }) | None => break, + } + } + } + } + + self.server.release_session(self.session_id); + } + + fn on_message( + &mut self, + message_type: u32, + data: &[u8], + matched_tx: &mpsc::UnboundedSender, + ) { + match message_type { + MSG_TYPE_PAIR => { + let Ok(msg) = decode_pair_from_slice(data) else { + trace!("blind-relay: dropping malformed pair message"); + return; + }; + let outcome = self.server.try_pair( + self.session_id, + msg.is_initiator, + msg.token, + msg.id, + self.pairing_count, + self.outbound_tx.clone(), + ); + match outcome { + PairOutcome::Pending => self.pairing_count += 1, + PairOutcome::Matched(matched) => { + let _ = matched_tx.send(matched); + } + PairOutcome::AlreadyPairing | PairOutcome::LimitExceeded => {} + } + } + MSG_TYPE_UNPAIR => { + let Ok(msg) = decode_unpair_from_slice(data) else { + trace!("blind-relay: dropping malformed unpair message"); + return; + }; + if self.server.unpair(self.session_id, msg.token) == UnpairOutcome::Cancelled { + self.pairing_count = self.pairing_count.saturating_sub(1); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod golden_interop { + use super::{ + decode_pair_from_slice, decode_unpair_from_slice, encode_pair_to_vec, encode_unpair_to_vec, + PairMessage, UnpairMessage, + }; + use serde::Deserialize; + + #[derive(Deserialize)] + struct GoldenFile { + #[allow(dead_code)] + generated_by: String, + #[allow(dead_code)] + blind_relay_version: String, + fixtures: Vec, + } + + #[derive(Deserialize)] + struct Fixture { + label: String, + #[serde(rename = "type")] + typ: String, + hex: String, + decoded: serde_json::Value, + } + + fn load_fixtures() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/interop/blind-relay-fixtures.json" + ); + let data = std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!("Failed to read blind-relay fixtures at {path}: {e}. Run `node generate-blind-relay-golden.js` in tests/node/ first.") + }); + let file: GoldenFile = serde_json::from_str(&data) + .unwrap_or_else(|e| panic!("Failed to parse blind-relay fixtures: {e}")); + file.fixtures + } + + fn from_hex(s: &str) -> Vec { + hex::decode(s).unwrap_or_else(|e| panic!("Invalid hex '{s}': {e}")) + } + + fn token_from_hex(s: &str) -> [u8; 32] { + let v = from_hex(s); + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + arr + } + + #[test] + fn golden_blind_relay_decode_pair() { + let fixtures = load_fixtures(); + let pairs: Vec<_> = fixtures.iter().filter(|f| f.typ == "pair").collect(); + assert!(!pairs.is_empty(), "no pair fixtures found"); + + for fix in pairs { + let raw = from_hex(&fix.hex); + let decoded = decode_pair_from_slice(&raw) + .unwrap_or_else(|e| panic!("[{}] decode failed: {e}", fix.label)); + + let d = &fix.decoded; + let expected = PairMessage { + is_initiator: d["is_initiator"].as_bool().unwrap(), + token: token_from_hex(d["token"].as_str().unwrap()), + id: d["id"].as_u64().unwrap(), + seq: d["seq"].as_u64().unwrap(), + }; + + assert_eq!(decoded, expected, "[{}] decode mismatch", fix.label); + } + } + + #[test] + fn golden_blind_relay_decode_unpair() { + let fixtures = load_fixtures(); + let unpairs: Vec<_> = fixtures.iter().filter(|f| f.typ == "unpair").collect(); + assert!(!unpairs.is_empty(), "no unpair fixtures found"); + + for fix in unpairs { + let raw = from_hex(&fix.hex); + let decoded = decode_unpair_from_slice(&raw) + .unwrap_or_else(|e| panic!("[{}] decode failed: {e}", fix.label)); + + let expected = UnpairMessage { + token: token_from_hex(fix.decoded["token"].as_str().unwrap()), + }; + + assert_eq!(decoded, expected, "[{}] decode mismatch", fix.label); + } + } + + #[test] + fn golden_blind_relay_encode_roundtrip() { + let fixtures = load_fixtures(); + + for fix in &fixtures { + let expected_bytes = from_hex(&fix.hex); + let d = &fix.decoded; + + let encoded = match fix.typ.as_str() { + "pair" => { + let msg = PairMessage { + is_initiator: d["is_initiator"].as_bool().unwrap(), + token: token_from_hex(d["token"].as_str().unwrap()), + id: d["id"].as_u64().unwrap(), + seq: d["seq"].as_u64().unwrap(), + }; + encode_pair_to_vec(&msg) + } + "unpair" => { + let msg = UnpairMessage { + token: token_from_hex(d["token"].as_str().unwrap()), + }; + encode_unpair_to_vec(&msg) + } + other => panic!("[{}] unknown fixture type: {other}", fix.label), + }; + + assert_eq!( + encoded, expected_bytes, + "[{}] encode roundtrip mismatch\n encoded: {}\n expected: {}", + fix.label, + hex::encode(&encoded), + fix.hex, + ); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -482,4 +1358,436 @@ mod tests { client.close(); } + + // ── BlindRelayServer engine (protocol-only) ────────────────────────── + + fn noop_outbound() -> mpsc::UnboundedSender { + let (tx, _rx) = mpsc::unbounded_channel(); + tx + } + + #[test] + fn try_pair_matches_two_sides() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x11; 32]; + + let first = server.try_pair(1, true, token, 100, 0, noop_outbound()); + assert!(matches!(first, PairOutcome::Pending)); + assert_eq!(server.stats().pairings_pending, 1); + + let second = server.try_pair(2, false, token, 200, 0, noop_outbound()); + match second { + PairOutcome::Matched(m) => { + assert_eq!(m.token, token); + assert_eq!(m.first.session_id, 1); + assert!(m.first.is_initiator); + assert_eq!(m.first.client_stream_id, 100); + assert_eq!(m.second.session_id, 2); + assert!(!m.second.is_initiator); + assert_eq!(m.second.client_stream_id, 200); + } + other => panic!("expected Matched, got {other:?}"), + } + + let stats = server.stats(); + assert_eq!(stats.pairings_pending, 0); + assert_eq!(stats.pairings_matched, 1); + assert_eq!(stats.pairings_active, 1); + assert_eq!(stats.pairings_requested, 2); + } + + #[test] + fn try_pair_duplicate_same_slot_is_noop() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x22; 32]; + + assert!(matches!( + server.try_pair(1, true, token, 1, 0, noop_outbound()), + PairOutcome::Pending + )); + // Same session, same slot (initiator), sent again — Node treats + // this as a silent no-op. + assert!(matches!( + server.try_pair(1, true, token, 1, 1, noop_outbound()), + PairOutcome::AlreadyPairing + )); + } + + #[test] + fn try_pair_respects_max_pairings_per_session() { + let config = BlindRelayServerConfig { + max_pairings_per_session: 2, + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + + assert!(matches!( + server.try_pair(1, true, [0x01; 32], 1, 0, noop_outbound()), + PairOutcome::Pending + )); + assert!(matches!( + server.try_pair(1, true, [0x02; 32], 2, 1, noop_outbound()), + PairOutcome::Pending + )); + // Session's 3rd concurrent pairing attempt exceeds the per-session cap. + assert!(matches!( + server.try_pair(1, true, [0x03; 32], 3, 2, noop_outbound()), + PairOutcome::LimitExceeded + )); + } + + #[test] + fn try_accept_session_respects_max_sessions() { + let config = BlindRelayServerConfig { + max_sessions: 1, + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + + let first = server.try_accept_session(); + assert!(first.is_some()); + assert!(server.try_accept_session().is_none()); + + server.release_session(first.unwrap()); + assert!(server.try_accept_session().is_some()); + } + + #[test] + fn unpair_cancels_pending_registration() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x33; 32]; + + server.try_pair(1, true, token, 1, 0, noop_outbound()); + assert_eq!(server.stats().pairings_pending, 1); + + assert_eq!(server.unpair(1, token), UnpairOutcome::Cancelled); + assert_eq!(server.stats().pairings_pending, 0); + assert_eq!(server.stats().pairings_cancelled, 1); + + // Second unpair for the same (now-gone) token finds nothing. + assert_eq!(server.unpair(1, token), UnpairOutcome::NotFound); + } + + #[test] + fn unpair_unknown_token_not_found() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + assert_eq!(server.unpair(1, [0x99; 32]), UnpairOutcome::NotFound); + } + + #[test] + fn release_session_cancels_its_pending_pairings() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x44; 32]; + + let session_id = server.try_accept_session().unwrap(); + server.try_pair(session_id, true, token, 1, 0, noop_outbound()); + assert_eq!(server.stats().pairings_pending, 1); + + server.release_session(session_id); + + assert_eq!(server.stats().pairings_pending, 0); + assert_eq!(server.stats().pairings_cancelled, 1); + assert_eq!(server.stats().sessions_active, 0); + // The token is fully free again for a fresh pairing attempt. + assert!(matches!( + server.try_pair(2, true, token, 1, 0, noop_outbound()), + PairOutcome::Pending + )); + } + + #[test] + fn sweep_expired_pairings_drops_stale_entries() { + let config = BlindRelayServerConfig { + pairing_timeout: Duration::from_millis(1), + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + let token = [0x55; 32]; + + server.try_pair(1, true, token, 1, 0, noop_outbound()); + std::thread::sleep(Duration::from_millis(20)); + + let dropped = server.sweep_expired_pairings(); + assert_eq!(dropped, 1); + assert_eq!(server.stats().pairings_pending, 0); + assert_eq!(server.stats().pairings_cancelled, 1); + + // Free to re-register after the sweep. + assert!(matches!( + server.try_pair(2, true, token, 1, 0, noop_outbound()), + PairOutcome::Pending + )); + } + + #[test] + fn sweep_expired_pairings_keeps_fresh_entries() { + let config = BlindRelayServerConfig { + pairing_timeout: Duration::from_secs(300), + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + server.try_pair(1, true, [0x66; 32], 1, 0, noop_outbound()); + + assert_eq!(server.sweep_expired_pairings(), 0); + assert_eq!(server.stats().pairings_pending, 1); + } + + // ── BlindRelaySession end-to-end (two real BlindRelayClients vs two + // BlindRelaySessions sharing one BlindRelayServer) ───────────────── + + #[tokio::test] + async fn session_end_to_end_pair_match() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let (matched_tx, mut matched_rx) = mpsc::unbounded_channel::(); + + // Client A <-> relay-side session A, over one in-memory pipe. + let (client_a_stream, session_a_stream) = mem_pair(); + let (mux_client_a, run_client_a) = Mux::new(client_a_stream); + let (mux_session_a, run_session_a) = Mux::new(session_a_stream); + tokio::spawn(run_client_a); + tokio::spawn(run_session_a); + + // Client B <-> relay-side session B, over a second in-memory pipe. + let (client_b_stream, session_b_stream) = mem_pair(); + let (mux_client_b, run_client_b) = Mux::new(client_b_stream); + let (mux_session_b, run_session_b) = Mux::new(session_b_stream); + tokio::spawn(run_client_b); + tokio::spawn(run_session_b); + + let channel_a = mux_session_a + .create_channel(PROTOCOL_NAME, None, None) + .await + .unwrap(); + let channel_b = mux_session_b + .create_channel(PROTOCOL_NAME, None, None) + .await + .unwrap(); + + let mut session_a = BlindRelaySession::new(server.clone(), channel_a).unwrap(); + let mut session_b = BlindRelaySession::new(server.clone(), channel_b).unwrap(); + + let matched_tx_a = matched_tx.clone(); + let session_a_task = tokio::spawn(async move { + session_a.run(&matched_tx_a).await; + }); + let session_b_task = tokio::spawn(async move { + session_b.run(&matched_tx).await; + }); + + let token = [0x77; 32]; + + let mut client_a = BlindRelayClient::open(&mux_client_a, None).await.unwrap(); + let mut client_b = BlindRelayClient::open(&mux_client_b, None).await.unwrap(); + client_a.wait_opened().await.unwrap(); + client_b.wait_opened().await.unwrap(); + + // Stand in for `relay_service`: once the match arrives, create the + // (fake, in this test) data-plane streams and reply to both sides + // with the newly-assigned local stream ids. + let wiring_task = tokio::spawn(async move { + let matched = matched_rx.recv().await.unwrap(); + assert_eq!(matched.token, token); + + matched + .first + .outbound_tx + .send(SessionOutbound::PairMatched { + token, + is_initiator: matched.first.is_initiator, + local_stream_id: 9001, + }) + .unwrap(); + matched + .second + .outbound_tx + .send(SessionOutbound::PairMatched { + token, + is_initiator: matched.second.is_initiator, + local_stream_id: 9002, + }) + .unwrap(); + + matched + }); + + let (resp_a, resp_b) = tokio::join!( + client_a.pair(true, &token, 111), + client_b.pair(false, &token, 222), + ); + let resp_a = resp_a.unwrap(); + let resp_b = resp_b.unwrap(); + let matched = wiring_task.await.unwrap(); + + // Each client's reported remote_id is the relay-assigned local + // stream id sent for its own session (9001 for whichever session + // was A, 9002 for whichever was B) — assert both distinct ids were + // actually delivered to the two clients (order depends on which + // pair message the relay processed first). + let ids = [resp_a.remote_id, resp_b.remote_id]; + assert!(ids.contains(&9001) && ids.contains(&9002)); + let _ = matched; + + client_a.close(); + client_b.close(); + session_a_task.abort(); + session_b_task.abort(); + } + + #[test] + fn session_new_respects_max_sessions() { + let config = BlindRelayServerConfig { + max_sessions: 0, + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + + // We don't need a real channel to exercise the capacity check — + // try_accept_session is what BlindRelaySession::new consults first. + assert!(server.try_accept_session().is_none()); + } + + // ── Stream teardown on unpair-of-active / session-close ─────────────── + // (Node precedent: blind-relay's `_onunpair` destroys an already- + // matched stream found in `this._streams`; `_onclose` destroys every + // stream in that map when the session's channel closes.) + + #[test] + fn unpair_on_active_pairing_signals_teardown() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x88; 32]; + let (teardown_tx, mut teardown_rx) = mpsc::unbounded_channel::<()>(); + + // Simulate a matched pairing whose data-plane the caller has + // already wired up (mirrors relay_service::bridge_one_pairing + // calling mark_active after try_pair returned Matched). + server.try_pair(1, true, token, 1, 0, noop_outbound()); + let PairOutcome::Matched(_) = server.try_pair(2, false, token, 2, 0, noop_outbound()) + else { + panic!("expected Matched"); + }; + server.mark_active(token, [1, 2], teardown_tx); + assert_eq!(server.stats().pairings_active, 1); + + // Either session can unpair an active pairing (mirrors Node: the + // lookup is by token in the server-wide `_streams`-equivalent + // table, not scoped to "the session that registered this slot"). + assert_eq!(server.unpair(1, token), UnpairOutcome::Destroyed); + assert!(teardown_rx.try_recv().is_ok(), "teardown signal not sent"); + assert_eq!(server.stats().pairings_active, 0); + + // A second unpair against the same (now-removed) token finds nothing. + assert_eq!(server.unpair(1, token), UnpairOutcome::NotFound); + } + + #[test] + fn release_session_tears_down_its_active_pairings() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token = [0x99; 32]; + let (teardown_tx, mut teardown_rx) = mpsc::unbounded_channel::<()>(); + + let session_a = server.try_accept_session().unwrap(); + let session_b = server.try_accept_session().unwrap(); + server.try_pair(session_a, true, token, 1, 0, noop_outbound()); + server.try_pair(session_b, false, token, 2, 0, noop_outbound()); + server.mark_active(token, [session_a, session_b], teardown_tx); + assert_eq!(server.stats().pairings_active, 1); + + // Releasing *either* session (not just the one that happened to + // register second) tears down the shared pairing. + server.release_session(session_a); + + assert!(teardown_rx.try_recv().is_ok(), "teardown signal not sent"); + assert_eq!(server.stats().pairings_active, 0); + } + + #[test] + fn release_session_only_tears_down_its_own_active_pairings() { + let server = BlindRelayServer::new(BlindRelayServerConfig::default()); + let token_a = [0xaa; 32]; + let token_b = [0xbb; 32]; + let (teardown_a_tx, mut teardown_a_rx) = mpsc::unbounded_channel::<()>(); + let (teardown_b_tx, mut teardown_b_rx) = mpsc::unbounded_channel::<()>(); + + let session_1 = server.try_accept_session().unwrap(); + let session_2 = server.try_accept_session().unwrap(); + let session_3 = server.try_accept_session().unwrap(); + + server.try_pair(session_1, true, token_a, 1, 0, noop_outbound()); + server.try_pair(session_2, false, token_a, 2, 0, noop_outbound()); + server.mark_active(token_a, [session_1, session_2], teardown_a_tx); + + server.try_pair(session_2, true, token_b, 3, 1, noop_outbound()); + server.try_pair(session_3, false, token_b, 4, 0, noop_outbound()); + server.mark_active(token_b, [session_2, session_3], teardown_b_tx); + + assert_eq!(server.stats().pairings_active, 2); + + // Releasing session_1 only affects token_a's pairing (session_1 + // wasn't part of token_b's pairing). + server.release_session(session_1); + + assert!(teardown_a_rx.try_recv().is_ok()); + assert!(teardown_b_rx.try_recv().is_err()); + assert_eq!(server.stats().pairings_active, 1); + } + + // ── Idle-session-timeout (peeroxide-only, no Node precedent) ────────── + + #[test] + fn sweep_idle_sessions_closes_sessions_past_timeout() { + let config = BlindRelayServerConfig { + idle_session_timeout: Duration::from_millis(1), + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + let session_id = server.try_accept_session().unwrap(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel(); + server.register_session(session_id, outbound_tx); + + std::thread::sleep(Duration::from_millis(20)); + + let closed = server.sweep_idle_sessions(); + assert_eq!(closed, vec![session_id]); + assert!(matches!( + outbound_rx.try_recv(), + Ok(SessionOutbound::Close) + )); + } + + #[test] + fn sweep_idle_sessions_keeps_recently_active_sessions() { + let config = BlindRelayServerConfig { + idle_session_timeout: Duration::from_secs(300), + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + let session_id = server.try_accept_session().unwrap(); + let (outbound_tx, _outbound_rx) = mpsc::unbounded_channel(); + server.register_session(session_id, outbound_tx); + + assert_eq!(server.sweep_idle_sessions(), Vec::::new()); + } + + #[test] + fn pair_and_unpair_reset_idle_clock() { + let config = BlindRelayServerConfig { + idle_session_timeout: Duration::from_millis(50), + ..BlindRelayServerConfig::default() + }; + let server = BlindRelayServer::new(config); + let session_id = server.try_accept_session().unwrap(); + let (outbound_tx, _outbound_rx) = mpsc::unbounded_channel(); + server.register_session(session_id, outbound_tx); + + std::thread::sleep(Duration::from_millis(30)); + // Activity within the timeout window resets the clock. + server.try_pair(session_id, true, [0x77; 32], 1, 0, noop_outbound()); + std::thread::sleep(Duration::from_millis(30)); + + assert_eq!( + server.sweep_idle_sessions(), + Vec::::new(), + "recent pair() activity should have reset the idle clock" + ); + } } diff --git a/peeroxide-dht/src/compact_encoding.rs b/peeroxide-dht/src/compact_encoding.rs index 0765742..6a654e1 100644 --- a/peeroxide-dht/src/compact_encoding.rs +++ b/peeroxide-dht/src/compact_encoding.rs @@ -1,3 +1,6 @@ +#![allow(missing_docs)] +#![allow(dead_code)] + use thiserror::Error; /// Errors returned by compact encoding operations diff --git a/peeroxide-dht/src/compact_encoding/tests.rs b/peeroxide-dht/src/compact_encoding/tests.rs index deca31f..7353b3f 100644 --- a/peeroxide-dht/src/compact_encoding/tests.rs +++ b/peeroxide-dht/src/compact_encoding/tests.rs @@ -363,3 +363,472 @@ trait BorrowMut { } impl BorrowMut for State {} + +mod golden_interop { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct GoldenFile { + #[allow(dead_code)] + generated_by: String, + #[allow(dead_code)] + version: String, + fixtures: Vec, + } + + #[derive(Deserialize)] + struct Fixture { + #[serde(rename = "type")] + typ: String, + label: String, + value: serde_json::Value, + hex: String, + } + + fn load_fixtures() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/interop/golden-fixtures.json" + ); + let data = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("Failed to read golden fixtures at {path}: {e}. Run `node generate-golden.js` in tests/node/ first.")); + let file: GoldenFile = serde_json::from_str(&data) + .unwrap_or_else(|e| panic!("Failed to parse golden fixtures: {e}")); + file.fixtures + } + + fn expected_bytes(hex_str: &str) -> Vec { + hex::decode(hex_str).unwrap_or_else(|e| panic!("Invalid hex '{hex_str}': {e}")) + } + + fn encode_with_pre(pre_fn: impl FnOnce(&mut State), enc_fn: impl FnOnce(&mut State)) -> Vec { + let mut state = State::new(); + pre_fn(&mut state); + state.alloc(); + enc_fn(&mut state); + state.buffer + } + + fn val_u64(v: &serde_json::Value) -> u64 { + match v { + serde_json::Value::Number(n) => n.as_u64().unwrap(), + serde_json::Value::String(s) => s.parse::().unwrap(), + _ => panic!("Expected number or string for u64, got {v:?}"), + } + } + + fn val_i64(v: &serde_json::Value) -> i64 { + match v { + serde_json::Value::Number(n) => n.as_i64().unwrap(), + serde_json::Value::String(s) => s.parse::().unwrap(), + _ => panic!("Expected number or string for i64, got {v:?}"), + } + } + + fn val_f64(v: &serde_json::Value) -> f64 { + v.as_f64().unwrap() + } + + fn val_f32(v: &serde_json::Value) -> f32 { + v.as_f64().unwrap() as f32 + } + + fn val_str(v: &serde_json::Value) -> &str { + v.as_str().unwrap() + } + + fn val_bool(v: &serde_json::Value) -> bool { + v.as_bool().unwrap() + } + + #[test] + fn golden_uint8() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint8") { + let val = val_u64(&f.value) as u8; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint8(s, val), |s| encode_uint8(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint8(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}: expected {val}, got {decoded}", f.label); + } + } + + #[test] + fn golden_uint16() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint16") { + let val = val_u64(&f.value) as u16; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint16(s, val), |s| encode_uint16(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint16(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_uint24() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint24") { + let val = val_u64(&f.value) as u32; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint24(s, val), |s| encode_uint24(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint24(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_uint32() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint32") { + let val = val_u64(&f.value) as u32; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint32(s, val), |s| encode_uint32(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint32(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_uint64() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint64") { + let val = val_u64(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint64(s, val), |s| encode_uint64(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint64(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_uint_varint() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint") { + let val = val_u64(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_uint(s, val), |s| encode_uint(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_int_zigzag() { + for f in load_fixtures().iter().filter(|f| f.typ == "int") { + let val = val_i64(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_int(s, val), |s| encode_int(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_int(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_int8() { + for f in load_fixtures().iter().filter(|f| f.typ == "int8") { + let val = val_i64(&f.value) as i8; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_int8(s, val), |s| encode_int8(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_int8(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_int16() { + for f in load_fixtures().iter().filter(|f| f.typ == "int16") { + let val = val_i64(&f.value) as i16; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_int16(s, val), |s| encode_int16(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_int16(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_int32() { + for f in load_fixtures().iter().filter(|f| f.typ == "int32") { + let val = val_i64(&f.value) as i32; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_int32(s, val), |s| encode_int32(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_int32(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_int64() { + for f in load_fixtures().iter().filter(|f| f.typ == "int64") { + let val = val_i64(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_int64(s, val), |s| encode_int64(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_int64(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_float32() { + for f in load_fixtures().iter().filter(|f| f.typ == "float32") { + let val = val_f32(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_float32(s, val), |s| encode_float32(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_float32(&mut state).unwrap(); + assert_eq!(decoded.to_bits(), val.to_bits(), "DECODE {}", f.label); + } + } + + #[test] + fn golden_float64() { + for f in load_fixtures().iter().filter(|f| f.typ == "float64") { + let val = val_f64(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_float64(s, val), |s| encode_float64(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_float64(&mut state).unwrap(); + assert_eq!(decoded.to_bits(), val.to_bits(), "DECODE {}", f.label); + } + } + + #[test] + fn golden_bool() { + for f in load_fixtures().iter().filter(|f| f.typ == "bool") { + let val = val_bool(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_bool(s, val), |s| encode_bool(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_bool(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_buffer() { + for f in load_fixtures().iter().filter(|f| f.typ == "buffer") { + let expected = expected_bytes(&f.hex); + + let val: Option> = if f.value.is_null() { + None + } else { + let hex_str = val_str(&f.value); + if hex_str.is_empty() { + None + } else { + Some(hex::decode(hex_str).unwrap()) + } + }; + + let val_slice: Option<&[u8]> = val.as_deref(); + let encoded = encode_with_pre( + |s| preencode_buffer(s, val_slice), + |s| encode_buffer(s, val_slice), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_buffer(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_string() { + for f in load_fixtures().iter().filter(|f| f.typ == "string") { + let val = val_str(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre(|s| preencode_string(s, val), |s| encode_string(s, val)); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_string(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_fixed32() { + for f in load_fixtures().iter().filter(|f| f.typ == "fixed32") { + let val_vec = hex::decode(val_str(&f.value)).unwrap(); + let val: [u8; 32] = val_vec.try_into().unwrap(); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| { preencode_fixed32(s, &val).unwrap(); }, + |s| encode_fixed32(s, &val), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_fixed32(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_fixed64() { + for f in load_fixtures().iter().filter(|f| f.typ == "fixed64") { + let val_vec = hex::decode(val_str(&f.value)).unwrap(); + let val: [u8; 64] = val_vec.try_into().unwrap(); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| { preencode_fixed64(s, &val).unwrap(); }, + |s| encode_fixed64(s, &val), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_fixed64(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_ipv4() { + for f in load_fixtures().iter().filter(|f| f.typ == "ipv4") { + let val = val_str(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_ipv4(s, val), + |s| encode_ipv4(s, val).unwrap(), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_ipv4(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_ipv6() { + for f in load_fixtures().iter().filter(|f| f.typ == "ipv6") { + let val = val_str(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_ipv6(s, val), + |s| encode_ipv6(s, val).unwrap(), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_ipv6(&mut state).unwrap(); + let expected_addr: std::net::Ipv6Addr = val.parse().unwrap(); + let decoded_addr: std::net::Ipv6Addr = decoded.parse().unwrap(); + assert_eq!(decoded_addr, expected_addr, "DECODE {}: expected {expected_addr}, got {decoded_addr}", f.label); + } + } + + #[test] + fn golden_ip_dual() { + for f in load_fixtures().iter().filter(|f| f.typ == "ip") { + let val = val_str(&f.value); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_ip(s, val), + |s| encode_ip(s, val).unwrap(), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_ip(&mut state).unwrap(); + + if val.contains(':') { + let expected_addr: std::net::Ipv6Addr = val.parse().unwrap(); + let decoded_addr: std::net::Ipv6Addr = decoded.parse().unwrap(); + assert_eq!(decoded_addr, expected_addr, "DECODE {}", f.label); + } else { + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + } + + #[test] + fn golden_ipv4_address() { + for f in load_fixtures().iter().filter(|f| f.typ == "ipv4Address") { + let obj = f.value.as_object().unwrap(); + let host = obj["host"].as_str().unwrap(); + let port = obj["port"].as_u64().unwrap() as u16; + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_ipv4_address(s, host, port), + |s| encode_ipv4_address(s, host, port).unwrap(), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let (decoded_host, decoded_port) = decode_ipv4_address(&mut state).unwrap(); + assert_eq!(decoded_host, host, "DECODE host {}", f.label); + assert_eq!(decoded_port, port, "DECODE port {}", f.label); + } + } + + #[test] + fn golden_uint_array() { + for f in load_fixtures().iter().filter(|f| f.typ == "uint_array") { + let val: Vec = f.value.as_array().unwrap().iter().map(val_u64).collect(); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_uint_array(s, &val), + |s| encode_uint_array(s, &val), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_uint_array(&mut state).unwrap(); + assert_eq!(decoded, val, "DECODE {}", f.label); + } + } + + #[test] + fn golden_string_array() { + for f in load_fixtures().iter().filter(|f| f.typ == "string_array") { + let val: Vec<&str> = f.value.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + let expected = expected_bytes(&f.hex); + let encoded = encode_with_pre( + |s| preencode_string_array(s, &val), + |s| encode_string_array(s, &val), + ); + assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); + + let mut state = State::from_buffer(&expected); + let decoded = decode_string_array(&mut state).unwrap(); + let val_owned: Vec = val.iter().map(|s| s.to_string()).collect(); + assert_eq!(decoded, val_owned, "DECODE {}", f.label); + } + } +} diff --git a/peeroxide-dht/src/holepuncher.rs b/peeroxide-dht/src/holepuncher.rs index 19e1abb..82d07f8 100644 --- a/peeroxide-dht/src/holepuncher.rs +++ b/peeroxide-dht/src/holepuncher.rs @@ -1,12 +1,35 @@ +//! NAT hole-punching coordination for peer-to-peer connections. +//! +//! The `Holepuncher` drives the active-side hole-punching state machine +//! used by [`crate::hyperdht::HyperDhtHandle::connect`] to traverse symmetric +//! NATs and reach peers that cannot be dialed directly. It owns the local +//! [`crate::socket_pool::SocketPool`] used for birthday-attack probes, the +//! NAT classification analyzer, and the punch-message dispatch loop that +//! cooperates with the remote peer over a relayed control channel. +//! +//! Most consumers reach this module indirectly through `connect()` and the +//! resulting [`crate::hyperdht::PeerConnection`]; direct use is only needed +//! when building custom DHT clients that orchestrate the punch sequence +//! themselves. See `hyperdht/lib/holepuncher.js` in the Node.js reference +//! implementation for the protocol shape. + +#![allow(missing_docs)] + use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use libudx::UdxSocket; use tokio::sync::mpsc; +use tokio::task::JoinHandle; use tokio::time::{sleep, Duration}; use crate::hyperdht_messages::{FIREWALL_CONSISTENT, FIREWALL_RANDOM, FIREWALL_UNKNOWN}; use crate::messages::Ipv4Peer; use crate::nat::Nat; -use crate::socket_pool::{coerce_firewall, random_port, SocketPool, SocketRef}; +use crate::socket_pool::{ + coerce_firewall, random_port, HolepunchEvent as ProbeEvent, SocketPool, SocketRef, +}; const BIRTHDAY_SOCKETS: usize = 256; @@ -25,7 +48,7 @@ pub struct RemoteAddress { } pub struct Holepuncher { - pub nat: Nat, + pub(crate) nat: Nat, pub is_initiator: bool, pub punching: bool, pub connected: bool, @@ -37,6 +60,18 @@ pub struct Holepuncher { sockets: Vec, event_tx: mpsc::UnboundedSender, + + /// Shared with the recv-adapter task; the initiator branch CAS-flips + /// this on the first inbound probe so that subsequent probes are + /// idempotent no-ops. Also consulted by `destroy()` so an Aborted + /// event isn't emitted on top of a Connected one. + connected_flag: Arc, + + /// Handles to every spawned recv-adapter task: one per socket in + /// `self.sockets` (the primary socket plus every birthday-attack + /// socket opened by `open_birthday_sockets`). `destroy()` aborts all + /// of them so cleanup is bounded and no task outlives the Holepuncher. + recv_tasks: Vec>, } impl Holepuncher { @@ -48,7 +83,15 @@ impl Holepuncher { remote_firewall: u64, event_tx: mpsc::UnboundedSender, ) -> Result { - let socket = pool.acquire(runtime).await?; + let mut socket = pool.acquire(runtime).await?; + + let connected_flag = Arc::new(AtomicBool::new(false)); + let recv_task = spawn_recv_adapter_for_socket( + &mut socket, + is_initiator, + event_tx.clone(), + Arc::clone(&connected_flag), + ); Ok(Self { nat: Nat::new(firewalled), @@ -61,6 +104,8 @@ impl Holepuncher { remote_holepunching: false, sockets: vec![socket], event_tx, + connected_flag, + recv_tasks: recv_task.into_iter().collect(), }) } @@ -88,16 +133,116 @@ impl Holepuncher { self.sockets.first() } + /// Enumerate IPv4 addresses of local network interfaces, paired with + /// the supplied port, for advertising to remote peers. Mirrors Node + /// `hyperdht/lib/holepuncher.js Holepuncher.localAddresses` — keeps + /// only `family === 4 && !internal` (i.e. skips loopback). Falls back + /// to `[127.0.0.1:port]` when no usable interface is found. + pub fn local_addresses(port: u16) -> Vec { + let mut out = Vec::new(); + if let Ok(ifs) = if_addrs::get_if_addrs() { + for iface in ifs { + if iface.is_loopback() { + continue; + } + if let std::net::IpAddr::V4(v4) = iface.ip() { + out.push(Ipv4Peer { + host: v4.to_string(), + port, + }); + } + } + } + if out.is_empty() { + out.push(Ipv4Peer { + host: "127.0.0.1".to_string(), + port, + }); + } + out + } + + /// Return the address list to advertise as the holepunch payload's + /// `addresses` field. Combines: + /// + /// 1. Reflexive samples accumulated by [`Self::auto_sample`] in + /// `self.nat.addresses` (the Node `addresses` source per + /// `hyperdht/lib/holepuncher.js:221-227`). These let off-host + /// peers punch through their respective NATs. + /// 2. Local IPv4 interface addresses from [`Self::local_addresses`], + /// paired with `fallback_port`. These let same-host and same-LAN + /// peers reach us directly without requiring NAT hairpin. + /// + /// The reflexive samples come first because off-host punching is the + /// hot path; the LAN entries serve as a hairpin-free fallback that + /// Node achieves via its receiver-side `clientAddress.host === + /// serverAddress.host` shortcut (which we do not yet implement). + /// + /// If autoSample produced nothing, only the LAN entries are returned + /// (which itself falls back to `[127.0.0.1:port]` if no usable + /// interface is found). + pub fn punch_addresses(&self, fallback_port: u16) -> Vec { + Self::select_punch_addresses(self.nat.addresses.as_ref(), fallback_port) + } + + pub(crate) fn select_punch_addresses( + nat_addresses: Option<&Vec>, + fallback_port: u16, + ) -> Vec { + let mut out = Vec::new(); + if let Some(addrs) = nat_addresses { + out.extend(addrs.iter().cloned()); + } + out.extend(Self::local_addresses(fallback_port)); + out + } + + /// Seed `self.nat` with reflexive samples from the puncher socket's + /// own NAT mapping by sending DHT pings via `dht.ping_via_socket(...)`. + /// Mirrors Node `lib/holepuncher.js:13-20` + `lib/nat.js:25-79`. Must + /// run BEFORE [`Self::punch`] or the NAT classification stays + /// `UNKNOWN` and no punch strategy fires (the + /// `coerce_firewall(UNKNOWN→CONSISTENT)` MVP lie that this method + /// replaces; see commit history). + /// + /// Returns the number of new samples added. No-op (returns 0) if the + /// puncher has no primary socket or its `dht_reply_rx` has already + /// been consumed. + pub async fn auto_sample(&mut self, dht: &crate::rpc::DhtHandle) -> usize { + let (socket_clone, rx) = { + let Some(socket_ref) = self.sockets.first_mut() else { + return 0; + }; + let Some(rx) = socket_ref.dht_reply_rx.take() else { + return 0; + }; + (socket_ref.socket.clone(), rx) + }; + self.nat + .auto_sample(dht, socket_clone, rx, crate::nat::NAT_MIN_SAMPLES as usize) + .await + } + fn unstable(&self) -> bool { let fw = self.nat.firewall; (self.remote_firewall >= FIREWALL_RANDOM && fw >= FIREWALL_RANDOM) || fw == FIREWALL_UNKNOWN } - pub async fn analyze(&mut self, _allow_reopen: bool) -> bool { - // NAT analysis is driven by add() calls from ping responses. - // In a full implementation, we'd await nat.analyzing here. - // For now, check current state. + pub async fn analyze(&mut self, allow_reopen: bool) -> bool { + self.nat.analyzing().await; + if !self.unstable() { + return true; + } + if !allow_reopen { + return false; + } + // Phase 3 MVP divergence from Node `_reopen()`: we do not destroy + // the NAT and rebuild the socket pool. We give the sampler a + // brief grace window and re-check `unstable()`. The outer probe + // loop in `run_holepunch_rounds` provides retry coverage; full + // reopen is a future extension if real-world peers need it. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; !self.unstable() } @@ -216,8 +361,19 @@ impl Holepuncher { while self.punching && self.sockets.len() < BIRTHDAY_SOCKETS { match pool.acquire(runtime).await { - Ok(socket) => { + Ok(mut socket) => { let _ = socket.send_holepunch(target, true); + // Cover this birthday socket's probe stream with its own + // recv-adapter task so a probe landing on it (rather than + // the primary socket) still fires `Connected`. + if let Some(handle) = spawn_recv_adapter_for_socket( + &mut socket, + self.is_initiator, + self.event_tx.clone(), + Arc::clone(&self.connected_flag), + ) { + self.recv_tasks.push(handle); + } self.sockets.push(socket); } Err(_) => break, @@ -259,6 +415,10 @@ impl Holepuncher { self.auto_destroy(); } + /// **Note:** This method is retained for API compatibility but is not + /// called by the production recv path. The internal recv-adapter task + /// (spawned in `Holepuncher::new`) implements equivalent semantics with + /// interior mutability via `connected_flag: Arc`. pub fn on_holepunch_message(&mut self, addr: SocketAddr, socket_idx: usize) { if !self.is_initiator { if let Some(socket) = self.sockets.get(socket_idx) { @@ -296,12 +456,76 @@ impl Holepuncher { self.sockets.clear(); self.nat.destroy(); - if !self.connected { + for h in self.recv_tasks.drain(..) { + h.abort(); + } + + if !self.connected && !self.connected_flag.load(Ordering::Acquire) { let _ = self.event_tx.send(HolepunchEvent::Aborted); } } } +/// Take ownership of `socket_ref`'s holepunch-probe receiver and spawn a +/// [`run_recv_adapter`] task for it, sharing the same `connected_flag` and +/// `event_tx` as every other socket belonging to the same [`Holepuncher`]. +/// Every socket that can receive a `PEER_HOLEPUNCH` probe — the primary +/// socket acquired in `Holepuncher::new`, and each birthday-attack socket +/// opened by `open_birthday_sockets` — must be covered this way, or a probe +/// landing on an uncovered socket will silently never fire `Connected`. +/// +/// Returns `None` (spawning nothing) if `socket_ref`'s `holepunch_rx` has +/// already been taken; this should not happen for a freshly acquired +/// `SocketRef` but is handled defensively rather than panicking. +fn spawn_recv_adapter_for_socket( + socket_ref: &mut SocketRef, + is_initiator: bool, + event_tx: mpsc::UnboundedSender, + connected_flag: Arc, +) -> Option> { + let hp_rx = socket_ref.take_holepunch_rx()?; + let socket_clone = socket_ref.socket.clone(); + Some(tokio::spawn(run_recv_adapter( + hp_rx, + socket_clone, + is_initiator, + event_tx, + connected_flag, + ))) +} + +async fn run_recv_adapter( + mut hp_rx: mpsc::UnboundedReceiver, + socket: UdxSocket, + is_initiator: bool, + event_tx: mpsc::UnboundedSender, + connected_flag: Arc, +) { + while let Some(probe) = hp_rx.recv().await { + if is_initiator { + if connected_flag + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + tracing::info!( + target: "peeroxide::_events::holepunch::probe_received", + addr = %probe.addr, + "holepuncher(initiator): probe received, firing Connected" + ); + let _ = event_tx.send(HolepunchEvent::Connected { addr: probe.addr }); + return; + } + } else { + tracing::info!( + target: "peeroxide::_events::holepunch::passive_reflected", + addr = %probe.addr, + "holepuncher(passive): probe received, reflecting" + ); + let _ = socket.send_to(&[0u8], probe.addr); + } + } +} + pub fn match_address(local_addresses: &[Ipv4Peer], remote_local_addresses: &[Ipv4Peer]) -> Option { if remote_local_addresses.is_empty() { return None; @@ -404,3 +628,250 @@ mod tests { assert_eq!(result.host, "10.1.2.3"); } } + +#[cfg(test)] +mod recv_adapter_tests { + use super::*; + use libudx::UdxRuntime; + use std::time::Duration; + use tokio::time::timeout; + + #[tokio::test] + async fn recv_adapter_initiator_emits_connected_on_first_probe() { + let runtime = UdxRuntime::new().expect("runtime"); + let pool = SocketPool::new("127.0.0.1".to_string()); + + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + + let puncher = Holepuncher::new( + &pool, + &runtime, + true, + true, + FIREWALL_CONSISTENT, + event_tx, + ) + .await + .expect("puncher"); + + let primary = puncher.primary_socket().expect("primary socket"); + let target_addr = primary.socket.local_addr().await.expect("local_addr"); + + let probe_socket = pool + .acquire(&runtime) + .await + .expect("acquire") + .socket + .clone(); + let probe_local = probe_socket.local_addr().await.expect("probe local_addr"); + probe_socket + .send_to(&[0u8], target_addr) + .expect("send probe"); + + let evt = timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("Connected within 2s") + .expect("event_rx open"); + match evt { + HolepunchEvent::Connected { addr } => { + assert_eq!(addr, probe_local, "Connected.addr matches probe source"); + } + _ => panic!("expected Connected, got something else"), + } + + probe_socket + .send_to(&[0u8], target_addr) + .expect("send second probe"); + let evt2 = timeout(Duration::from_millis(500), event_rx.recv()).await; + assert!( + evt2.is_err(), + "second probe should not emit a second event" + ); + } + + /// Regression test for the birthday-socket recv-adapter coverage gap: + /// previously only the primary socket's probe stream was watched, so a + /// probe landing on a birthday-attack socket (opened by + /// `open_birthday_sockets` for RANDOM-class NAT punching) never fired + /// `Connected`. Registers a second socket exactly as + /// `open_birthday_sockets` does and confirms a probe delivered to *that* + /// socket (not the primary) still resolves the initiator's Connected + /// event. + #[tokio::test] + async fn recv_adapter_covers_birthday_sockets() { + let runtime = UdxRuntime::new().expect("runtime"); + let pool = SocketPool::new("127.0.0.1".to_string()); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + + let mut puncher = Holepuncher::new( + &pool, + &runtime, + true, + true, + FIREWALL_RANDOM, + event_tx, + ) + .await + .expect("puncher"); + + // Register a second ("birthday") socket exactly as + // `open_birthday_sockets` would, without running the full punch loop. + let mut extra = pool.acquire(&runtime).await.expect("acquire extra socket"); + let extra_addr = extra.socket.local_addr().await.expect("extra local_addr"); + if let Some(handle) = spawn_recv_adapter_for_socket( + &mut extra, + puncher.is_initiator, + puncher.event_tx.clone(), + Arc::clone(&puncher.connected_flag), + ) { + puncher.recv_tasks.push(handle); + } + puncher.sockets.push(extra); + assert_eq!(puncher.sockets.len(), 2, "primary + one birthday socket"); + + // Send a probe to the SECOND (birthday) socket, not the primary one. + let probe_socket = pool + .acquire(&runtime) + .await + .expect("acquire probe socket") + .socket + .clone(); + let probe_local = probe_socket.local_addr().await.expect("probe local_addr"); + probe_socket + .send_to(&[0u8], extra_addr) + .expect("send probe to birthday socket"); + + let evt = timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("Connected within 2s") + .expect("event_rx open"); + match evt { + HolepunchEvent::Connected { addr } => { + assert_eq!( + addr, probe_local, + "Connected fires from a probe landing on the birthday socket" + ); + } + _ => panic!("expected Connected, got something else"), + } + } + + #[tokio::test] + async fn recv_adapter_passive_reflects_probe() { + let runtime = UdxRuntime::new().expect("runtime"); + let pool = SocketPool::new("127.0.0.1".to_string()); + + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + + let puncher = Holepuncher::new( + &pool, + &runtime, + true, + false, + FIREWALL_CONSISTENT, + event_tx, + ) + .await + .expect("puncher"); + + let primary = puncher.primary_socket().expect("primary socket"); + let puncher_addr = primary.socket.local_addr().await.expect("local_addr"); + + let mut probe_socket_ref = pool.acquire(&runtime).await.expect("acquire"); + let probe_socket = probe_socket_ref.socket.clone(); + let mut probe_hp_rx = probe_socket_ref.take_holepunch_rx().expect("rx"); + + probe_socket + .send_to(&[0u8], puncher_addr) + .expect("send probe"); + + let reflection = timeout(Duration::from_secs(2), probe_hp_rx.recv()) + .await + .expect("reflection within 2s") + .expect("rx open"); + + assert_eq!( + reflection.addr, puncher_addr, + "reflection comes from puncher's address" + ); + } + + #[test] + fn local_addresses_assigns_input_port_to_every_entry() { + let addrs = Holepuncher::local_addresses(49737); + assert!(!addrs.is_empty(), "must return at least loopback fallback"); + for a in &addrs { + assert_eq!(a.port, 49737, "every entry uses the supplied port"); + } + } + + #[test] + fn local_addresses_excludes_loopback_when_other_ifaces_exist() { + // On any host with a real network interface (CI runners, dev boxes), + // the result must NOT contain only loopback. If only loopback is + // returned, the host literally has no non-internal IPv4 interface + // and the fallback path was taken — that's allowed but documented. + let addrs = Holepuncher::local_addresses(1234); + let has_real = addrs.iter().any(|a| a.host != "127.0.0.1"); + let only_loopback = !has_real; + // We can't *force* a real interface to exist in CI, but we can at + // least assert the function emits non-loopback when one is present. + // If only_loopback, document that path was taken (test still passes). + if only_loopback { + assert_eq!(addrs.len(), 1, "loopback fallback must be exactly one entry"); + assert_eq!(addrs[0].host, "127.0.0.1"); + } else { + assert!( + addrs.iter().all(|a| a.host != "127.0.0.1"), + "when real interfaces exist, loopback must be excluded entirely" + ); + } + } + + #[test] + fn local_addresses_returns_only_ipv4_strings() { + let addrs = Holepuncher::local_addresses(0); + for a in &addrs { + let parsed: std::net::Ipv4Addr = a.host.parse().expect("must parse as IPv4"); + assert_eq!(parsed.to_string(), a.host, "canonical form expected"); + } + } + + #[test] + fn local_addresses_fallback_is_loopback_zero_port_ok() { + // The fallback path uses port=0 if requested; verify shape. + let addrs = Holepuncher::local_addresses(0); + assert!(!addrs.is_empty()); + for a in &addrs { + assert_eq!(a.port, 0); + } + } + + #[test] + fn select_punch_addresses_prepends_autosample_then_appends_local() { + let nat_addrs = vec![ + Ipv4Peer { host: "47.197.162.13".into(), port: 49737 }, + Ipv4Peer { host: "47.197.162.13".into(), port: 49738 }, + ]; + let result = Holepuncher::select_punch_addresses(Some(&nat_addrs), 1234); + let local = Holepuncher::local_addresses(1234); + // First N entries are reflexive (in order), trailing entries are local. + assert_eq!(result[..2], nat_addrs[..], "reflexive entries come first"); + assert_eq!(result[2..], local[..], "local entries appended after"); + } + + #[test] + fn select_punch_addresses_falls_back_to_local_when_autosample_empty() { + let empty: Vec = Vec::new(); + let result = Holepuncher::select_punch_addresses(Some(&empty), 1234); + let expected = Holepuncher::local_addresses(1234); + assert_eq!(result, expected, "empty nat.addresses → local only"); + } + + #[test] + fn select_punch_addresses_falls_back_to_local_when_autosample_none() { + let result = Holepuncher::select_punch_addresses(None, 5678); + let expected = Holepuncher::local_addresses(5678); + assert_eq!(result, expected, "None nat.addresses → local only"); + } +} diff --git a/peeroxide-dht/src/hyperdht.rs b/peeroxide-dht/src/hyperdht.rs index 4b3dc8d..7ce73f5 100644 --- a/peeroxide-dht/src/hyperdht.rs +++ b/peeroxide-dht/src/hyperdht.rs @@ -25,8 +25,9 @@ use crate::hyperdht_messages::{ encode_announce_to_bytes, encode_hyper_peer_to_bytes, encode_mutable_put_request_to_bytes, AnnounceMessage, HandshakeMessage, HolepunchMessage, HolepunchPayload, HyperPeer, MutablePutRequest, NoisePayload, RelayThroughInfo, - SecretStreamInfo, UdxInfo, ANNOUNCE, FIND_PEER, FIREWALL_OPEN, FIREWALL_UNKNOWN, IMMUTABLE_GET, - IMMUTABLE_PUT, LOOKUP, MUTABLE_GET, MUTABLE_PUT, PEER_HANDSHAKE, PEER_HOLEPUNCH, UNANNOUNCE, + SecretStreamInfo, UdxInfo, ANNOUNCE, ERROR_ABORTED, ERROR_NONE, ERROR_TRY_LATER, FIND_PEER, + FIREWALL_OPEN, FIREWALL_UNKNOWN, IMMUTABLE_GET, IMMUTABLE_PUT, LOOKUP, MUTABLE_GET, + MUTABLE_PUT, PEER_HANDSHAKE, PEER_HOLEPUNCH, UNANNOUNCE, }; use crate::messages::Ipv4Peer; use crate::noise::Keypair as NoiseKeypair; @@ -51,6 +52,18 @@ fn next_stream_id() -> u32 { NEXT_STREAM_ID.fetch_add(1, Ordering::Relaxed) } +/// Allocate a fresh, process-wide-unique local UDX stream id. +/// +/// Exposed so callers outside this crate that reuse a `HyperDhtHandle`'s +/// connections (e.g. `peeroxide::swarm`'s relay-through server path) can +/// mint additional stream ids from the *same* counter this module uses +/// internally for control connections — using a separately-counted id +/// (e.g. a crate-local counter starting at 1 again) risks colliding with +/// an id this crate already registered on a shared, reused socket. +pub fn alloc_stream_id() -> u32 { + next_stream_id() +} + /// Matches Node.js `isBogon` from the `bogon` package — returns true for /// loopback, link-local, private RFC-1918, and other reserved ranges. fn is_addr_private(host: &str) -> bool { @@ -160,8 +173,21 @@ pub enum ServerEvent { PeerHandshake { /// The decoded handshake message. msg: HandshakeMessage, - /// Address of the peer that sent the request. + /// Address of the peer that sent the request to us. + /// + /// For a direct (non-forwarded) handshake, this is the remote peer. + /// For a relayed handshake, this is the forwarding DHT node (the + /// previous hop), NOT the originating peer; use `peer_address` for + /// the originating peer's address in that case. from: Ipv4Peer, + /// Address of the originating peer for a forwarded handshake, or + /// `None` for a direct handshake. + /// + /// Populated from the relay-layer's `peer_address` payload field. + /// Mirrors Node Hyperswarm's "peerAddress" semantics in + /// `lib/router.js`. Servers should prefer this over `from` when + /// choosing a remote address to dial. + peer_address: Option, /// Optional DHT target associated with the request. target: Option, /// Reply channel for the generated response. @@ -293,7 +319,7 @@ pub struct MutableGetResult { pub from: Ipv4Peer, } -#[derive(Debug, Clone)] +#[derive(Clone)] /// Metadata needed to establish a peer connection. #[non_exhaustive] pub struct ConnectResult { @@ -311,6 +337,27 @@ pub struct ConnectResult { pub local_stream_id: u32, /// Remote UDX metadata advertised by the peer. pub remote_udx: Option, + /// Clone of the puncher's primary UDX socket when holepunching lands. + /// + /// When set by `run_holepunch_rounds`' `Connected` arm in a follow-up + /// commit, this is the socket whose punched 4-tuple is established. + /// Consumers should prefer it over the DHT server socket when present. + pub udx_socket: Option, +} + +impl fmt::Debug for ConnectResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ConnectResult") + .field("remote_public_key", &self.remote_public_key) + .field("server_address", &self.server_address) + .field("client_address", &self.client_address) + .field("is_relayed", &self.is_relayed) + .field("noise", &self.noise) + .field("local_stream_id", &self.local_stream_id) + .field("remote_udx", &self.remote_udx) + .field("udx_socket", &self.udx_socket.as_ref().map(|_| "Some(UdxSocket)")) + .finish() + } } /// Established encrypted connection to a peer. @@ -407,6 +454,28 @@ pub const DEFAULT_BOOTSTRAP: [&str; 3] = [ // ── Config ──────────────────────────────────────────────────────────────────── +/// Per-connect options for [`HyperDhtHandle::connect_with_options`]. +/// +/// Default: same-NAT LAN-shortcut enabled (matches Node hyperdht's +/// `opts.localConnection` default of `true`). +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ConnectOpts { + /// Allow the same-NAT LAN-shortcut. When `false`, the receiver + /// ignores any loopback/private addresses the server advertised in + /// its `addresses4` and dials only the public address. Set this to + /// `false` to force the real-network code path under test. + pub local_connection: bool, +} + +impl Default for ConnectOpts { + fn default() -> Self { + Self { + local_connection: true, + } + } +} + #[derive(Debug, Clone, Default)] /// Configuration for a HyperDHT instance. #[non_exhaustive] @@ -449,6 +518,7 @@ pub struct HyperDhtHandle { router: Arc>, server_tx: mpsc::UnboundedSender, admin_tx: mpsc::UnboundedSender, + current_relay_addresses: Arc>>, } impl HyperDhtHandle { @@ -525,6 +595,7 @@ impl HyperDhtHandle { .await?; let mut closest_nodes = Vec::new(); + let mut accepted_relays: Vec = Vec::new(); for reply in &replies { closest_nodes.push(reply.from.clone()); @@ -538,13 +609,19 @@ impl HyperDhtHandle { None => continue, }; + // Stored record uses caller-provided relays verbatim, matching + // Node `_requestAnnounce`: `ann.peer.relayAddresses = relayAddresses || []`. + // Closest-ack accumulation lives only in handle-state + // (`current_relay_addresses` below) for *subsequent* announces + // to consume — it is NEVER written into a stored peer record. + // Do not "fix" this by re-adding per-record accumulation; the + // TOPIC-close nodes don't hold FE for `hash(pk)`, so advertising + // them as PEER_HANDSHAKE relays yields "empty reply". + let record_relays: Vec = relay_addresses.iter().take(3).cloned().collect(); + let peer = HyperPeer { public_key: key_pair.public_key, - relay_addresses: relay_addresses - .iter() - .take(3) - .cloned() - .collect(), + relay_addresses: record_relays, }; let peer_encoded = encode_hyper_peer_to_bytes(&peer)?; @@ -568,11 +645,35 @@ impl HyperDhtHandle { command: ANNOUNCE, target: Some(target), value: Some(ann_bytes), + timeout_ms: None, + retries: None, }, &reply.from.host, reply.from.port, ) .await; + + // Accumulate this acker into handle-state relay list (Node + // `Announcer._commit`: `if (relayAddresses.length < 3) + // relayAddresses.push({ host: msg.from.host, port: msg.from.port })`). + // We push only once per accepted reply and cap at 3. Dedup is + // implicit — each `reply.from` is a distinct DHT node. + if accepted_relays.len() < 3 { + accepted_relays.push(reply.from.clone()); + } + } + + // Only update handle-state `current_relay_addresses` when announcing + // on `hash(public_key)` (self-announce). Topic-announces also produce + // a `closest_nodes` set, but those are topic-close nodes (NOT FE-holders + // for PEER_HANDSHAKE forwarding). Mixing them into `current_relay_addresses` + // — when parallel announces race on the shared Mutex — would corrupt + // the relay list the topic-announce reads on a subsequent refresh. + // See peer_discovery::do_refresh for the parallel-join order. + if target == hash(&key_pair.public_key) { + if let Ok(mut guard) = self.current_relay_addresses.lock() { + *guard = accepted_relays; + } } Ok(AnnounceResult { closest_nodes }) @@ -687,6 +788,8 @@ impl HyperDhtHandle { command: UNANNOUNCE, target: Some(target), value: Some(ann_bytes), + timeout_ms: None, + retries: None, }, &reply.from.host, reply.from.port, @@ -735,6 +838,8 @@ impl HyperDhtHandle { command: IMMUTABLE_PUT, target: Some(target), value: Some(value.to_vec()), + timeout_ms: None, + retries: None, }, &reply.from.host, reply.from.port, @@ -829,6 +934,8 @@ impl HyperDhtHandle { command: MUTABLE_PUT, target: Some(target), value: Some(put_bytes.clone()), + timeout_ms: None, + retries: None, }, &reply.from.host, reply.from.port, @@ -921,6 +1028,57 @@ impl HyperDhtHandle { self.dht.listen_socket().await.map_err(HyperDhtError::Dht) } + /// Returns the externally-observed IPv4 address of this node, as inferred + /// from the `to` field of inbound DHT responses (NAT consensus). + /// + /// Returns `None` until the NAT analysis has settled. Mirrors Node + /// Hyperswarm's `dht.remoteAddress()`. + pub async fn remote_address(&self) -> Result, HyperDhtError> { + self.dht.remote_address().await.map_err(HyperDhtError::Dht) + } + + /// Build the IPv4 `addresses4` list to advertise in a noise handshake + /// payload. Mirrors Node `hyperdht/lib/connect.js:420-437` and + /// `hyperdht/lib/server.js:277-284`: the reflexive address from + /// [`Self::remote_address`] (if any) is prepended, then the local + /// IPv4 interface addresses from + /// [`Holepuncher::local_addresses`](crate::holepuncher::Holepuncher::local_addresses) + /// are appended. Order matters: the receiver iterates this list + /// sequentially and LAN-special-cases on its side. + pub async fn noise_addresses4(&self, port: u16) -> Vec { + let mut out = Vec::new(); + if let Ok(Some(remote)) = self.remote_address().await { + out.push(remote); + } + out.extend(crate::holepuncher::Holepuncher::local_addresses(port)); + out + } + + /// Returns the current firewall classification (one of the + /// `FIREWALL_OPEN` / `FIREWALL_UNKNOWN` / `FIREWALL_CONSISTENT` / + /// `FIREWALL_RANDOM` constants from [`crate::hyperdht_messages`]). + /// + /// Mirrors Node Hyperswarm's `dht.firewalled`. + pub async fn firewalled(&self) -> Result { + self.dht.firewalled().await.map_err(HyperDhtError::Dht) + } + + /// Returns the set of DHT-relay endpoints this node is currently + /// announcing through (capped at 3, refreshed on each `announce()`). + /// + /// These are the addresses of DHT nodes that successfully accepted this + /// node's last announce — meaning they hold a `ForwardEntry` and will + /// relay `PEER_HANDSHAKE` on this node's behalf. Empty until the first + /// `announce()` completes. + /// + /// Mirrors Node Hyperswarm's `Server.relayAddresses` / `Announcer.relayAddresses`. + pub fn current_relay_addresses(&self) -> Vec { + self.current_relay_addresses + .lock() + .map(|g| g.clone()) + .unwrap_or_default() + } + /// Access the shared router state. pub fn router(&self) -> &Arc> { &self.router @@ -981,17 +1139,43 @@ impl HyperDhtHandle { /// Connect to a remote peer, optionally using known relay addresses first. /// - /// Connection strategy (matches Node.js `findAndConnect`): + /// Connection strategy (matches Node.js `findAndConnect` in + /// `hyperdht/lib/connect.js`): /// 1. Try provided `relay_addresses` first (optimistic pre-connect). - /// 2. Run FIND_NODE to discover all DHT nodes close to the target, - /// then try `connect_through_node` for each one. - /// 3. Try relay addresses found in peer records via FIND_PEER query. + /// 2. Run FIND_PEER on hash(remote_pk); each responder is an FE-holder + /// (it returned a stored peer record), making it a known-good + /// PEER_HANDSHAKE forwarder. Try each `reply.from`, and additionally + /// each `peer.relay_addresses` entry from the stored records. + /// 3. Fallback to FIND_NODE walk on hash(remote_pk) for nodes the DHT + /// routing table knows about but didn't store an FE. pub async fn connect_with_nodes( &self, key_pair: &KeyPair, remote_public_key: [u8; 32], relay_addresses: &[Ipv4Peer], runtime: &UdxRuntime, + ) -> Result { + self.connect_with_options( + key_pair, + remote_public_key, + relay_addresses, + runtime, + ConnectOpts::default(), + ) + .await + } + + /// Like [`connect_with_nodes`] but accepts a [`ConnectOpts`] options bag. + /// + /// Use this when you need to disable the same-NAT LAN-shortcut + /// (e.g. to force the real-network code path under test). + pub async fn connect_with_options( + &self, + key_pair: &KeyPair, + remote_public_key: [u8; 32], + relay_addresses: &[Ipv4Peer], + runtime: &UdxRuntime, + opts: ConnectOpts, ) -> Result { let mut last_err = HyperDhtError::NoRelayNodes; let mut tried: Vec<(String, u16)> = Vec::new(); @@ -999,8 +1183,7 @@ impl HyperDhtHandle { // Phase 1: Optimistic pre-connect through provided relay addresses. for relay in relay_addresses { tried.push((relay.host.clone(), relay.port)); - match self - .connect_through_node(key_pair, &remote_public_key, relay, runtime) + match self.connect_through_node(key_pair, &remote_public_key, relay, runtime, &opts) .await { Ok(result) => return Ok(result), @@ -1011,35 +1194,122 @@ impl HyperDhtHandle { } } - // Phase 2: Walk the DHT to find nodes close to hash(remotePublicKey). - // Use FIND_NODE (internal command all DHT nodes handle) to ensure we - // discover the server's own node — FIND_PEER (user command) might not - // reach all nodes in small networks. let target = hash(&remote_public_key); + + // Phase 2: FIND_PEER on hash(pk). Responders are FE-holders (they + // returned a stored peer record under this target). Their `reply.from` + // address is a verified PEER_HANDSHAKE forwarder. This mirrors Node's + // `findAndConnect` which does `dht.findPeer(c.target)` and tries each + // `data.from`. + let peer_replies = self + .query_find_peer(target) + .await + .unwrap_or_else(|e| { + tracing::debug!(err = %e, "connect_with_nodes: FIND_PEER query failed"); + Vec::new() + }); + tracing::debug!( + reply_count = peer_replies.len(), + "connect_with_nodes: FIND_PEER completed" + ); + + for reply in &peer_replies { + if reply.value.is_none() { + continue; + } + if tried + .iter() + .any(|(h, p)| h == &reply.from.host && *p == reply.from.port) + { + continue; + } + tried.push((reply.from.host.clone(), reply.from.port)); + tracing::debug!( + relay = %format!("{}:{}", reply.from.host, reply.from.port), + "connect_with_nodes: trying FE-holder" + ); + match self.connect_through_node(key_pair, &remote_public_key, &reply.from, runtime, &opts) + .await + { + Ok(result) => return Ok(result), + Err(e) => { + tracing::debug!( + relay = %format!("{}:{}", reply.from.host, reply.from.port), + err = %e, + "FE-holder relay attempt failed" + ); + last_err = e; + } + } + } + + for reply in &peer_replies { + if let Some(value) = &reply.value { + if let Ok(peer) = decode_hyper_peer_from_bytes(value) { + for relay in &peer.relay_addresses { + if tried + .iter() + .any(|(h, p)| h == &relay.host && *p == relay.port) + { + continue; + } + tried.push((relay.host.clone(), relay.port)); + match self.connect_through_node(key_pair, &remote_public_key, relay, runtime, &opts) + .await + { + Ok(result) => return Ok(result), + Err(e) => { + tracing::debug!( + relay = %format!("{}:{}", relay.host, relay.port), + err = %e, + "peer-record relay attempt failed" + ); + last_err = e; + } + } + } + } + } + } + + // Phase 3: Fallback to FIND_NODE walk for closer nodes the DHT routing + // table reaches but that didn't return an FE record (e.g. mid-refresh). let table_size = self.dht.table_size().await.unwrap_or(0); - tracing::debug!(table_size, "connect_with_nodes: routing table size before FIND_NODE"); - let node_replies = self.dht.find_node(target).await - .map_err(HyperDhtError::Dht)?; - tracing::debug!(reply_count = node_replies.len(), "connect_with_nodes: FIND_NODE completed"); + tracing::debug!( + table_size, + "connect_with_nodes: routing table size before FIND_NODE" + ); + let node_replies = self.dht.find_node(target).await.map_err(HyperDhtError::Dht)?; + tracing::debug!( + reply_count = node_replies.len(), + "connect_with_nodes: FIND_NODE completed" + ); - if relay_addresses.is_empty() && node_replies.is_empty() { + if relay_addresses.is_empty() && peer_replies.is_empty() && node_replies.is_empty() { return Err(HyperDhtError::PeerNotFound); } - // Collect all unique candidate addresses from replies AND their closer_nodes. let mut candidates: Vec = Vec::new(); for reply in &node_replies { candidates.push(reply.from.clone()); for cn in &reply.closer_nodes { - if !candidates.iter().any(|c| c.host == cn.host && c.port == cn.port) { + if !candidates + .iter() + .any(|c| c.host == cn.host && c.port == cn.port) + { candidates.push(cn.clone()); } } } - tracing::debug!(candidate_count = candidates.len(), "connect_with_nodes: total candidates (replies + closer_nodes)"); + tracing::debug!( + candidate_count = candidates.len(), + "connect_with_nodes: total FIND_NODE candidates (replies + closer_nodes)" + ); for (i, candidate) in candidates.iter().enumerate() { - let skip = tried.iter().any(|(h, p)| h == &candidate.host && *p == candidate.port); + let skip = tried + .iter() + .any(|(h, p)| h == &candidate.host && *p == candidate.port); tracing::debug!( i, candidate = %format!("{}:{}", candidate.host, candidate.port), @@ -1050,44 +1320,25 @@ impl HyperDhtHandle { continue; } tried.push((candidate.host.clone(), candidate.port)); - tracing::debug!(candidate = %format!("{}:{}", candidate.host, candidate.port), "connect_with_nodes: trying node candidate"); - match self - .connect_through_node(key_pair, &remote_public_key, candidate, runtime) + tracing::debug!( + candidate = %format!("{}:{}", candidate.host, candidate.port), + "connect_with_nodes: trying node candidate" + ); + match self.connect_through_node(key_pair, &remote_public_key, candidate, runtime, &opts) .await { Ok(result) => return Ok(result), Err(e) => { - tracing::debug!(relay = %format!("{}:{}", candidate.host, candidate.port), err = %e, "query relay attempt failed"); + tracing::debug!( + relay = %format!("{}:{}", candidate.host, candidate.port), + err = %e, + "find_node candidate attempt failed" + ); last_err = e; } } } - // Phase 3: Also try relay addresses from a FIND_PEER query (peer records). - let peer_replies = self.query_find_peer(target).await?; - for reply in &peer_replies { - if let Some(value) = &reply.value { - if let Ok(peer) = decode_hyper_peer_from_bytes(value) { - for relay in &peer.relay_addresses { - if tried.iter().any(|(h, p)| h == &relay.host && *p == relay.port) { - continue; - } - tried.push((relay.host.clone(), relay.port)); - match self - .connect_through_node(key_pair, &remote_public_key, relay, runtime) - .await - { - Ok(result) => return Ok(result), - Err(e) => { - tracing::debug!(relay = %format!("{}:{}", relay.host, relay.port), err = %e, "peer record relay attempt failed"); - last_err = e; - } - } - } - } - } - } - Err(last_err) } @@ -1108,7 +1359,7 @@ impl HyperDhtHandle { host: target_addr.ip().to_string(), port: target_addr.port(), }; - self.connect_through_node(key_pair, &remote_public_key, &relay, runtime) + self.connect_through_node(key_pair, &remote_public_key, &relay, runtime, &ConnectOpts::default()) .await } @@ -1118,6 +1369,7 @@ impl HyperDhtHandle { remote_public_key: &[u8; 32], relay: &Ipv4Peer, runtime: &UdxRuntime, + opts: &ConnectOpts, ) -> Result { let target = hash(remote_public_key); @@ -1126,12 +1378,21 @@ impl HyperDhtHandle { let local_stream_id = next_stream_id(); + // Advertise our reflexive address (if known) + local IPv4 + // interface addresses. Mirrors Node `lib/connect.js:420-437`. The + // receiver sequentially probes this list and applies its own + // LAN-special-case filtering — see `hyperdht/lib/server.js:426`. + let client_addresses4 = match self.dht.local_port().await { + Ok(p) => self.noise_addresses4(p).await, + Err(_) => vec![], + }; + let local_payload = NoisePayload { version: 1, error: 0, firewall: FIREWALL_UNKNOWN, holepunch: None, - addresses4: vec![], + addresses4: client_addresses4, addresses6: vec![], udx: Some(UdxInfo { version: 1, @@ -1156,6 +1417,8 @@ impl HyperDhtHandle { command: PEER_HANDSHAKE, target: Some(target), value: Some(handshake_value), + timeout_ms: Some(10000), + retries: Some(0), }, &relay.host, relay.port, @@ -1211,16 +1474,33 @@ impl HyperDhtHandle { // Node.js (connect.js) checks: // payload.firewall === FIREWALL.OPEN -- server says it's open // (relayed && !remoteHolepunchable) -- relayed but server has no HP relays - // In either case, connect directly using the server address from the handshake. + // same NAT (own remote addr matches server's reflexive) -- LAN shortcut + // In any of those cases, connect directly using the server address from the handshake. let remote_holepunchable = remote_payload .holepunch .as_ref() .is_some_and(|hp| !hp.relays.is_empty()); + // NAT-derived same-host check. `hs_result.client_address.host` is the + // FE-holder's source IP (not our own public address), so comparing it + // to `server_address.host` is always false on relayed handshakes and + // cannot be the same-host signal. Use our own NAT-sampled public + // address instead. Disabled when the caller opts out via + // `local_connection=false`. + let same_host = if !opts.local_connection { + false + } else { + match self.dht.remote_address().await { + Ok(Some(my_remote)) => my_remote.host == hs_result.server_address.host, + _ => false, + } + }; + tracing::debug!( relayed = hs_result.relayed, firewall = remote_payload.firewall, remote_holepunchable, + same_host, server_address = %format!("{}:{}", hs_result.server_address.host, hs_result.server_address.port), "handshake complete, deciding connection path" ); @@ -1229,26 +1509,55 @@ impl HyperDhtHandle { hs_result.relayed, remote_payload.firewall, remote_holepunchable, - hs_result.server_address.host == hs_result.client_address.host, + same_host, ) { - // Prefer first non-private address from the remote's advertised list, - // falling back to the server address extracted from the handshake reply. - let connect_addr = remote_payload - .addresses4 - .iter() - .find(|a| !is_addr_private(&a.host)) - .cloned() - .unwrap_or_else(|| hs_result.server_address.clone()); + // Pick the dial target. When same-host is true (both peers + // share a NAT), prefer a private/loopback address from the + // remote's advertised list, since the public IP often can't be + // reached from inside the same NAT (no NAT hairpin). Otherwise + // prefer a non-private public address, falling back to the + // FE-holder's-tagged server address. Mirrors Node + // `lib/connect.js::holepunch` LAN-shortcut. + let connect_addr = if same_host { + remote_payload + .addresses4 + .iter() + .find(|a| is_addr_private(&a.host)) + .cloned() + .or_else(|| { + remote_payload + .addresses4 + .iter() + .find(|a| !is_addr_private(&a.host)) + .cloned() + }) + .unwrap_or_else(|| hs_result.server_address.clone()) + } else { + remote_payload + .addresses4 + .iter() + .find(|a| !is_addr_private(&a.host)) + .cloned() + .unwrap_or_else(|| hs_result.server_address.clone()) + }; + tracing::debug!( + same_host, + connect_addr = %format!("{}:{}", connect_addr.host, connect_addr.port), + server_address = %format!("{}:{}", hs_result.server_address.host, hs_result.server_address.port), + advertised = ?remote_payload.addresses4.iter().map(|a| format!("{}:{}", a.host, a.port)).collect::>(), + "direct-connect dial target chosen" + ); let direct = ConnectResult { remote_public_key: nw_result.remote_public_key, server_address: connect_addr, client_address: hs_result.client_address, is_relayed: false, - noise: nw_result, - local_stream_id, - remote_udx: remote_payload.udx.clone(), - }; + noise: nw_result, + local_stream_id, + remote_udx: remote_payload.udx.clone(), + udx_socket: None, + }; let shared = self.server_socket().await?; return establish_stream_with_socket(&direct, runtime, shared).await; } @@ -1266,7 +1575,10 @@ impl HyperDhtHandle { local_stream_id, ) .await?; - let shared = self.server_socket().await?; + let shared = match hp_result.udx_socket.clone() { + Some(s) => Some(s), + None => self.server_socket().await?, + }; establish_stream_with_socket(&hp_result, runtime, shared).await } @@ -1277,10 +1589,46 @@ impl HyperDhtHandle { remote_payload: &NoisePayload, relay: &Ipv4Peer, target: &[u8; 32], - server_address: &Ipv4Peer, + _server_address: &Ipv4Peer, runtime: &UdxRuntime, local_stream_id: u32, ) -> Result { + // Node-shaped multi-round client holepunch loop. + // + // The previous (legacy) implementation sent exactly two PEER_HOLEPUNCH + // messages — a probe (`punching=false`) and an immediate punch + // (`punching=true`) — then waited for the local Holepuncher to emit a + // Connected event. That does not match Node's `lib/connect.js` + // semantics and fails against passive Node servers (and against our + // own swarm-side passive holepunch server). + // + // The flow below mirrors Node: + // 1. Up to `HOLEPUNCH_MAX_PROBE_ROUNDS` probe rounds, each with + // `punching=false`. After every reply we feed the new info into + // the Holepuncher via `update_remote(...)` and ask `analyze(false)` + // whether the local NAT state is stable enough to punch. + // 2. A reply is only treated as "verified" when its `remote_token` + // echoes the token we sent on the same round — only the + // legitimate server could echo our random token through the + // encrypted FE-holder relay. + // 3. Once we have at least one verified remote address AND + // `analyze(false)` is true we send a single final + // `punching=true` round, then race the local `puncher.punch()` + // against incoming `HolepunchEvent::Connected`/`Aborted` events, + // bounded by `HOLEPUNCH_TOTAL_DEADLINE`. + // 4. `reply.error` is decoded each round: `ERROR_NONE` continues, + // `ERROR_TRY_LATER` is retried in the next probe round, + // `ERROR_ABORTED` returns `HolepunchAborted`, anything else + // returns `HolepunchFailed`. + + /// Maximum wall-clock time spent in the full holepunch flow (probes + + /// final punch + waiting for Connected). + const HOLEPUNCH_TOTAL_DEADLINE: std::time::Duration = + std::time::Duration::from_secs(30); + /// Maximum number of `punching=false` probe rounds before we either + /// transition to the final punch round or give up. + const HOLEPUNCH_MAX_PROBE_ROUNDS: u32 = 3; + let sp = SecurePayload::new(nw_result.holepunch_secret); let pool = SocketPool::new("0.0.0.0".into()); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); @@ -1301,42 +1649,92 @@ impl HyperDhtHandle { .await .map_err(|_| HyperDhtError::HolepunchFailed)?; - // Probe round: exchange addresses without punching - let probe_payload = HolepunchPayload { - error: 0, - firewall: puncher.nat.firewall, - round: 0, - connected: false, - punching: false, - addresses: None, - remote_address: None, - token: Some(sp.token(&server_address.host)), - remote_token: None, + // Seed the puncher's NAT classifier with reflexive samples from the + // puncher socket's own UDP binding BEFORE punch() runs. Without this + // the puncher.nat stays UNKNOWN (Phase 3's only sample source was + // PEER_HOLEPUNCH replies, all from the same FE-holder, which Nat::add + // dedupes after the first hit) and coerce_firewall would have to lie. + // See Node lib/holepuncher.js:13-20 + lib/nat.js:25-79. + let added = puncher + .auto_sample(&self.dht) + .await; + tracing::debug!( + added, + firewall = puncher.nat.firewall, + "initiator auto_sample" + ); + + // Holepunch payload `addresses` list: prefer the puncher's + // autoSample-derived reflexive samples (`puncher.nat.addresses`) + // and fall back to local LAN interface enumeration when those are + // empty. Mirrors Node `hyperdht/lib/holepuncher.js:221-227` (the + // receiver iterates `remoteAddresses` to drive `_consistentProbe`). + let local_punch_addrs: Vec = match puncher.primary_socket() { + Some(sr) => match sr.socket.local_addr().await { + Ok(addr) => puncher.punch_addresses(addr.port()), + Err(_) => Vec::new(), + }, + None => Vec::new(), }; - let encrypted_probe = sp.encrypt(&probe_payload)?; - let hp_value = Router::encode_client_holepunch(hp_id, encrypted_probe, None)?; + let deadline = tokio::time::Instant::now() + HOLEPUNCH_TOTAL_DEADLINE; + + // State carried between probe rounds. + let mut cached_reply_token: Option<[u8; 32]> = None; + let mut verified_remote_addr: Option = None; + let mut stable = false; + let mut last_round: u64 = 0; + + for round in 0u64..=u64::from(HOLEPUNCH_MAX_PROBE_ROUNDS) { + last_round = round; + + // Fresh outbound token per round; the reply must echo this back + // in `remote_token` for us to consider the host verified. + let our_outbound_token: [u8; 32] = rand::random(); + + let probe_payload = HolepunchPayload { + error: ERROR_NONE, + firewall: puncher.nat.firewall, + round, + connected: false, + punching: false, + addresses: Some(local_punch_addrs.clone()), + remote_address: None, + token: Some(our_outbound_token), + remote_token: cached_reply_token, + }; - let hp_resp = self - .dht - .request( - UserRequestParams { - token: None, - command: PEER_HOLEPUNCH, - target: Some(*target), - value: Some(hp_value), - }, - &relay.host, - relay.port, - ) - .await?; + let encrypted_probe = sp.encrypt(&probe_payload)?; + let hp_value = Router::encode_client_holepunch(hp_id, encrypted_probe, None)?; - if hp_resp.error != 0 { - puncher.destroy(); - return Err(HyperDhtError::HolepunchFailed); - } + tracing::debug!(round, hp_id, "probe sent"); + + let hp_resp = self + .dht + .request( + UserRequestParams { + token: None, + command: PEER_HOLEPUNCH, + target: Some(*target), + value: Some(hp_value), + timeout_ms: None, + retries: None, + }, + &relay.host, + relay.port, + ) + .await?; + + if hp_resp.error != 0 { + puncher.destroy(); + return Err(HyperDhtError::HolepunchFailed); + } + + let Some(reply_value) = &hp_resp.value else { + puncher.destroy(); + return Err(HyperDhtError::HolepunchFailed); + }; - if let Some(reply_value) = &hp_resp.value { let hp_result = { let router = self .router @@ -1345,35 +1743,137 @@ impl HyperDhtHandle { router.validate_holepunch_reply(reply_value, relay, &hp_resp.from, relay)? }; - if let Ok(remote_hp) = sp.decrypt(&hp_result.payload) { - let verified_host = Some(hp_result.peer_address.host.as_str()); - if let Some(addrs) = &remote_hp.addresses { - puncher.update_remote( - remote_hp.punching, - remote_hp.firewall, - addrs, - verified_host, - ); + let reply_hp = sp.decrypt(&hp_result.payload)?; + + match reply_hp.error { + ERROR_NONE => {} + ERROR_TRY_LATER => { + tracing::debug!(round, "reply ERROR_TRY_LATER, continuing"); + cached_reply_token = reply_hp.token; + continue; + } + ERROR_ABORTED => { + puncher.destroy(); + return Err(HyperDhtError::HolepunchAborted); + } + other => { + tracing::warn!(round, error = other, "holepunch reply server error"); + puncher.destroy(); + return Err(HyperDhtError::HolepunchFailed); } } + + // Mirror Node `connect.js:612-616`: feed NAT sampler. + puncher.nat.add(&hp_result.peer_address, &hp_resp.from); + + // Token-echo verification: only consider the host verified if + // the reply echoes back our outbound token in `remote_token`. + let token_matches = reply_hp.remote_token == Some(our_outbound_token); + let verified_host = if token_matches { + Some(hp_result.peer_address.host.as_str()) + } else { + None + }; + + tracing::debug!( + round, + punching = reply_hp.punching, + firewall = reply_hp.firewall, + addresses = reply_hp.addresses.as_ref().map_or(0, |a| a.len()), + verified = token_matches, + "probe reply" + ); + + if let Some(addrs) = &reply_hp.addresses { + puncher.update_remote( + reply_hp.punching, + reply_hp.firewall, + addrs, + verified_host, + ); + } + + if token_matches { + verified_remote_addr = Some(hp_result.peer_address.clone()); + } + cached_reply_token = reply_hp.token; + + // Match Node `connect.js:630-634`: 1s grace on UNKNOWN firewall. + if reply_hp.firewall == FIREWALL_UNKNOWN { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } + + // Synchronous early-exit: if BOTH local NAT is settled (sampled + // enough to classify) AND remote firewall is settled AND we have + // a verified remote address, we can short-circuit the remaining + // probe rounds. We deliberately avoid `puncher.analyze(false).await` + // here because that awaits `nat.analyzing()` which can't resolve + // until `NAT_MIN_SAMPLES` samples have accumulated — calling it + // mid-loop would deadlock the loop's own sample-accumulation work. + // The post-loop `analyze(true)` provides the authoritative wait. + if verified_remote_addr.is_some() + && puncher.nat.is_settled() + && reply_hp.firewall != FIREWALL_UNKNOWN + { + stable = true; + tracing::info!( + target: "peeroxide::_events::holepunch::nat_settled", + round, + "NAT settled + verified remote, transitioning to final punch round" + ); + break; + } } - // Punch round: send with punching=true, then initiate punch + // Phase 3 MVP divergence from Node: skip the post-loop + // `puncher.analyze(true).await` gate. Node's gate relies on + // `Holepuncher.autoSample()` having fed 4+ unique DHT-node pings + // into the puncher's NAT sampler in parallel; we don't have + // autoSample yet, so the in-loop `puncher.nat.add(...)` from + // PEER_HOLEPUNCH replies is our only sample source. Each round + // here comes from the SAME FE-holder relay (same `from`), which + // `Nat::add`'s dedupe-by-from rejects after the first sample — + // leaving the puncher's NAT permanently UNKNOWN and the analyze + // gate hanging forever. Instead we rely on `verified_remote_addr` + // (set when a reply echoes back our outbound token) as the + // signal that we've identified a punchable target. The actual + // punch-success signal is `HolepunchEvent::Connected` emitted by + // the recv adapter, not the local NAT classification. Adding + // autoSample is tracked as future work for full Node parity. + let _ = stable; // suppress unused-write warning; loop still tracks it for future use + + let Some(verified_addr) = verified_remote_addr else { + tracing::warn!( + target: "peeroxide::_events::holepunch::failed_no_verified_addr", + "no verified remote address after probe rounds" + ); + puncher.destroy(); + return Err(HyperDhtError::HolepunchFailed); + }; + + // ── Final punch round (single round with punching=true) ───────────── + let final_round = last_round.saturating_add(1); + let final_outbound_token: [u8; 32] = rand::random(); let punch_payload = HolepunchPayload { - error: 0, + error: ERROR_NONE, firewall: puncher.nat.firewall, - round: 1, + round: final_round, connected: false, punching: true, - addresses: None, - remote_address: None, - token: Some(sp.token(&server_address.host)), - remote_token: None, + addresses: Some(local_punch_addrs.clone()), + remote_address: Some(verified_addr.clone()), + token: Some(final_outbound_token), + remote_token: cached_reply_token, }; let encrypted_punch = sp.encrypt(&punch_payload)?; let hp_punch_value = Router::encode_client_holepunch(hp_id, encrypted_punch, None)?; + tracing::info!( + target: "peeroxide::_events::holepunch::final_punch_sent", + round = final_round, + verified_addr = %format!("{}:{}", verified_addr.host, verified_addr.port), + "final punch sent, waiting for Connected event" + ); + let punch_resp = self .dht .request( @@ -1382,6 +1882,8 @@ impl HyperDhtHandle { command: PEER_HOLEPUNCH, target: Some(*target), value: Some(hp_punch_value), + timeout_ms: None, + retries: None, }, &relay.host, relay.port, @@ -1394,20 +1896,19 @@ impl HyperDhtHandle { .router .lock() .map_err(|_| HyperDhtError::ChannelClosed)?; - router.validate_holepunch_reply( - reply_value, - relay, - &punch_resp.from, - relay, - )? + router.validate_holepunch_reply(reply_value, relay, &punch_resp.from, relay)? }; - if let Ok(remote_hp) = sp.decrypt(&hp_result.payload) { - let verified_host = Some(hp_result.peer_address.host.as_str()); - if let Some(addrs) = &remote_hp.addresses { + if let Ok(reply_hp) = sp.decrypt(&hp_result.payload) { + let verified_host = if reply_hp.remote_token == Some(final_outbound_token) { + Some(hp_result.peer_address.host.as_str()) + } else { + None + }; + if let Some(addrs) = &reply_hp.addresses { puncher.update_remote( - remote_hp.punching, - remote_hp.firewall, + reply_hp.punching, + reply_hp.firewall, addrs, verified_host, ); @@ -1415,38 +1916,90 @@ impl HyperDhtHandle { } } - // Initiate the actual punch - let punched = puncher.punch(&pool, runtime).await; - if !punched { + // Spawn the local punch loop and race it against Connected / Aborted + // events and the global deadline. The pinned `punch_fut` mutably + // borrows `puncher` for the duration of the inner scope; on timeout + // we exit the scope so the borrow is released before we call + // `puncher.destroy()`. + + // Clone the puncher's primary UDX socket BEFORE punch_fut takes &mut + // puncher, so it is available inside the Connected arm below. + // + // Per D8 (Node connect.js): the initiator's rawStream attaches to the + // SAME socket the puncher used — that is where the NAT mapping was + // opened by the probe exchange. Using the DHT server socket would send + // the UDX SYN through an unrelated NAT path and never reach the peer. + // + // The Arc-clone keeps the socket alive after the Holepuncher is + // dropped on the early-return; SocketPool::acquire's route_messages + // task continues running as long as one clone exists. + let puncher_udx: Option = puncher + .primary_socket() + .map(|sr| sr.socket.clone()); + debug_assert!( + puncher_udx.is_some(), + "primary socket missing before punch loop — logic error" + ); + + let timed_out = { + let punch_fut = puncher.punch(&pool, runtime); + tokio::pin!(punch_fut); + let mut punch_done = false; + let deadline_sleep = tokio::time::sleep_until(deadline); + tokio::pin!(deadline_sleep); + + loop { + tokio::select! { + ev = event_rx.recv() => { + match ev { + Some(HolepunchEvent::Connected { addr }) => { + tracing::info!( + target: "peeroxide::_events::holepunch::connected", + from = %addr, + "punch successful" + ); + let connected_addr = Ipv4Peer { + host: addr.ip().to_string(), + port: addr.port(), + }; + return Ok(ConnectResult { + remote_public_key: nw_result.remote_public_key, + server_address: connected_addr.clone(), + client_address: connected_addr, + is_relayed: true, + noise: nw_result.clone(), + local_stream_id, + remote_udx: remote_payload.udx.clone(), + udx_socket: puncher_udx, + }); + } + Some(HolepunchEvent::Aborted) | None => { + tracing::info!( + target: "peeroxide::_events::holepunch::aborted", + "punch aborted" + ); + return Err(HyperDhtError::HolepunchAborted); + } + } + } + _ = punch_fut.as_mut(), if !punch_done => { + punch_done = true; + tracing::debug!("local punch loop completed, awaiting Connected event"); + } + _ = deadline_sleep.as_mut() => { + break true; + } + } + } + }; + + if timed_out { + tracing::debug!("punch deadline elapsed"); puncher.destroy(); return Err(HyperDhtError::HolepunchFailed); } - // Wait for the punch to connect - match tokio::time::timeout(std::time::Duration::from_secs(10), event_rx.recv()).await { - Ok(Some(HolepunchEvent::Connected { addr })) => { - let connected_addr = Ipv4Peer { - host: addr.ip().to_string(), - port: addr.port(), - }; - Ok(ConnectResult { - remote_public_key: nw_result.remote_public_key, - server_address: connected_addr.clone(), - client_address: connected_addr, - is_relayed: true, - noise: nw_result.clone(), - local_stream_id, - remote_udx: remote_payload.udx.clone(), - }) - } - Ok(Some(HolepunchEvent::Aborted)) | Ok(None) => { - Err(HyperDhtError::HolepunchAborted) - } - Err(_) => { - puncher.destroy(); - Err(HyperDhtError::HolepunchFailed) - } - } + unreachable!("holepunch select loop exited without return or timeout"); } /// Establish an encrypted connection to a peer via a relay node. @@ -1632,6 +2185,7 @@ pub async fn run_server( mut event_rx: mpsc::UnboundedReceiver, config: ServerConfig, runtime: UdxRuntime, + dht: DhtHandle, ) { let mut session = ServerSession { holepunch_secrets: std::collections::HashMap::new(), @@ -1643,6 +2197,7 @@ pub async fn run_server( ServerEvent::PeerHandshake { msg, from, + peer_address: _, target, reply_tx, } => { @@ -1662,15 +2217,47 @@ pub async fn run_server( target: _, reply_tx, } => { - let reply = handle_server_holepunch( - &config, - &mut session, - &pool, - &runtime, - msg, - &peer_address, - ) - .await; + let holepunch_secret = + session.holepunch_secrets.values().find_map(|state| { + let sp = SecurePayload::new(state.holepunch_secret); + if sp.decrypt(&msg.payload).is_ok() { + Some(state.holepunch_secret) + } else { + None + } + }); + let reply: Option> = if let Some(secret) = holepunch_secret { + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + match build_passive_holepunch_reply( + config.firewall, + &pool, + &runtime, + &peer_address, + secret, + &msg.payload, + msg.id, + event_tx, + Some(&dht), + ) + .await + { + Ok(result) => { + if result.remote_hp.punching { + let pool_clone = SocketPool::new("0.0.0.0".into()); + let mut puncher = result.puncher; + tokio::spawn(async move { + if let Ok(rt) = UdxRuntime::new() { + puncher.punch(&pool_clone, &rt).await; + } + }); + } + Some(result.reply_bytes) + } + Err(_) => None, + } + } else { + None + }; let _ = reply_tx.send(reply); } } @@ -1746,82 +2333,106 @@ fn handle_server_handshake( crate::hyperdht_messages::encode_handshake_to_bytes(&reply_msg).ok() } -async fn handle_server_holepunch( - config: &ServerConfig, - session: &mut ServerSession, +/// Result returned by [`build_passive_holepunch_reply`]. +#[non_exhaustive] +pub struct PassiveHolepunchReply { + /// Fully-encoded [`HolepunchMessage`] bytes ready to send on the wire. + pub reply_bytes: Vec, + /// Decrypted incoming holepunch payload from the remote peer. + pub remote_hp: HolepunchPayload, + /// The [`SecurePayload`] instance used for this exchange; reuse it across rounds for token continuity. + pub payload: SecurePayload, + /// Constructed [`Holepuncher`]; caller decides whether to spawn `punch()` or retain it. + pub puncher: Holepuncher, +} + +/// Build a passive (server-side) holepunch reply from raw encrypted bytes. +/// +/// Pass `dht = Some(&dht_handle)` to run the puncher's autoSample +/// before returning (required for the `coerce_firewall` revert to be +/// safe on the passive path). Tests may pass `None` to skip sampling. +#[allow(clippy::too_many_arguments)] +pub async fn build_passive_holepunch_reply( + config_firewall: u64, pool: &SocketPool, runtime: &UdxRuntime, - msg: HolepunchMessage, peer_address: &Ipv4Peer, -) -> Option> { - // Find the matching session by trying each known peer's secret - let mut matched_state: Option<&ServerPeerState> = None; - for state in session.holepunch_secrets.values() { - let sp = SecurePayload::new(state.holepunch_secret); - if sp.decrypt(&msg.payload).is_ok() { - matched_state = Some(state); - break; - } + holepunch_secret: [u8; 32], + incoming_payload: &[u8], + msg_id: u64, + event_tx: mpsc::UnboundedSender, + dht: Option<&crate::rpc::DhtHandle>, +) -> Result { + let sp = SecurePayload::new(holepunch_secret); + let remote_hp = sp.decrypt(incoming_payload)?; + + let mut puncher = Holepuncher::new(pool, runtime, true, false, remote_hp.firewall, event_tx) + .await + .map_err(|_| HyperDhtError::HolepunchFailed)?; + + if let Some(addrs) = &remote_hp.addresses { + puncher.update_remote(true, remote_hp.firewall, addrs, Some(peer_address.host.as_str())); } - let state = matched_state?; - let sp = SecurePayload::new(state.holepunch_secret); + // Seed the passive puncher's NAT classifier BEFORE the caller spawns + // punch(). Symmetric to the initiator path — without this the passive + // NAT stays UNKNOWN and the coerce_firewall divergence has to lie. + // Critical: this is what makes the T8 coerce_firewall revert safe on + // the passive path. + if let Some(dht_handle) = dht { + let added = puncher + .auto_sample(dht_handle) + .await; + tracing::debug!( + added, + firewall = puncher.nat.firewall, + "passive auto_sample" + ); + } - let remote_hp = sp.decrypt(&msg.payload).ok()?; + // Advertise the passive puncher's reachable addresses so the initiator + // probes back at us. Prefer autoSample-derived reflexive samples + // (`puncher.nat.addresses`); fall back to local LAN interfaces when + // empty. Echoing `peer_address` here would tell the initiator to + // punch its own reflexive address. Mirrors the algorithm used on the + // initiator side (Node `hyperdht/lib/holepuncher.js:221-227`). + let local_punch_addrs: Vec = match puncher.primary_socket() { + Some(sr) => match sr.socket.local_addr().await { + Ok(addr) => puncher.punch_addresses(addr.port()), + Err(_) => Vec::new(), + }, + None => Vec::new(), + }; let reply_hp = HolepunchPayload { error: 0, - firewall: config.firewall, + firewall: config_firewall, round: remote_hp.round, connected: false, punching: remote_hp.punching, - addresses: Some(vec![peer_address.clone()]), + addresses: Some(local_punch_addrs), remote_address: Some(peer_address.clone()), token: Some(sp.token(&peer_address.host)), remote_token: remote_hp.token, }; - let encrypted_reply = sp.encrypt(&reply_hp).ok()?; - - if remote_hp.punching { - let (event_tx, _event_rx) = mpsc::unbounded_channel(); - if let Ok(mut puncher) = Holepuncher::new( - pool, - runtime, - true, - false, - remote_hp.firewall, - event_tx, - ) - .await - { - if let Some(addrs) = &remote_hp.addresses { - puncher.update_remote( - true, - remote_hp.firewall, - addrs, - Some(peer_address.host.as_str()), - ); - } - let pool_clone = SocketPool::new("0.0.0.0".into()); - tokio::spawn(async move { - // Create a dedicated UdxRuntime for the fire-and-forget punch. - // The server handler borrows its runtime, but tokio::spawn requires 'static. - if let Ok(rt) = UdxRuntime::new() { - puncher.punch(&pool_clone, &rt).await; - } - }); - } - } + let encrypted_reply = sp.encrypt(&reply_hp)?; let reply_msg = HolepunchMessage { mode: crate::hyperdht_messages::MODE_REPLY, - id: msg.id, + id: msg_id, payload: encrypted_reply, peer_address: Some(peer_address.clone()), }; - crate::hyperdht_messages::encode_holepunch_msg_to_bytes(&reply_msg).ok() + let reply_bytes = crate::hyperdht_messages::encode_holepunch_msg_to_bytes(&reply_msg)?; + + Ok(PassiveHolepunchReply { + reply_bytes, + remote_hp, + payload: sp, + puncher, + }) } // ── Spawn ───────────────────────────────────────────────────────────────────── @@ -1882,6 +2493,7 @@ pub async fn spawn( router, server_tx, admin_tx, + current_relay_addresses: Arc::new(Mutex::new(Vec::new())), }; Ok((join, handle, server_rx)) } @@ -2048,38 +2660,135 @@ fn handle_peer_handshake( }; match action { - HandshakeAction::Relay { value, to } => { - tracing::info!( + HandshakeAction::Reply(value) => { + tracing::debug!(from = %format!("{}:{}", req.from.host, req.from.port), "handshake REPLY"); + req.reply(Some(value)); + } + HandshakeAction::ForwardRequest { value, to } => { + // FE-holder relay: forward the inbound REQUEST as a new REQUEST + // to `to`, preserving the original requester's tid end-to-end. + // Fire-and-forget — the eventual REPLY will reach the original + // client directly via the tid-preserved chain (server replies + // with `dht.relay_with_tid` back to this FE-holder, which then + // dispatches HandshakeAction::ReplyTo to send the REPLY packet + // straight to the client at peer_address). Mirrors Node + // `dht-rpc::Request.relay(value, to)`. + tracing::debug!( from = %format!("{}:{}", req.from.host, req.from.port), to = %format!("{}:{}", to.host, to.port), - "handshake RELAY — forwarding between peers" + "handshake FORWARD_REQUEST — tid-preserved relay" ); - let _ = dht.relay(PEER_HANDSHAKE, req.target, Some(value), &to); - req.reply(None); + if let Err(e) = dht.relay_with_tid( + PEER_HANDSHAKE, + req.target, + Some(value), + &to, + req.tid, + ) { + tracing::debug!(err = %e, "ForwardRequest: relay_with_tid send failed"); + } + // No reply to the inbound REQUEST — the original client awaits + // its REPLY via the preserved-tid chain, not via this hop. + req.release(); } - HandshakeAction::Reply(value) => { - tracing::debug!(from = %format!("{}:{}", req.from.host, req.from.port), "handshake REPLY"); - req.reply(Some(value)); + HandshakeAction::ReplyTo { value, to } => { + // FE-holder finalises the relay chain: send a REPLY packet + // (Response, not Request) with the inbound tid (= original + // client's tid) directly to the client at `to`. The REPLY + // matches the client's outstanding inflight entry. Mirrors + // Node `dht-rpc::Request.reply(value, { to })` as invoked from + // `lib/server.js _addHandshake case FROM_SERVER`. + tracing::debug!( + from = %format!("{}:{}", req.from.host, req.from.port), + to = %format!("{}:{}", to.host, to.port), + "handshake REPLY_TO — finalise tid-preserved chain" + ); + if let Err(e) = dht.send_reply_to(req.tid, req.target, &to, Some(value)) { + tracing::debug!(err = %e, "ReplyTo: send_reply_to failed"); + } + // The inbound REQUEST came from the server via its relay_with_tid; + // the server is not awaiting a reply, so we do not call + // req.reply / req.error here. + req.release(); + } + HandshakeAction::Relay { value, to } => { + // Legacy variant — the router no longer emits this for handshakes. + // Retained for forward-compatibility (non_exhaustive enum) so any + // external producer continues to function. Treat as fire-and-forget + // tid-preserved relay (equivalent to ForwardRequest). + tracing::warn!( + from = %format!("{}:{}", req.from.host, req.from.port), + to = %format!("{}:{}", to.host, to.port), + "handshake legacy Relay action — treating as ForwardRequest" + ); + if let Err(e) = dht.relay_with_tid( + PEER_HANDSHAKE, + req.target, + Some(value), + &to, + req.tid, + ) { + tracing::debug!(err = %e, "legacy Relay: relay_with_tid send failed"); + } + req.release(); } HandshakeAction::HandleLocally(msg) => { - tracing::debug!(from = %format!("{}:{}", req.from.host, req.from.port), "handshake HANDLE_LOCALLY"); + tracing::debug!(from = %format!("{}:{}", req.from.host, req.from.port), inbound_mode = msg.mode, "handshake HANDLE_LOCALLY"); let (reply_tx, reply_rx) = oneshot::channel(); let from = req.from.clone(); let target = req.target; + let peer_address = msg.peer_address.clone(); + // FROM_CLIENT direct handshakes are answered via the inbound + // request's REPLY channel (`req.reply`). FROM_RELAY / + // FROM_SECOND_RELAY handshakes arrive as REQUESTs whose tid was + // preserved by the FE-holder via `req.relay`-style forwarding; + // we mirror Node `lib/server.js _addHandshake case FROM_RELAY` + // by calling `dht.relay_with_tid(...)` to push a tid-preserved + // FROM_SERVER REQUEST back to the FE-holder. The FE-holder then + // finalises the chain by emitting the REPLY directly to the + // original client (see HandshakeAction::ReplyTo above). + let inbound_mode = msg.mode; + let inbound_tid = req.tid; let sent = server_tx .send(ServerEvent::PeerHandshake { msg, from, + peer_address, target, reply_tx, }) .is_ok(); if sent { + let dht = dht.clone(); tokio::spawn(async move { match reply_rx.await { - Ok(value) => req.reply(value), + Ok(Some(value)) => match inbound_mode { + crate::hyperdht_messages::MODE_FROM_CLIENT => req.reply(Some(value)), + crate::hyperdht_messages::MODE_FROM_RELAY + | crate::hyperdht_messages::MODE_FROM_SECOND_RELAY => { + if let Err(e) = dht.relay_with_tid( + PEER_HANDSHAKE, + target, + Some(value), + &req.from, + inbound_tid, + ) { + tracing::debug!(err = %e, "relay_with_tid send failed"); + } + // The FE-holder used `req.relay`-style + // forwarding to reach us; it is not awaiting + // a REPLY for this REQUEST. Release the + // inbound req so the deferred-reply pipeline + // does not emit an auto-error REPLY racing + // against the genuine REPLY arriving via the + // chain's tail. + req.release(); + } + _ => req.reply(Some(value)), + }, + Ok(None) => req.reply(None), Err(_) => req.error(1), } }); @@ -2127,33 +2836,109 @@ fn handle_peer_holepunch( }; match action { + HolepunchAction::ForwardRequest { value, to } => { + // FE-holder relay: forward the inbound REQUEST as a new REQUEST + // to `to`, preserving the original requester's tid end-to-end. + // Fire-and-forget — the eventual REPLY reaches the original + // client directly via the tid-preserved chain (server replies + // with `dht.relay_with_tid` back to this FE-holder, which then + // dispatches HolepunchAction::ReplyTo to send the REPLY packet + // straight to the client at peer_address). Mirrors Node + // `dht-rpc::Request.relay(value, to)` and item-8 for handshake. + tracing::debug!( + from = %format!("{}:{}", req.from.host, req.from.port), + to = %format!("{}:{}", to.host, to.port), + "holepunch FORWARD_REQUEST — tid-preserved relay" + ); + if let Err(e) = dht.relay_with_tid( + PEER_HOLEPUNCH, + req.target, + Some(value), + &to, + req.tid, + ) { + tracing::debug!(err = %e, "ForwardRequest: relay_with_tid send failed"); + } + // No reply to the inbound REQUEST — the original client awaits + // its REPLY via the preserved-tid chain, not via this hop. + req.release(); + } + HolepunchAction::ReplyTo { value, to } => { + // FE-holder finalises the relay chain: send a REPLY packet + // (Response, not Request) with the inbound tid (= original + // client's tid) directly to the client at `to`. The REPLY + // matches the client's outstanding inflight entry. Mirrors + // Node `dht-rpc::Request.reply(value, { to })` as invoked from + // `lib/server.js _addHolepunch case FROM_SERVER`. + tracing::debug!( + from = %format!("{}:{}", req.from.host, req.from.port), + to = %format!("{}:{}", to.host, to.port), + "holepunch REPLY_TO — finalise tid-preserved chain" + ); + if let Err(e) = dht.send_reply_to(req.tid, req.target, &to, Some(value)) { + tracing::debug!(err = %e, "ReplyTo: send_reply_to failed"); + } + // The inbound REQUEST came from the server via its relay_with_tid; + // the server is not awaiting a reply, so we do not call + // req.reply / req.error here. + req.release(); + } HolepunchAction::Relay { value, to } => { - tracing::info!( + // Legacy variant — the router no longer emits this for holepunch. + // Retained for forward-compatibility (non_exhaustive enum) so any + // external producer continues to function. Treat as fire-and-forget + // tid-preserved relay (equivalent to ForwardRequest). + tracing::warn!( from = %format!("{}:{}", req.from.host, req.from.port), to = %format!("{}:{}", to.host, to.port), - "holepunch RELAY — forwarding between peers" + "holepunch legacy Relay action — treating as ForwardRequest" ); - let _ = dht.relay(PEER_HOLEPUNCH, req.target, Some(value), &to); - req.reply(None); + if let Err(e) = dht.relay_with_tid( + PEER_HOLEPUNCH, + req.target, + Some(value), + &to, + req.tid, + ) { + tracing::debug!(err = %e, "legacy Relay: relay_with_tid send failed"); + } + req.release(); } HolepunchAction::Reply { value, to } => { - tracing::debug!( + // Legacy variant — the router no longer emits this for holepunch. + // Retained for forward-compatibility (non_exhaustive enum). Treat + // as REPLY-to-arbitrary-address (equivalent to ReplyTo). + tracing::warn!( from = %format!("{}:{}", req.from.host, req.from.port), to = %format!("{}:{}", to.host, to.port), - "holepunch REPLY" + "holepunch legacy Reply action — treating as ReplyTo" ); - let _ = dht.relay(PEER_HOLEPUNCH, req.target, Some(value), &to); - req.reply(None); + if let Err(e) = dht.send_reply_to(req.tid, req.target, &to, Some(value)) { + tracing::debug!(err = %e, "legacy Reply: send_reply_to failed"); + } + req.release(); } HolepunchAction::HandleLocally { msg, peer_address } => { tracing::debug!( from = %format!("{}:{}", req.from.host, req.from.port), peer = %format!("{:?}", peer_address), + inbound_mode = msg.mode, "holepunch HANDLE_LOCALLY" ); let (reply_tx, reply_rx) = oneshot::channel(); let from = req.from.clone(); let target = req.target; + // FROM_CLIENT direct holepunch (if ever wired) is answered via + // the inbound request's REPLY channel (`req.reply`). FROM_RELAY / + // FROM_SECOND_RELAY holepunches arrive as REQUESTs whose tid was + // preserved by the FE-holder via `req.relay`-style forwarding; + // we mirror Node `lib/server.js _addHolepunch case FROM_RELAY` + // by calling `dht.relay_with_tid(...)` to push a tid-preserved + // FROM_SERVER REQUEST back to the FE-holder. The FE-holder then + // finalises the chain by emitting the REPLY directly to the + // original client (see HolepunchAction::ReplyTo above). + let inbound_mode = msg.mode; + let inbound_tid = req.tid; let sent = server_tx .send(ServerEvent::PeerHolepunch { @@ -2166,9 +2951,34 @@ fn handle_peer_holepunch( .is_ok(); if sent { + let dht = dht.clone(); tokio::spawn(async move { match reply_rx.await { - Ok(value) => req.reply(value), + Ok(Some(value)) => match inbound_mode { + crate::hyperdht_messages::MODE_FROM_CLIENT => req.reply(Some(value)), + crate::hyperdht_messages::MODE_FROM_RELAY + | crate::hyperdht_messages::MODE_FROM_SECOND_RELAY => { + if let Err(e) = dht.relay_with_tid( + PEER_HOLEPUNCH, + target, + Some(value), + &req.from, + inbound_tid, + ) { + tracing::debug!(err = %e, "relay_with_tid send failed"); + } + // The FE-holder used `req.relay`-style + // forwarding to reach us; it is not awaiting + // a REPLY for this REQUEST. Release the + // inbound req so the deferred-reply pipeline + // does not emit an auto-error REPLY racing + // against the genuine REPLY arriving via the + // chain's tail. + req.release(); + } + _ => req.reply(Some(value)), + }, + Ok(None) => req.reply(None), Err(_) => req.error(1), } }); @@ -2363,6 +3173,7 @@ mod tests { noise: nw_result, local_stream_id: 1, remote_udx: None, + udx_socket: None, }; let err = establish_stream(&result, &runtime).await.unwrap_err(); assert!(matches!(err, HyperDhtError::StreamEstablishment(_))); @@ -2387,6 +3198,7 @@ mod tests { noise: nw_result, local_stream_id: next_stream_id(), remote_udx: Some(UdxInfo { version: 1, reusable_socket: true, id: 42, seq: 0 }), + udx_socket: None, }; let err = establish_stream(&result, &runtime).await.unwrap_err(); assert!(matches!(err, HyperDhtError::StreamEstablishment(_))); @@ -2416,6 +3228,7 @@ mod tests { id: u64::from(u32::MAX) + 1, seq: 0, }), + udx_socket: None, }; let err = establish_stream(&result, &runtime).await.unwrap_err(); assert!(matches!(err, HyperDhtError::StreamEstablishment(_))); @@ -2538,4 +3351,148 @@ mod tests { // CGNAT peer with no holepunch support → direct connect fallback. assert!(should_direct_connect(true, FIREWALL_RANDOM, false, false)); } + + #[tokio::test] + async fn current_relay_addresses_starts_empty() { + use libudx::UdxRuntime; + + let runtime = UdxRuntime::new().expect("runtime"); + let cfg = HyperDhtConfig { + dht: crate::rpc::DhtConfig { + bootstrap: vec![], + ephemeral: Some(true), + ..crate::rpc::DhtConfig::default() + }, + ..HyperDhtConfig::default() + }; + let (_task, handle, _rx) = spawn(&runtime, cfg).await.expect("spawn"); + + assert!( + handle.current_relay_addresses().is_empty(), + "current_relay_addresses should be empty before any announce()" + ); + + let _ = handle.destroy().await; + } + + #[tokio::test] + async fn current_relay_addresses_is_shared_across_clones() { + use libudx::UdxRuntime; + + let runtime = UdxRuntime::new().expect("runtime"); + let cfg = HyperDhtConfig { + dht: crate::rpc::DhtConfig { + bootstrap: vec![], + ephemeral: Some(true), + ..crate::rpc::DhtConfig::default() + }, + ..HyperDhtConfig::default() + }; + let (_task, handle, _rx) = spawn(&runtime, cfg).await.expect("spawn"); + + let clone = handle.clone(); + + { + let mut guard = handle + .current_relay_addresses + .lock() + .expect("lock current_relay_addresses"); + *guard = vec![Ipv4Peer { + host: "1.2.3.4".to_string(), + port: 49737, + }]; + } + + let via_clone = clone.current_relay_addresses(); + assert_eq!(via_clone.len(), 1); + assert_eq!(via_clone[0].host, "1.2.3.4"); + assert_eq!(via_clone[0].port, 49737); + + let _ = handle.destroy().await; + } +} + +#[cfg(test)] +mod passive_holepunch_helper_tests { + use super::*; + use crate::hyperdht_messages::{ + decode_holepunch_msg_from_bytes, FIREWALL_CONSISTENT, FIREWALL_OPEN, + }; + use crate::messages::Ipv4Peer; + use crate::secure_payload::SecurePayload; + use libudx::UdxRuntime; + use tokio::sync::mpsc; + + #[tokio::test] + async fn passive_holepunch_helper_roundtrip() { + let holepunch_secret: [u8; 32] = [7u8; 32]; + + let incoming_hp = HolepunchPayload { + error: 0, + firewall: FIREWALL_CONSISTENT, + round: 0, + connected: false, + punching: true, + addresses: Some(vec![Ipv4Peer { + host: "10.0.0.1".to_string(), + port: 5000, + }]), + remote_address: None, + token: Some([0x42u8; 32]), + remote_token: None, + }; + + let sp_sender = SecurePayload::new(holepunch_secret); + let incoming_payload = sp_sender.encrypt(&incoming_hp).expect("encrypt incoming"); + + let pool = SocketPool::new("0.0.0.0".to_string()); + let runtime = UdxRuntime::new().expect("UdxRuntime"); + let peer_addr = Ipv4Peer { + host: "1.2.3.4".to_string(), + port: 9876, + }; + + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let result = build_passive_holepunch_reply( + FIREWALL_OPEN, + &pool, + &runtime, + &peer_addr, + holepunch_secret, + &incoming_payload, + 0, + event_tx, + None, + ) + .await + .expect("build_passive_holepunch_reply should succeed"); + + assert!(result.remote_hp.punching, "remote_hp.punching should be true"); + assert_eq!( + result.remote_hp.token, + Some([0x42u8; 32]), + "remote_hp.token should be present" + ); + + let decoded_msg = + decode_holepunch_msg_from_bytes(&result.reply_bytes).expect("decode reply_bytes"); + + let decoded_hp = result + .payload + .decrypt(&decoded_msg.payload) + .expect("decrypt reply payload"); + + assert!(!decoded_hp.connected, "decoded_hp.connected should be false"); + assert!(decoded_hp.punching, "decoded_hp.punching should be true"); + assert_eq!( + decoded_hp.remote_token, + Some([0x42u8; 32]), + "remote_token should be echoed in reply" + ); + + assert!( + result.puncher.primary_socket().is_some(), + "puncher should have a primary socket" + ); + } } diff --git a/peeroxide-dht/src/io.rs b/peeroxide-dht/src/io.rs index 5d2e112..88556c5 100644 --- a/peeroxide-dht/src/io.rs +++ b/peeroxide-dht/src/io.rs @@ -3,6 +3,7 @@ //! Faithful Rust port of the Node.js dht-rpc IO layer. //! The [`Io`] struct is driven by the caller from a `tokio::select!` loop. +#![allow(missing_docs)] use std::collections::VecDeque; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, Ordering}; @@ -47,6 +48,7 @@ pub type IoResult = Result; /// IO layer configuration. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct IoConfig { pub max_window: usize, pub port: u16, @@ -69,6 +71,7 @@ impl Default for IoConfig { /// IO layer statistics. #[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct IoStats { pub active: u64, pub total: u64, @@ -85,6 +88,7 @@ pub struct IoStats { /// from the OS sockets, regardless of which protocol layer originated it /// (queries, requests, replies, relays, retries — all counted). #[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct WireCounters { pub bytes_sent: Arc, pub bytes_received: Arc, @@ -95,6 +99,18 @@ impl WireCounters { Self::default() } + /// Build a `WireCounters` snapshot from externally-shared atomic counters. + /// + /// Use when a downstream consumer (e.g. a UI progress reporter) already + /// owns `Arc` byte counters and wants a `WireCounters` view + /// over them. The returned value shares the atomics with the caller. + pub fn from_counters(bytes_sent: Arc, bytes_received: Arc) -> Self { + Self { + bytes_sent, + bytes_received, + } + } + pub fn snapshot(&self) -> (u64, u64) { ( self.bytes_sent.load(Ordering::Relaxed), @@ -121,6 +137,7 @@ pub struct ResolvedRequest { } /// Events emitted by the IO layer. +#[non_exhaustive] pub enum IoEvent { IncomingRequest(IncomingRequest), Response { @@ -152,7 +169,8 @@ pub struct IncomingRequest { } /// Parameters for creating an outgoing request. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] +#[non_exhaustive] pub struct RequestParams { pub to: Ipv4Peer, pub token: Option<[u8; 32]>, @@ -160,10 +178,13 @@ pub struct RequestParams { pub command: u64, pub target: Option, pub value: Option>, + pub timeout_ms: Option, + pub retries: Option, } /// A timeout event — emitted when a request exceeds all retries. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct TimeoutEvent { pub tid: u16, pub to: Ipv4Peer, @@ -202,6 +223,7 @@ struct InflightEntry { retries: u32, deadline: Instant, timestamp: Instant, + timeout: Duration, } struct PendingSend { @@ -360,7 +382,7 @@ impl Io { self.wire .bytes_received .fetch_add(datagram.data.len() as u64, Ordering::Relaxed); - tracing::debug!( + tracing::trace!( from = %datagram.addr, len = datagram.data.len(), first_byte = datagram.data.first().copied().unwrap_or(0), @@ -508,6 +530,7 @@ impl Io { let now = Instant::now(); let to_str = format!("{}:{}", params.to.host, params.to.port); + let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)); let entry = InflightEntry { tid, to: params.to, @@ -518,9 +541,10 @@ impl Io { buffer, socket_kind, sent: 0, - retries: DEFAULT_RETRIES, - deadline: now + Duration::from_millis(DEFAULT_TIMEOUT_MS), + retries: params.retries.unwrap_or(DEFAULT_RETRIES), + deadline: now + timeout, timestamp: now, + timeout, }; self.inflight.push(entry); @@ -546,6 +570,100 @@ impl Io { Some(tid) } + /// Send a one-shot DHT request out an arbitrary UDP socket (typically a + /// puncher socket). The TID is allocated from the shared registry so the + /// reply still matches via the existing inflight machinery, but no + /// automatic retry will fire — we don't own the override socket beyond + /// this call. Replies must be forwarded back through + /// [`Self::handle_inbound_reply_bytes`]. + pub fn send_request_via_socket( + &mut self, + params: RequestParams, + socket: &UdxSocket, + ) -> Option { + if self.destroying { + return None; + } + + let addr_str = format!("{}:{}", params.to.host, params.to.port); + let addr: SocketAddr = addr_str.parse().ok()?; + + let tid = self.tid; + self.tid = self.tid.wrapping_add(1); + + let include_id = !self.ephemeral; + let id = if include_id { + self.table.lock().ok().map(|t| *t.id()) + } else { + None + }; + + let request = messages::Request { + tid, + to: params.to.clone(), + id, + token: params.token, + internal: params.internal, + command: params.command, + target: params.target, + value: params.value, + }; + + let buffer = match messages::encode_request_to_bytes(&request) { + Ok(b) => b, + Err(e) => { + tracing::warn!(err = %e, "send_request_via_socket: encode failed"); + return None; + } + }; + + let buffer_len = buffer.len() as u64; + if let Err(e) = socket.send_to(&buffer, addr) { + tracing::warn!(err = %e, "send_request_via_socket: send_to failed"); + return None; + } + self.wire.bytes_sent.fetch_add(buffer_len, Ordering::Relaxed); + + self.stats.active += 1; + self.stats.total += 1; + + let now = Instant::now(); + let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)); + + self.inflight.push(InflightEntry { + tid, + to: params.to, + addr, + internal: params.internal, + command: params.command, + target: params.target, + buffer, + socket_kind: SocketKind::Client, + sent: 1, + retries: 0, + deadline: now + timeout, + timestamp: now, + timeout, + }); + + Some(tid) + } + + /// Inject reply bytes received on a non-`Io`-owned socket (puncher + /// socket) into the response-matching path. The actor calls this when + /// a `DhtCommand::InboundReplyBytes` arrives from the puncher-socket + /// demux task. + pub fn handle_inbound_reply_bytes( + &mut self, + addr: SocketAddr, + data: Vec, + ) -> Option { + self.wire + .bytes_received + .fetch_add(data.len() as u64, Ordering::Relaxed); + self.process_datagram(Datagram { data, addr }, SocketKind::Client) + } + /// Send a reply to an incoming request. pub fn send_reply(&mut self, req: &IncomingRequest, error: u64, value: Option<&[u8]>) { let socket_kind = req.reply_ctx.socket_kind; @@ -618,12 +736,20 @@ impl Io { /// Send a fire-and-forget relay request (no inflight tracking, no response). /// Used by the Router to forward PEER_HANDSHAKE/PEER_HOLEPUNCH messages /// to relay targets. + /// + /// When `preserve_tid` is `Some(t)`, the outgoing packet uses tid `t` + /// instead of allocating a fresh one. This is required for relay-back + /// semantics that mirror Node `dht-rpc::Request.relay`: the inbound + /// request's tid is propagated end-to-end so the eventual REPLY matches + /// the original requester's inflight entry. Without tid preservation, + /// relayed responses are silently dropped at intermediate hops. pub fn relay( &mut self, command: u64, target: Option, value: Option>, to: &Ipv4Peer, + preserve_tid: Option, ) -> bool { if self.destroying { return false; @@ -634,8 +760,14 @@ impl Io { Err(_) => return false, }; - let tid = self.tid; - self.tid = self.tid.wrapping_add(1); + let tid = match preserve_tid { + Some(t) => t, + None => { + let t = self.tid; + self.tid = self.tid.wrapping_add(1); + t + } + }; let socket_kind = if self.firewalled { SocketKind::Client @@ -684,6 +816,51 @@ impl Io { true } + /// Send a fire-and-forget REPLY (Response packet) to a specific address, + /// using the provided tid. Mirrors Node `dht-rpc::Request.reply(value, + /// { to })`: the tid is taken from an inbound request whose tid was + /// preserved end-to-end through a relay chain, so the eventual REPLY + /// matches the original requester's inflight entry at the chain's + /// destination. + /// + /// Used by the handshake router's FROM_SERVER case (FE-holder finalising + /// the tid-preserved chain by replying directly to the original client). + /// Unlike [`Self::send_reply`], the destination address is independent + /// of any inbound request; unlike [`Self::send_reply_deferred`], the + /// socket kind is derived from current firewall state rather than passed + /// in via a `ReplyContext`. + pub fn reply_to( + &mut self, + tid: u16, + target: Option, + to: &Ipv4Peer, + value: Option>, + ) -> bool { + if self.destroying { + return false; + } + + let socket_kind = if self.firewalled { + SocketKind::Client + } else { + SocketKind::Server + }; + + self.send_reply_internal( + to, + ReplyInternalParams { + socket_kind, + tid, + target, + error: 0, + include_token: true, + value, + }, + ); + + true + } + /// Destroy the IO layer, closing both sockets. pub async fn destroy(mut self) -> IoResult<()> { self.destroying = true; @@ -786,7 +963,7 @@ impl Io { let (buffer, addr, socket_kind) = { let entry = &mut self.inflight[idx]; entry.sent += 1; - entry.deadline = Instant::now() + Duration::from_millis(DEFAULT_TIMEOUT_MS); + entry.deadline = Instant::now() + entry.timeout; (entry.buffer.clone(), entry.addr, entry.socket_kind) }; diff --git a/peeroxide-dht/src/lib.rs b/peeroxide-dht/src/lib.rs index 6bb1f01..5fa2fae 100644 --- a/peeroxide-dht/src/lib.rs +++ b/peeroxide-dht/src/lib.rs @@ -14,12 +14,12 @@ //! //! | Layer | Module | Reference | //! |---|---|---| -//! | Wire encoding | [`compact_encoding`] | [compact-encoding](https://github.com/holepunchto/compact-encoding) | -//! | DHT RPC | [`rpc`], [`io`], [`query`], [`routing_table`] | [dht-rpc](https://github.com/mafintosh/dht-rpc) | +//! | Wire encoding | `compact_encoding` (crate-internal) | [compact-encoding](https://github.com/holepunchto/compact-encoding) | +//! | DHT RPC | [`rpc`], [`io`], `query` (internal), `routing_table` (internal) | [dht-rpc](https://github.com/mafintosh/dht-rpc) | //! | Peer operations | [`hyperdht`], [`hyperdht_messages`] | [hyperdht](https://github.com/holepunchto/hyperdht) | //! | Noise XX handshake | [`noise`], [`noise_wrap`] | [noise-handshake](https://github.com/holepunchto/noise-handshake) | //! | Encrypted streams | [`secret_stream`], [`secretstream`] | [@hyperswarm/secret-stream](https://github.com/holepunchto/hyperswarm-secret-stream) | -//! | NAT traversal | [`nat`], [`holepuncher`] | hyperdht/lib/holepuncher.js | +//! | NAT traversal | `nat` (internal), [`holepuncher`] | hyperdht/lib/holepuncher.js | //! | Relay | [`blind_relay`], [`protomux`] | [blind-relay](https://github.com/holepunchto/blind-relay) | //! //! # Typical usage @@ -57,11 +57,10 @@ #![deny(clippy::all)] +// ─── Always-public modules (documented) ────────────────────────────────────── + /// Blind relay for proxying encrypted traffic between peers behind restrictive NATs. pub mod blind_relay; -/// Compact binary encoding primitives compatible with the -/// [compact-encoding](https://github.com/holepunchto/compact-encoding) wire format. -pub mod compact_encoding; /// BLAKE2b hashing, Ed25519 signing, and namespace derivation helpers. pub mod crypto; /// High-level HyperDHT node: peer discovery, announce/unannounce, mutable/immutable @@ -78,32 +77,131 @@ pub mod noise; pub mod noise_wrap; /// Lightweight multiplexer for running multiple channels over a single connection. pub mod protomux; +/// Standalone blind-relay server entry point: accepts connections and bridges +/// matched pairings over raw UDX streams (see [`blind_relay`] for the +/// protocol-only engine this wires up to real transport). +pub mod relay_service; /// DHT RPC transport layer: request dispatch, reply handling, and node communication. pub mod rpc; /// Noise-encrypted bidirectional byte stream over any `AsyncRead + AsyncWrite` transport. pub mod secret_stream; -// Internal protocol modules — public for advanced use but hidden from -// top-level docs. Access via `peeroxide_dht::` if needed. -#[doc(hidden)] +// ─── Promoted modules (were #[doc(hidden)], now fully documented public API) ── + +/// NAT hole-punching coordination for peer-to-peer connections. +/// +/// The `Holepuncher` drives the active-side hole-punching state machine used +/// by [`hyperdht::HyperDhtHandle::connect`] to traverse symmetric NATs and +/// reach peers that cannot be dialed directly. It owns the local +/// [`socket_pool::SocketPool`] used for birthday-attack probes, the NAT +/// classification analyzer, and the punch-message dispatch loop that +/// cooperates with the remote peer over a relayed control channel. +/// +/// Most consumers reach this module indirectly through `connect()` and the +/// resulting `PeerConnection`; direct use is only needed when building +/// custom DHT clients that orchestrate the punch sequence themselves. See +/// `hyperdht/lib/holepuncher.js` in the Node.js reference implementation for +/// the protocol shape. pub mod holepuncher; -#[doc(hidden)] +/// IO layer for the DHT-RPC protocol. +/// +/// A faithful Rust port of the Node.js `dht-rpc` IO layer. The `Io` struct +/// tracks in-flight requests, wire counters, and per-request parameters, and +/// is driven by the caller from a `tokio::select!` loop rather than owning +/// its own task. Most consumers reach it only indirectly through +/// [`rpc::DhtHandle`]; direct use is for building alternative RPC transports +/// or low-level protocol tooling. pub mod io; -#[doc(hidden)] -pub mod nat; -#[doc(hidden)] +/// Peer-identity primitives shared across the DHT and swarm layers. +/// +/// Defines `PeerAddr` (an Ed25519-keyed node identity paired with a UDP +/// socket address) and the `peer_id` helper that derives the 32-byte +/// Kademlia node ID from a peer's public key. These types are used by +/// lower-level routing-table and request-routing code; most callers reach +/// them only through cross-language interop tests and DHT-level integration +/// code rather than directly. pub mod peer; -#[doc(hidden)] +/// Persistent storage for DHT records published by server nodes. +/// +/// Provides the `Persistent` handler that backs the four record-storing RPC +/// verbs (`ANNOUNCE`, `UNANNOUNCE`, `MUTABLE_PUT`/`GET`, `IMMUTABLE_PUT`/`GET`) +/// with bounded LRU caches sized via `PersistentConfig`. `PersistentStats` +/// exposes per-cache record counts for operators monitoring a running node. +/// `PersistentConfig` is part of the public API for callers that run their +/// own DHT node (e.g. the `peeroxide node` CLI); the handler itself is +/// plumbed inside [`rpc::DhtHandle`] and not constructed directly by typical +/// consumers. pub mod persistent; -#[doc(hidden)] -pub mod query; -#[doc(hidden)] -pub mod router; -#[doc(hidden)] -pub mod routing_table; -#[doc(hidden)] +/// Pure-Rust implementation of libsodium's `crypto_secretstream_xchacha20poly1305`. +/// +/// Uses a manual ChaCha20 + Poly1305 construction matching libsodium's +/// internal layout exactly (counter=0 generates the Poly1305 key, counter=1 +/// encrypts a 64-byte tag block, counter=2+ encrypts message bytes), backing +/// the encrypted-stream layer used above the Noise handshake. Most +/// consumers reach this only through [`secret_stream`]; direct use is for +/// byte-exact interop with the Node.js `sodium-native` secretstream API. pub mod secretstream; -#[doc(hidden)] +/// Authenticated-encryption helper for short DHT-handshake payloads. +/// +/// `SecurePayload` wraps an XChaCha20-Poly1305 secretbox keyed off a BLAKE2b +/// namespace + remote-secret pairing, and is used by the swarm and +/// connect-handshake paths to attach encrypted application data (peer +/// addresses, holepunch instructions) to otherwise plaintext +/// `PEER_HANDSHAKE` / `PEER_HOLEPUNCH` messages. Errors are reported through +/// `SecurePayloadError`; the encrypted output also carries a short opaque +/// token (`SecurePayload::token`) that callers use to bind a reply to the +/// originating request. pub mod secure_payload; -#[doc(hidden)] +/// Shared UDP socket pool used by NAT hole-punching. +/// +/// `SocketPool` hands out `SocketRef` references to ephemeral UDP sockets +/// bound on local ports, multiplexing receive of incoming `PEER_HOLEPUNCH` +/// probes through a dedicated channel (`HolepunchEvent`). The +/// [`holepuncher::Holepuncher`] uses this pool to launch the birthday-attack +/// probe sequence required to traverse symmetric NATs. Most consumers use +/// the pool indirectly via [`hyperdht::HyperDhtHandle::connect`]; direct use +/// is only needed for custom DHT-server orchestration or low-level +/// hole-punch experiments. pub mod socket_pool; + +// ─── Demoted modules (crate-internal; not part of the published API) ───────── + +pub(crate) mod compact_encoding; +pub(crate) mod nat; +pub(crate) mod query; +pub(crate) mod router; +pub(crate) mod routing_table; + +// ─── Crate-root re-exports for types in pub(crate) modules that surface ─────── +// via public method return types / error variants. Without these re-exports, +// `cargo doc --no-deps` would fail because public signatures reference types +// that are not otherwise reachable from the crate root. + +/// Errors produced by compact-encoding operations. +/// +/// Re-exported from the crate-internal `compact_encoding` module because it +/// appears as a variant of [`hyperdht::HyperDhtError`] and [`RouterError`]. +pub use compact_encoding::{EncodingError, State}; + +/// A reply entry in the iterative-query closest-replies set. +/// +/// Re-exported from the crate-internal `query` module because it is the element +/// type of `Vec` returned by +/// [`rpc::DhtHandle::find_node`], [`rpc::DhtHandle::query`], and +/// [`hyperdht::HyperDhtHandle::query_find_peer`]. +pub use query::QueryReply; + +/// Re-exported router types from the crate-internal `router` module. +/// +/// [`Router`] is returned by [`hyperdht::HyperDhtHandle::router`]. The +/// associated action / result / error types appear in [`Router`]'s public +/// method signatures and in the [`hyperdht::HyperDhtError::Router`] variant. +pub use router::{ + ForwardEntry, + HandshakeAction, + HandshakeResult, + HolepunchAction, + HolepunchResult, + Router, + RouterError, +}; diff --git a/peeroxide-dht/src/messages.rs b/peeroxide-dht/src/messages.rs index e41ec80..27b466f 100644 --- a/peeroxide-dht/src/messages.rs +++ b/peeroxide-dht/src/messages.rs @@ -40,7 +40,7 @@ const FLAG_INTERNAL_OR_CLOSER: u8 = 0b0000_0100; const FLAG_TARGET_OR_ERROR: u8 = 0b0000_1000; const FLAG_VALUE: u8 = 0b0001_0000; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] /// A peer network address (host and port). /// /// Despite the name, this type may carry either an IPv4 or IPv6 address diff --git a/peeroxide-dht/src/nat.rs b/peeroxide-dht/src/nat.rs index 6ff2ab8..4c7cf41 100644 --- a/peeroxide-dht/src/nat.rs +++ b/peeroxide-dht/src/nat.rs @@ -1,5 +1,15 @@ +#![allow(dead_code)] + use crate::hyperdht_messages::{FIREWALL_CONSISTENT, FIREWALL_OPEN, FIREWALL_RANDOM, FIREWALL_UNKNOWN}; use crate::messages::Ipv4Peer; +use crate::rpc::DhtHandle; +use libudx::{Datagram, UdxSocket}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::sync::{Notify, mpsc}; + +pub(crate) const NAT_MIN_SAMPLES: u32 = 4; #[derive(Debug, Clone)] struct Sample { @@ -19,6 +29,9 @@ pub struct Nat { pub sampled: u32, pub firewall: u64, pub addresses: Option>, + + analyzing_notify: Arc, + analyzing_settled: Arc, } impl Nat { @@ -36,11 +49,20 @@ impl Nat { FIREWALL_OPEN }, addresses: None, + analyzing_notify: Arc::new(Notify::new()), + analyzing_settled: Arc::new(AtomicBool::new(!firewalled)), } } pub fn destroy(&mut self) { self.frozen = true; + // Force-settle on destroy. Mirrors Node `nat.js` `destroy()` calling + // `_resolve()` unconditionally so any pending `analyzing` await + // wakes immediately rather than hanging on a NAT that will never + // accumulate more samples. + if !self.analyzing_settled.swap(true, Ordering::AcqRel) { + self.analyzing_notify.notify_waiters(); + } } pub fn freeze(&mut self) { @@ -77,12 +99,39 @@ impl Nat { if (self.sampled >= 3 || !self.firewalled) && !self.frozen { self.update(); } + + self.try_settle(); } pub fn is_settled(&self) -> bool { self.firewall == FIREWALL_CONSISTENT || self.firewall == FIREWALL_OPEN } + pub fn analyzing(&self) -> impl std::future::Future + 'static { + let notify = Arc::clone(&self.analyzing_notify); + let settled = Arc::clone(&self.analyzing_settled); + async move { + if settled.load(Ordering::Acquire) { + return; + } + let waiter = notify.notified(); + tokio::pin!(waiter); + waiter.as_mut().enable(); + if settled.load(Ordering::Acquire) { + return; + } + waiter.await; + } + } + + fn try_settle(&mut self) { + let should = matches!(self.firewall, FIREWALL_OPEN | FIREWALL_CONSISTENT) + || self.sampled >= NAT_MIN_SAMPLES; + if should && !self.analyzing_settled.swap(true, Ordering::AcqRel) { + self.analyzing_notify.notify_waiters(); + } + } + pub fn mark_visited(&mut self, host: &str, port: u16) -> bool { let key = format!("{host}:{port}"); if self.visited.contains_key(&key) { @@ -92,6 +141,84 @@ impl Nat { true } + /// Seed the puncher's NAT classifier with reflexive samples by sending + /// DHT pings out the supplied puncher socket. Mirrors Node's + /// `lib/nat.js:25-79 autoSample()`. + /// + /// Walks `dht.recent_nodes(max_samples + 5)`, fires up to 4 concurrent + /// `dht.ping_via_socket` calls, and feeds each pong's `(to, from)` + /// into [`Self::add`]. Returns the number of NEW samples added. + /// + /// Short-circuits with `0` if the NAT is already settled. Each ping + /// is bound by a ~2s wall-clock timeout (Node-default); on failure + /// (timeout, decode error, channel closed) the sample is skipped and + /// the function continues. + pub(crate) async fn auto_sample( + &mut self, + dht: &DhtHandle, + socket: UdxSocket, + dht_reply_rx: mpsc::UnboundedReceiver, + max_samples: usize, + ) -> usize { + if self.firewall != FIREWALL_UNKNOWN { + drop(dht_reply_rx); + return 0; + } + + let candidates = dht.recent_nodes(max_samples + 5); + if candidates.is_empty() { + drop(dht_reply_rx); + return 0; + } + + let forwarder = spawn_dht_reply_forwarder(dht.clone(), dht_reply_rx); + + let mut joinset: tokio::task::JoinSet> = + tokio::task::JoinSet::new(); + for node in candidates.into_iter().take(max_samples) { + let dht_clone = dht.clone(); + let socket_clone = socket.clone(); + joinset.spawn(async move { + tokio::time::timeout( + Duration::from_millis(2000), + dht_clone.ping_via_socket(node, socket_clone), + ) + .await + .map_err(|_| crate::rpc::DhtError::ChannelClosed)? + }); + } + + let mut added = 0usize; + let before = self.sampled; + while let Some(joined) = joinset.join_next().await { + match joined { + Ok(Ok(resp)) => { + let Some(to) = resp.to.as_ref() else { + tracing::warn!(from = %format!("{}:{}", resp.from.host, resp.from.port), "auto_sample: ping reply missing reflexive `to`; skipping"); + continue; + }; + self.add(to, &resp.from); + if self.sampled > before + added as u32 { + added = (self.sampled - before) as usize; + } + if self.firewall != FIREWALL_UNKNOWN { + break; + } + } + Ok(Err(e)) => { + tracing::warn!(err = ?e, "auto_sample: ping_via_socket failed"); + } + Err(e) => { + tracing::warn!(err = %e, "auto_sample: join error"); + } + } + } + + joinset.abort_all(); + forwarder.abort(); + added + } + fn update_firewall(&mut self) { if !self.firewalled { self.firewall = FIREWALL_OPEN; @@ -170,6 +297,20 @@ impl Nat { } } +/// Bridge raw `dht_reply_rx` datagrams from the puncher socket into the +/// `DhtNode` actor via [`DhtHandle::forward_inbound_reply_bytes`]. Spawned +/// for the duration of one [`Nat::auto_sample`] run; aborted on return. +fn spawn_dht_reply_forwarder( + dht: DhtHandle, + mut rx: mpsc::UnboundedReceiver, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(dgram) = rx.recv().await { + dht.forward_inbound_reply_bytes(dgram.addr, dgram.data); + } + }) +} + fn add_sample(samples: &mut Vec, host: &str, port: u16) { for i in 0..samples.len() { if samples[i].port != port || samples[i].host != host { @@ -375,4 +516,166 @@ mod tests { let addrs = nat.addresses.as_ref().unwrap(); assert!(addrs.len() >= 2); } + + #[tokio::test(flavor = "current_thread")] + async fn analyzing_resolves_on_consistent() { + let mut nat = Nat::new(true); + let analyzing = nat.analyzing(); + + nat.add(&peer("1.2.3.4", 5000), &peer("100.0.0.1", 1)); + nat.add(&peer("1.2.3.4", 5000), &peer("100.0.0.2", 1)); + nat.add(&peer("1.2.3.4", 5000), &peer("100.0.0.3", 1)); + + assert_eq!(nat.firewall, FIREWALL_CONSISTENT); + + tokio::time::timeout(std::time::Duration::from_millis(100), analyzing) + .await + .expect("analyzing did not resolve after CONSISTENT classification"); + } + + #[tokio::test(flavor = "current_thread")] + async fn analyzing_resolves_on_min_samples_exhaustion() { + let mut nat = Nat::new(true); + let analyzing = nat.analyzing(); + + nat.add(&peer("1.2.3.4", 1), &peer("100.0.0.1", 1)); + nat.add(&peer("1.2.3.4", 2), &peer("100.0.0.2", 1)); + nat.add(&peer("1.2.3.4", 3), &peer("100.0.0.3", 1)); + nat.add(&peer("1.2.3.4", 4), &peer("100.0.0.4", 1)); + + assert!(nat.sampled >= NAT_MIN_SAMPLES); + + tokio::time::timeout(std::time::Duration::from_millis(100), analyzing) + .await + .expect("analyzing did not resolve after MIN_SAMPLES exhaustion"); + } + + #[tokio::test(flavor = "current_thread")] + async fn analyzing_resolves_on_destroy() { + let mut nat = Nat::new(true); + let analyzing = nat.analyzing(); + + nat.destroy(); + + tokio::time::timeout(std::time::Duration::from_millis(100), analyzing) + .await + .expect("analyzing did not resolve after destroy()"); + } + + #[tokio::test(flavor = "current_thread")] + async fn settled_state_after_three_consistent_samples() { + let mut nat = Nat::new(true); + nat.add(&peer("1.2.3.4", 5000), &peer("10.0.0.1", 1)); + nat.add(&peer("1.2.3.4", 5000), &peer("10.0.0.2", 2)); + nat.add(&peer("1.2.3.4", 5000), &peer("10.0.0.3", 3)); + assert_eq!(nat.firewall, FIREWALL_CONSISTENT); + assert!( + nat.firewall != FIREWALL_UNKNOWN, + "settled NAT must not be UNKNOWN — auto_sample short-circuits in this state" + ); + } + + /// Drives `Nat::auto_sample` end-to-end against real loopback DHT nodes + /// (no internet required). A primary node's routing table is seeded + /// with `NAT_MIN_SAMPLES` local peer nodes via + /// `DhtHandle::insert_node_for_test` (a test-only helper), each backed + /// by a genuine `rpc::spawn`-ed DHT actor bound to `127.0.0.1` that can + /// answer the internal `ping_via_socket` FIND_NODE probe. This exercises + /// the concurrent-ping, reflexive-`to`/`from` sample-collection, and + /// puncher-socket reply-forwarding paths without any mock trait — the + /// "mock" is simply a same-host, no-bootstrap DHT peer. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn auto_sample_collects_target_samples() { + use crate::routing_table::Node; + use crate::socket_pool::SocketPool; + + let runtime = libudx::UdxRuntime::new().expect("runtime"); + + // Primary node under test: firewalled, no bootstrap, ephemeral. + let primary_cfg = crate::rpc::DhtConfig { + bootstrap: vec![], + host: "127.0.0.1".to_string(), + ephemeral: Some(true), + firewalled: true, + ..crate::rpc::DhtConfig::default() + }; + let (_primary_task, primary) = crate::rpc::spawn(&runtime, primary_cfg) + .await + .expect("spawn primary"); + + // Ping targets: independent loopback DHT nodes that can answer a + // FIND_NODE probe sent via a puncher socket. + let target_count = NAT_MIN_SAMPLES as usize; + let mut target_tasks = Vec::new(); + let mut target_handles = Vec::new(); + for _ in 0..target_count { + let cfg = crate::rpc::DhtConfig { + bootstrap: vec![], + host: "127.0.0.1".to_string(), + ephemeral: Some(true), + firewalled: false, + ..crate::rpc::DhtConfig::default() + }; + let (task, handle) = crate::rpc::spawn(&runtime, cfg).await.expect("spawn target"); + target_tasks.push(task); + target_handles.push(handle); + } + + // Seed the primary's routing table directly (bypassing discovery) + // so `recent_nodes()` returns our loopback targets deterministically. + for handle in &target_handles { + let port = handle.local_port().await.expect("target local_port"); + let node = Node { + id: rand::random(), + host: "127.0.0.1".to_string(), + port, + token: None, + added_tick: 0, + seen_tick: 0, + pinged_tick: 0, + down_hints: 0, + }; + primary.insert_node_for_test(node); + } + + // Acquire a puncher socket exactly as production code does, wiring + // its DHT-reply lane through to `auto_sample`. + let pool = SocketPool::new("127.0.0.1".to_string()); + let mut socket_ref = pool.acquire(&runtime).await.expect("acquire puncher socket"); + let socket = socket_ref.socket.clone(); + let dht_reply_rx = socket_ref + .take_dht_reply_rx() + .expect("dht_reply_rx available"); + + let mut nat = Nat::new(true); + let added = tokio::time::timeout( + Duration::from_secs(10), + nat.auto_sample(&primary, socket, dht_reply_rx, target_count), + ) + .await + .expect("auto_sample timed out"); + + // All targets share one reflexive loopback address, so the NAT + // classifier can settle on FIREWALL_CONSISTENT after 3 matching + // samples and `auto_sample` breaks out early (mirrors Node's + // early-exit once `self.firewall != FIREWALL_UNKNOWN`) — it need + // not visit every candidate to prove samples are being collected. + assert!( + added >= 3, + "expected auto_sample to collect at least 3 samples from reachable loopback targets, got {added}" + ); + assert!( + nat.sampled >= 3, + "Nat::add should have been driven by successful ping_via_socket replies" + ); + assert_eq!( + nat.firewall, FIREWALL_CONSISTENT, + "identical reflexive address across all targets should settle as CONSISTENT" + ); + + let _ = primary.destroy().await; + for handle in &target_handles { + let _ = handle.destroy().await; + } + } } diff --git a/peeroxide-dht/src/noise.rs b/peeroxide-dht/src/noise.rs index 6519f6a..61c5ef3 100644 --- a/peeroxide-dht/src/noise.rs +++ b/peeroxide-dht/src/noise.rs @@ -313,6 +313,7 @@ pub struct Keypair { /// Session keys and metadata produced after a completed Noise XX handshake. #[derive(Clone)] +#[non_exhaustive] pub struct HandshakeResult { /// Session key for outbound messages (encrypt with this). pub tx: [u8; 32], diff --git a/peeroxide-dht/src/peer.rs b/peeroxide-dht/src/peer.rs index 0e534c1..b7cd019 100644 --- a/peeroxide-dht/src/peer.rs +++ b/peeroxide-dht/src/peer.rs @@ -1,3 +1,15 @@ +//! Peer-identity primitives shared across the DHT and swarm layers. +//! +//! Defines `PeerAddr` (an Ed25519-keyed node identity paired with a +//! UDP socket address) and the `peer_id` helper that derives the +//! 32-byte Kademlia node ID from a peer's public key. +//! +//! These types are used by lower-level routing-table and request-routing +//! code; most callers reach them only through cross-language interop tests +//! and DHT-level integration code rather than directly. + +#![allow(missing_docs)] + use blake2::digest::consts::U32; use blake2::digest::Digest; use blake2::Blake2b; diff --git a/peeroxide-dht/src/persistent.rs b/peeroxide-dht/src/persistent.rs index b5c22a8..7eece0c 100644 --- a/peeroxide-dht/src/persistent.rs +++ b/peeroxide-dht/src/persistent.rs @@ -1,3 +1,16 @@ +//! Persistent storage for DHT records published by server nodes. +//! +//! Provides the `Persistent` handler that backs the four record-storing +//! RPC verbs (`ANNOUNCE`, `UNANNOUNCE`, `MUTABLE_PUT`/`GET`, `IMMUTABLE_PUT`/`GET`) +//! with bounded LRU caches sized via `PersistentConfig`. `PersistentStats` +//! exposes per-cache record counts for operators monitoring a running node. +//! +//! The `PersistentConfig` type is part of the public API for callers that +//! run their own DHT node (e.g. the `peeroxide node` CLI). The handler itself +//! is plumbed inside [`crate::rpc::DhtHandle`] and not constructed directly +//! by typical consumers. + +#![allow(missing_docs)] #![deny(clippy::all)] use std::collections::HashMap; diff --git a/peeroxide-dht/src/protomux.rs b/peeroxide-dht/src/protomux.rs index dc77ad5..1127cbd 100644 --- a/peeroxide-dht/src/protomux.rs +++ b/peeroxide-dht/src/protomux.rs @@ -434,6 +434,7 @@ struct ChannelHandle { /// /// Channels are created via [`Mux::create_channel`] and support sending /// and receiving typed messages. +#[non_exhaustive] pub struct Channel { local_id: u32, protocol: String, @@ -533,6 +534,7 @@ impl Channel { /// Handle for interacting with a running Protomux instance. /// /// Create with [`Mux::new`], which returns this handle and a future to spawn. +#[non_exhaustive] pub struct Mux { cmd_tx: mpsc::UnboundedSender, } diff --git a/peeroxide-dht/src/query.rs b/peeroxide-dht/src/query.rs index 759ba99..86f6f72 100644 --- a/peeroxide-dht/src/query.rs +++ b/peeroxide-dht/src/query.rs @@ -3,6 +3,8 @@ //! Faithful Rust port of `dht-rpc/lib/query.js`. //! A `Query` is a state machine driven by the DHT node event loop. +#![allow(missing_docs)] +#![allow(dead_code)] #![deny(clippy::all)] use std::collections::HashMap; @@ -390,6 +392,8 @@ impl Query { command: self.command, target: Some(self.target), value: self.value.clone(), + timeout_ms: None, + retries: None, })); } @@ -444,6 +448,8 @@ impl Query { command: self.command, target: Some(self.target), value: self.value.clone(), + timeout_ms: None, + retries: None, }) }) .collect(); @@ -621,6 +627,8 @@ fn make_down_hint(to: &Ipv4Peer, down: &Ipv4Peer) -> Option { command: DOWN_HINT_CMD, target: None, value: Some(value), + timeout_ms: None, + retries: None, })) } diff --git a/peeroxide-dht/src/relay_service.rs b/peeroxide-dht/src/relay_service.rs new file mode 100644 index 0000000..53ec458 --- /dev/null +++ b/peeroxide-dht/src/relay_service.rs @@ -0,0 +1,495 @@ +//! Standalone entry point that runs a blind-relay server on top of a +//! [`HyperDhtHandle`]. +//! +//! This module wires the protocol-only engine in [`crate::blind_relay`] to +//! real transport: it accepts incoming `PEER_HANDSHAKE` requests (via +//! [`ServerEvent`]), finalizes each into an encrypted connection, opens a +//! `"blind-relay"` Protomux channel on it, and drives a +//! [`BlindRelaySession`] against a shared [`BlindRelayServer`]. When a +//! pairing matches, it creates the two raw UDX data-plane streams and +//! bridges them with [`UdxStream::relay_to`] (packet-level, blind — +//! peeroxide never decrypts relayed application data). +//! +//! Deliberately independent of `peeroxide::Swarm`: a relay has no topics, +//! no peer discovery, and no retry bookkeeping, so this reimplements only +//! the minimal "accept a handshake, finalize a connection" path rather than +//! reusing `Swarm`'s internals. See `docs`/plan notes for the rationale. +//! +//! Holepunching is intentionally out of scope for v1: a relay is expected +//! to run with a directly reachable address (matches how +//! `blind-relay-service`'s reference deployment works — a plain +//! `dht.createServer()` with no NAT-traversal staging on the relay's own +//! control connections). + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use libudx::{RuntimeHandle, UdxRuntime, UdxStream}; +use rand::Rng; +use tokio::sync::mpsc; + +use crate::blind_relay::{ + BlindRelayServer, BlindRelayServerConfig, BlindRelaySession, MatchedPairing, SessionOutbound, +}; +use crate::hyperdht::{HyperDhtError, HyperDhtHandle, KeyPair, ServerEvent}; +use crate::hyperdht_messages::{ + encode_handshake_to_bytes, FIREWALL_UNKNOWN, HandshakeMessage, MODE_FROM_RELAY, + MODE_FROM_SECOND_RELAY, MODE_FROM_SERVER, MODE_REPLY, NoisePayload, SecretStreamInfo, UdxInfo, +}; +use crate::noise::Keypair as NoiseKeypair; +use crate::noise_wrap::NoiseWrap; +use crate::protomux::Mux; +use crate::secret_stream::SecretStream; + +static NEXT_RELAY_STREAM_ID: AtomicU32 = AtomicU32::new(1); + +fn next_relay_stream_id() -> u32 { + NEXT_RELAY_STREAM_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Configuration for [`run_relay_server`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct RelayServiceConfig { + /// Limits/timeouts for the shared [`BlindRelayServer`] engine. + pub relay: BlindRelayServerConfig, + /// How often to sweep expired (timed-out) pending pairings. + pub sweep_interval: Duration, + /// Firewall state advertised to connecting peers in the noise reply. + /// A relay is expected to run reachably, so this defaults to + /// `FIREWALL_UNKNOWN` (matches Node's default — the relay doesn't + /// participate in NAT classification for its own control connections). + pub firewall: u64, +} + +impl Default for RelayServiceConfig { + fn default() -> Self { + Self { + relay: BlindRelayServerConfig::default(), + sweep_interval: Duration::from_secs(30), + firewall: FIREWALL_UNKNOWN, + } + } +} + +/// Errors from running the relay service. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RelayServiceError { + /// The DHT server-event channel closed unexpectedly. + #[error("DHT server-event channel closed")] + ChannelClosed, +} + +/// Run a blind-relay server against an already-spawned [`HyperDhtHandle`]. +/// +/// Consumes `server_rx` (the `ServerEvent` receiver returned by +/// `hyperdht::spawn`) until it closes or the returned `shutdown_rx` fires. +/// Returns the shared [`BlindRelayServer`] handle (for stats/inspection — +/// e.g. periodic logging) alongside the driving task. +pub fn run_relay_server( + runtime_handle: Arc, + dht: HyperDhtHandle, + key_pair: KeyPair, + mut server_rx: mpsc::UnboundedReceiver, + config: RelayServiceConfig, +) -> (BlindRelayServer, tokio::task::JoinHandle<()>) { + // Register our own identity so the DHT layer's handshake router treats + // inbound `PEER_HANDSHAKE` requests targeting `hash(public_key)` as + // "handle locally" instead of replying CLOSER_NODES (mirrors + // `peeroxide::swarm`'s `do_join(server: true)` — without this, nothing + // ever reaches this module's handshake handler at all). + let self_target = crate::crypto::hash(&key_pair.public_key); + dht.register_server(&self_target); + + // Self-announce our identity (`hash(public_key)`) to the DHT, matching + // Node's `Server.listen()` which internally starts an `Announcer` for + // the server's own target. Without this, `register_server` only makes + // *this* node answer PEER_HANDSHAKE locally once a request arrives — + // but nothing tells the rest of the network we exist, so a remote + // client's `dht.connect(pubkey)`/`findPeer` walk (which is a + // LOOKUP-style query for an announced record on `hash(pubkey)`, not a + // raw closest-node walk) never surfaces us as a candidate at all and + // fails with PEER_NOT_FOUND — confirmed empirically against Node's + // reference `hyperdht` client. Mirrors + // `peeroxide::peer_discovery::do_refresh`'s "self_announce" pattern. + let announce_dht = dht.clone(); + let announce_key_pair = key_pair.clone(); + tokio::spawn(async move { + const REFRESH_INTERVAL: Duration = Duration::from_secs(600); + const REFRESH_JITTER_MS: u64 = 120_000; + + loop { + let relays = announce_dht.current_relay_addresses(); + match announce_dht + .announce(self_target, &announce_key_pair, &relays) + .await + { + Ok(r) => { + tracing::debug!(closest = r.closest_nodes.len(), "relay: self-announce complete"); + } + Err(e) => { + tracing::warn!(err = %e, "relay: self-announce failed"); + } + } + + let jitter_ms = rand::rng().random_range(0..REFRESH_JITTER_MS); + tokio::time::sleep(REFRESH_INTERVAL + Duration::from_millis(jitter_ms)).await; + } + }); + + let relay = BlindRelayServer::new(config.relay.clone()); + let relay_for_task = relay.clone(); + + let (matched_tx, matched_rx) = mpsc::unbounded_channel::(); + + // Background task: wires the raw UDX data-plane streams for every + // matched pairing and bridges them bidirectionally. + let bridge_runtime_handle = Arc::clone(&runtime_handle); + let bridge_dht = dht.clone(); + let bridge_relay = relay.clone(); + tokio::spawn(bridge_matched_pairings( + bridge_runtime_handle, + bridge_dht, + bridge_relay, + matched_rx, + )); + + // Background task: periodically sweep pending pairings that timed out + // and sessions idle past `idle_session_timeout` (peeroxide-only — see + // BlindRelayServerConfig::idle_session_timeout). + let sweep_relay = relay.clone(); + let sweep_interval = config.sweep_interval; + tokio::spawn(async move { + let mut ticker = tokio::time::interval(sweep_interval); + ticker.tick().await; // skip immediate first tick + loop { + ticker.tick().await; + sweep_relay.sweep_expired_pairings(); + sweep_relay.sweep_idle_sessions(); + } + }); + + let firewall = config.firewall; + let task = tokio::spawn(async move { + while let Some(event) = server_rx.recv().await { + match event { + ServerEvent::PeerHandshake { + msg, + from: _, + peer_address, + target: _, + reply_tx, + } => { + handle_handshake( + &runtime_handle, + &dht, + &key_pair, + firewall, + &relay_for_task, + &matched_tx, + msg, + peer_address, + reply_tx, + ) + .await; + } + ServerEvent::PeerHolepunch { reply_tx, .. } => { + // Not supported in v1 — see module docs. Politely decline. + let _ = reply_tx.send(None); + } + } + } + }); + + (relay, task) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_handshake( + runtime_handle: &Arc, + dht: &HyperDhtHandle, + key_pair: &KeyPair, + firewall: u64, + relay: &BlindRelayServer, + matched_tx: &mpsc::UnboundedSender, + msg: HandshakeMessage, + peer_address: Option, + reply_tx: tokio::sync::oneshot::Sender>>, +) { + let noise_kp = NoiseKeypair { + public_key: key_pair.public_key, + secret_key: key_pair.secret_key, + }; + let mut nw = NoiseWrap::new_responder(noise_kp); + + tracing::debug!(mode = msg.mode, noise_len = msg.noise.len(), "relay: received handshake request"); + + let remote_payload = match nw.recv(&msg.noise) { + Ok(p) => p, + Err(e) => { + tracing::debug!(err = %e, "relay handshake: noise recv failed"); + let _ = reply_tx.send(None); + return; + } + }; + + if remote_payload.error != 0 { + tracing::debug!(error = remote_payload.error, "relay handshake: remote reported error"); + let _ = reply_tx.send(None); + return; + } + + let local_stream_id = next_relay_stream_id(); + let addresses4 = dht.noise_addresses4(dht.local_port().await.unwrap_or(0)).await; + + let reply_payload = NoisePayload { + version: 1, + error: 0, + firewall, + holepunch: None, + addresses4, + addresses6: vec![], + udx: Some(UdxInfo { + version: 1, + reusable_socket: true, + id: u64::from(local_stream_id), + seq: 0, + }), + secret_stream: Some(SecretStreamInfo { version: 1 }), + relay_through: None, + relay_addresses: None, + }; + + let noise_reply = match nw.send(&reply_payload) { + Ok(b) => b, + Err(e) => { + tracing::debug!(err = %e, "relay handshake: noise send failed"); + let _ = reply_tx.send(None); + return; + } + }; + + let nw_result = match nw.finalize() { + Ok(r) => r, + Err(e) => { + tracing::debug!(err = %e, "relay handshake: noise finalize failed"); + let _ = reply_tx.send(None); + return; + } + }; + + let (reply_mode, reply_peer_address) = match msg.mode { + MODE_FROM_RELAY | MODE_FROM_SECOND_RELAY => (MODE_FROM_SERVER, peer_address.clone()), + _ => (MODE_REPLY, None), + }; + + let reply_msg = HandshakeMessage { + mode: reply_mode, + noise: noise_reply, + peer_address: reply_peer_address, + relay_address: None, + }; + let _ = reply_tx.send(encode_handshake_to_bytes(&reply_msg).ok()); + + let Some(remote_udx) = remote_payload.udx else { + tracing::debug!("relay: connecting peer advertised no UDX info"); + return; + }; + + let runtime_handle = Arc::clone(runtime_handle); + let dht = dht.clone(); + let relay = relay.clone(); + let matched_tx = matched_tx.clone(); + let remote_pk = nw_result.remote_public_key; + + tokio::spawn(async move { + match finalize_relay_connection(runtime_handle, &dht, local_stream_id, &remote_udx, &nw_result) + .await + { + Ok(mux) => { + run_relay_session(relay, mux, remote_pk, matched_tx).await; + } + Err(e) => { + tracing::debug!(err = %e, "relay: connection finalize failed"); + } + } + }); +} + +/// Finalize the accepted control connection: bind the raw UDX stream the +/// client dialed into, complete the Noise/SecretStream handshake, and wrap +/// the result in a Protomux [`Mux`] ready for a `"blind-relay"` channel. +/// +/// Mirrors `peeroxide::swarm::create_server_connection` (kept independent — +/// see module docs). +async fn finalize_relay_connection( + runtime_handle: Arc, + dht: &HyperDhtHandle, + local_stream_id: u32, + remote_udx: &UdxInfo, + noise_result: &crate::noise_wrap::NoiseWrapResult, +) -> Result { + let runtime = UdxRuntime::shared(runtime_handle); + + let remote_id = u32::try_from(remote_udx.id) + .map_err(|_| HyperDhtError::StreamEstablishment("remote UDX id out of u32 range".into()))?; + + let socket = dht + .server_socket() + .await? + .ok_or_else(|| HyperDhtError::StreamEstablishment("DHT server socket not available".into()))?; + + let stream = runtime.create_stream(local_stream_id).await?; + stream.set_firewall_hook(&socket, remote_id, |_, _, _| true)?; + + let async_stream = stream.into_async_stream(); + let ss = SecretStream::from_session( + false, + async_stream, + noise_result.tx, + noise_result.rx, + noise_result.handshake_hash, + noise_result.remote_public_key, + ) + .await + .map_err(HyperDhtError::SecretStream)?; + + let (mux, run) = Mux::new(ss); + tokio::spawn(run); + Ok(mux) +} + +async fn run_relay_session( + relay: BlindRelayServer, + mux: Mux, + remote_public_key: [u8; 32], + matched_tx: mpsc::UnboundedSender, +) { + let channel = match mux + .create_channel( + crate::blind_relay::PROTOCOL_NAME, + Some(remote_public_key.to_vec()), + None, + ) + .await + { + Ok(c) => c, + Err(e) => { + tracing::debug!(err = %e, "relay: failed to open blind-relay channel"); + return; + } + }; + + let Some(mut session) = BlindRelaySession::new(relay, channel) else { + tracing::warn!("relay: rejecting session — at max_sessions capacity"); + return; + }; + + if session.wait_opened().await.is_err() { + return; + } + + session.run(&matched_tx).await; +} + +/// Shared storage for bridged (active) pairings' raw stream pairs, keyed +/// by relay token. +type ActiveStreamMap = Arc>>; + +/// Consume matched pairings, creating and bridging the two raw UDX +/// data-plane streams for each (mirrors Node's `createStream()` + +/// `stream.relayTo(remote.stream)`, both directions). +/// +/// Bridged streams are kept in `active`, keyed by token, until an explicit +/// teardown signal arrives (from `BlindRelayServer::unpair` on an active +/// pairing, or `release_session` when either side's session closes — +/// both registered via `BlindRelayServer::mark_active`). Dropping a +/// `UdxStream` aborts its packet-forwarding task (confirmed earlier this +/// session), so removing an entry here is enough to stop forwarding and +/// free the resources. +async fn bridge_matched_pairings( + runtime_handle: Arc, + dht: HyperDhtHandle, + relay: BlindRelayServer, + mut matched_rx: mpsc::UnboundedReceiver, +) { + let active: ActiveStreamMap = Arc::new(tokio::sync::Mutex::new(HashMap::new())); + + while let Some(matched) = matched_rx.recv().await { + let runtime_handle = Arc::clone(&runtime_handle); + let dht = dht.clone(); + let relay = relay.clone(); + let active = Arc::clone(&active); + tokio::spawn(async move { + let token = matched.token; + let session_ids = [matched.first.session_id, matched.second.session_id]; + match bridge_one_pairing(runtime_handle, &dht, matched).await { + Ok(pair) => { + active.lock().await.insert(token, pair); + + let (teardown_tx, mut teardown_rx) = mpsc::unbounded_channel::<()>(); + relay.mark_active(token, session_ids, teardown_tx); + + // Wait for the teardown signal, then drop the stream + // pair (stopping forwarding) and free the map entry. + let _ = teardown_rx.recv().await; + active.lock().await.remove(&token); + } + Err(e) => tracing::debug!(err = %e, "relay: failed to bridge matched pairing"), + } + }); + } +} + +async fn bridge_one_pairing( + runtime_handle: Arc, + dht: &HyperDhtHandle, + matched: MatchedPairing, +) -> Result<(UdxStream, UdxStream), HyperDhtError> { + let runtime = UdxRuntime::shared(runtime_handle); + let socket = dht + .server_socket() + .await? + .ok_or_else(|| HyperDhtError::StreamEstablishment("DHT server socket not available".into()))?; + + let first_local_id = next_relay_stream_id(); + let second_local_id = next_relay_stream_id(); + + let first_remote_id = u32::try_from(matched.first.client_stream_id) + .map_err(|_| HyperDhtError::StreamEstablishment("client stream id out of range".into()))?; + let second_remote_id = u32::try_from(matched.second.client_stream_id) + .map_err(|_| HyperDhtError::StreamEstablishment("client stream id out of range".into()))?; + + let first_stream = runtime.create_stream(first_local_id).await?; + first_stream.set_firewall_hook(&socket, first_remote_id, |_, _, _| true)?; + + let second_stream = runtime.create_stream(second_local_id).await?; + second_stream.set_firewall_hook(&socket, second_remote_id, |_, _, _| true)?; + + // Blind, bidirectional packet-level forwarding — peeroxide never reads + // or decrypts the relayed application data. Safe to wire up before + // either side's first packet arrives: `UdxStream::process_incoming` + // runs the firewall-hook gate (single-fire 4-tuple adoption) *before* + // checking `relay_target`, so the packet that fires a stream's hook + // still gets forwarded on this same pass rather than being absorbed + // into normal (unread) stream processing — see the ordering comment + // in `libudx::native::stream::process_incoming`. + first_stream.relay_to(&second_stream)?; + second_stream.relay_to(&first_stream)?; + + let _ = matched.first.outbound_tx.send(SessionOutbound::PairMatched { + token: matched.token, + is_initiator: matched.first.is_initiator, + local_stream_id: u64::from(first_local_id), + }); + let _ = matched.second.outbound_tx.send(SessionOutbound::PairMatched { + token: matched.token, + is_initiator: matched.second.is_initiator, + local_stream_id: u64::from(second_local_id), + }); + + Ok((first_stream, second_stream)) +} diff --git a/peeroxide-dht/src/router.rs b/peeroxide-dht/src/router.rs index ccd6f36..960e7ab 100644 --- a/peeroxide-dht/src/router.rs +++ b/peeroxide-dht/src/router.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs)] + use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -45,6 +47,8 @@ pub struct HolepunchResult { pub enum HandshakeAction { Reply(Vec), Relay { value: Vec, to: Ipv4Peer }, + ForwardRequest { value: Vec, to: Ipv4Peer }, + ReplyTo { value: Vec, to: Ipv4Peer }, HandleLocally(HandshakeMessage), CloserNodes, Drop, @@ -55,6 +59,8 @@ pub enum HandshakeAction { pub enum HolepunchAction { Reply { value: Vec, to: Ipv4Peer }, Relay { value: Vec, to: Ipv4Peer }, + ForwardRequest { value: Vec, to: Ipv4Peer }, + ReplyTo { value: Vec, to: Ipv4Peer }, HandleLocally { msg: HolepunchMessage, peer_address: Ipv4Peer, @@ -230,7 +236,7 @@ impl Router { }; let encoded = hyperdht_messages::encode_handshake_to_bytes(&relayed)?; - Ok(HandshakeAction::Relay { + Ok(HandshakeAction::ForwardRequest { value: encoded, to: target_addr, }) @@ -252,7 +258,7 @@ impl Router { relay_address: Some(from.clone()), }; let encoded = hyperdht_messages::encode_handshake_to_bytes(&relayed)?; - Ok(HandshakeAction::Relay { + Ok(HandshakeAction::ForwardRequest { value: encoded, to: relay_addr, }) @@ -271,8 +277,7 @@ impl Router { relay_address: None, }; let encoded = hyperdht_messages::encode_handshake_to_bytes(&reply)?; - // Reply to the original client at peer_address - Ok(HandshakeAction::Relay { + Ok(HandshakeAction::ReplyTo { value: encoded, to: peer_address, }) @@ -310,7 +315,7 @@ impl Router { peer_address: Some(from.clone()), }; let encoded = hyperdht_messages::encode_holepunch_msg_to_bytes(&relayed)?; - Ok(HolepunchAction::Relay { + Ok(HolepunchAction::ForwardRequest { value: encoded, to, }) @@ -338,7 +343,7 @@ impl Router { peer_address: Some(from.clone()), }; let encoded = hyperdht_messages::encode_holepunch_msg_to_bytes(&reply)?; - Ok(HolepunchAction::Reply { + Ok(HolepunchAction::ReplyTo { value: encoded, to: peer_address, }) @@ -700,14 +705,14 @@ mod tests { let action = router.route_handshake(Some(&key), &from, &encoded).unwrap(); match action { - HandshakeAction::Relay { value, to } => { + HandshakeAction::ForwardRequest { value, to } => { assert_eq!(to.host, "9.9.9.9"); assert_eq!(to.port, 5000); let decoded = hyperdht_messages::decode_handshake_from_bytes(&value).unwrap(); assert_eq!(decoded.mode, MODE_FROM_RELAY); assert_eq!(decoded.peer_address.unwrap().host, "2.3.4.5"); } - other => panic!("Expected Relay, got {other:?}"), + other => panic!("Expected ForwardRequest, got {other:?}"), } } @@ -802,11 +807,13 @@ mod tests { .route_handshake(Some(&pk_hash), &peer("8.8.8.8", 6000), &encoded) .unwrap(); match action { - HandshakeAction::Relay { to, .. } => { + HandshakeAction::ForwardRequest { to, .. } => { assert_eq!(to.host, "10.0.0.5"); assert_eq!(to.port, 4000); } - other => panic!("Expected Relay to server via self-announce entry, got {other:?}"), + other => panic!( + "Expected ForwardRequest to server via self-announce entry, got {other:?}" + ), } } @@ -875,13 +882,13 @@ mod tests { .route_handshake(None, &from, &encoded) .unwrap(); match action { - HandshakeAction::Relay { value, to } => { + HandshakeAction::ReplyTo { value, to } => { assert_eq!(to.host, "2.3.4.5"); let decoded = hyperdht_messages::decode_handshake_from_bytes(&value).unwrap(); assert_eq!(decoded.mode, MODE_REPLY); assert_eq!(decoded.peer_address.unwrap().host, "5.5.5.5"); } - other => panic!("Expected Relay (as reply-to), got {other:?}"), + other => panic!("Expected ReplyTo (final REPLY-to-client), got {other:?}"), } } @@ -910,14 +917,14 @@ mod tests { .route_holepunch(Some(&key), &peer("2.3.4.5", 3000), &encoded) .unwrap(); match action { - HolepunchAction::Relay { value, to } => { + HolepunchAction::ForwardRequest { value, to } => { assert_eq!(to.host, "9.9.9.9"); let decoded = hyperdht_messages::decode_holepunch_msg_from_bytes(&value).unwrap(); assert_eq!(decoded.mode, MODE_FROM_RELAY); assert_eq!(decoded.id, 42); assert_eq!(decoded.peer_address.unwrap().host, "2.3.4.5"); } - other => panic!("Expected Relay, got {other:?}"), + other => panic!("Expected ForwardRequest, got {other:?}"), } } @@ -937,13 +944,13 @@ mod tests { .route_holepunch(None, &peer("5.5.5.5", 7000), &encoded) .unwrap(); match action { - HolepunchAction::Reply { value, to } => { + HolepunchAction::ReplyTo { value, to } => { assert_eq!(to.host, "2.3.4.5"); let decoded = hyperdht_messages::decode_holepunch_msg_from_bytes(&value).unwrap(); assert_eq!(decoded.mode, MODE_REPLY); assert_eq!(decoded.id, 99); } - other => panic!("Expected Reply, got {other:?}"), + other => panic!("Expected ReplyTo (final REPLY-to-client), got {other:?}"), } } diff --git a/peeroxide-dht/src/routing_table.rs b/peeroxide-dht/src/routing_table.rs index ca22445..8e73d0a 100644 --- a/peeroxide-dht/src/routing_table.rs +++ b/peeroxide-dht/src/routing_table.rs @@ -278,6 +278,29 @@ impl RoutingTable { Some(&bucket.nodes[node_idx]) } + /// Return up to `limit` nodes ordered by `seen_tick` descending (most + /// recently seen first). + /// + /// When the table holds 8 or more nodes the 5 most-recently-seen are + /// skipped; they are likely nodes engaged in current traffic and should + /// not be re-used as NAT-sample ping targets. Mirrors the candidate + /// selection in `lib/nat.js:31-59` of the Node.js reference. + #[allow(dead_code)] + pub(crate) fn recent(&self, limit: usize) -> Vec<&Node> { + if limit == 0 { + return Vec::new(); + } + let mut all: Vec<&Node> = self + .buckets + .iter() + .filter_map(|b| b.as_ref()) + .flat_map(|b| b.nodes.iter()) + .collect(); + all.sort_by_key(|n| std::cmp::Reverse(n.seen_tick)); + let skip = if all.len() >= 8 { 5 } else { 0 }; + all.into_iter().skip(skip).take(limit).collect() + } + /// Drain and return all pending events. pub fn drain_events(&mut self) -> Vec { std::mem::take(&mut self.pending_events) @@ -548,6 +571,74 @@ mod tests { assert_eq!(node.down_hints, 3); } + #[test] + fn recent_limit_zero_returns_empty() { + let local: NodeId = [0u8; 32]; + let mut rt = RoutingTable::new(local); + assert_eq!(rt.recent(0).len(), 0, "empty table, limit=0 => empty"); + + for i in 0..5_usize { + let mut n = make_node(node_id_for_bucket(i)); + n.seen_tick = i as u64; + rt.add(n); + } + assert_eq!(rt.recent(0).len(), 0, "populated table, limit=0 => empty"); + } + + #[test] + fn recent_orders_by_seen_tick_desc() { + let local: NodeId = [0u8; 32]; + let mut rt = RoutingTable::new(local); + + for (i, tick) in [1u64, 2, 3].iter().enumerate() { + let mut n = make_node(node_id_for_bucket(i)); + n.seen_tick = *tick; + rt.add(n); + } + + let result = rt.recent(3); + assert_eq!(result.len(), 3, "expected 3 nodes (stub returns 0 — RED)"); + assert_eq!(result[0].seen_tick, 3, "newest first"); + assert_eq!(result[1].seen_tick, 2); + assert_eq!(result[2].seen_tick, 1, "oldest last"); + } + + #[test] + fn recent_skips_5_newest_when_cache_geq_8() { + let local: NodeId = [0u8; 32]; + let mut rt = RoutingTable::new(local); + + for i in 0..10_usize { + let mut n = make_node(node_id_for_bucket(i)); + n.seen_tick = i as u64; + rt.add(n); + } + assert_eq!(rt.len(), 10); + + let result = rt.recent(4); + assert_eq!(result.len(), 4, "expected 4 nodes (stub returns 0 — RED)"); + assert_eq!(result[0].seen_tick, 4, "pos 5 from newest"); + assert_eq!(result[1].seen_tick, 3); + assert_eq!(result[2].seen_tick, 2); + assert_eq!(result[3].seen_tick, 1, "pos 8 from newest"); + } + + #[test] + fn recent_caps_at_limit() { + let local: NodeId = [0u8; 32]; + let mut rt = RoutingTable::new(local); + + for i in 0..20_usize { + let mut n = make_node(node_id_for_bucket(i)); + n.seen_tick = i as u64; + rt.add(n); + } + assert_eq!(rt.len(), 20); + + let result = rt.recent(4); + assert_eq!(result.len(), 4, "recent(4) must cap at 4 (stub returns 0 — RED)"); + } + #[test] fn test_closest_respects_k_limit() { let local: NodeId = [0u8; 32]; diff --git a/peeroxide-dht/src/rpc.rs b/peeroxide-dht/src/rpc.rs index 4eb0d85..0227749 100644 --- a/peeroxide-dht/src/rpc.rs +++ b/peeroxide-dht/src/rpc.rs @@ -1,7 +1,7 @@ #![deny(clippy::all)] use std::collections::HashMap; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, SocketAddr}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -13,6 +13,7 @@ use libudx::{UdxRuntime, UdxSocket}; use crate::io::{Io, IoConfig, IoEvent, ReplyContext, RequestParams, TimeoutEvent}; use crate::messages::{Command, Ipv4Peer}; +use crate::nat::Nat; use crate::peer::{peer_id, NodeId}; use crate::query::{ parse_bootstrap_str, resolve_bootstrap_nodes, IoResponseData, Query, QueryReply, QueryRequest, @@ -37,6 +38,13 @@ const CMD_DELAYED_PING: u64 = Command::DelayedPing as u64; const ERR_UNKNOWN_COMMAND: u64 = 1; +/// Internal sentinel sent on `UserRequest::reply_tx` by [`UserRequest::release`] +/// to instruct the deferred-reply pipeline to suppress the auto-emitted REPLY +/// packet (the relay-forwarding paths consume the inbound REQUEST without +/// responding; the original requester's REPLY arrives via the tid-preserved +/// chain instead). +const SUPPRESS_REPLY_SENTINEL: u64 = u64::MAX; + // ── Error ───────────────────────────────────────────────────────────────────── #[derive(Debug, Error)] @@ -134,6 +142,7 @@ pub struct ResponseData { #[derive(Debug, Clone)] /// Parameters for a user-driven DHT query. +#[non_exhaustive] pub struct UserQueryParams { /// Query target node id. pub target: NodeId, @@ -147,7 +156,8 @@ pub struct UserQueryParams { pub concurrency: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] +#[non_exhaustive] /// Parameters for a user-driven DHT request. pub struct UserRequestParams { /// Optional request token. @@ -158,14 +168,23 @@ pub struct UserRequestParams { pub target: Option, /// Optional request payload. pub value: Option>, + /// Optional per-request timeout override (ms). Defaults to the dht-rpc DEFAULT_TIMEOUT_MS. + pub timeout_ms: Option, + /// Optional per-request retries override. Defaults to the dht-rpc DEFAULT_RETRIES. + pub retries: Option, } /// An incoming user-facing request forwarded from the DHT. +#[non_exhaustive] pub struct UserRequest { /// Origin peer for the request. pub from: Ipv4Peer, /// Optional origin peer id. pub id: Option, + /// Transaction id from the inbound request, preserved end-to-end through + /// any relay hops so the eventual REPLY matches the originator's inflight + /// entry. Mirrors Node `dht-rpc/lib/io.js` Request.tid semantics. + pub tid: u16, /// Optional request token. pub token: Option<[u8; 32]>, /// RPC command received. @@ -191,6 +210,25 @@ impl UserRequest { let _ = tx.send((code, None)); } } + + /// Consume the request without emitting any reply. The deferred-reply + /// pipeline that normally fires `ERR_UNKNOWN_COMMAND` on a dropped + /// `reply_tx` is suppressed via an internal sentinel error code + /// (`SUPPRESS_REPLY_SENTINEL`). + /// + /// Used by relay-forwarding paths that consume the inbound REQUEST + /// without responding — the original requester's REPLY arrives via the + /// tid-preserved relay chain, not via this hop. Mirrors Node behaviour: + /// `dht-rpc::Request.relay(value, to)` does not also call + /// `req.reply(...)`. Without this, the inbound `req` would auto-emit a + /// REPLY using the preserved tid (= original client's tid), racing + /// against the genuine REPLY arriving via the chain's tail and causing + /// the client to match on the first arrival (typically an error). + pub fn release(&mut self) { + if let Some(tx) = self.reply_tx.take() { + let _ = tx.send((SUPPRESS_REPLY_SENTINEL, None)); + } + } } // ── Internal command channel ────────────────────────────────────────────────── @@ -223,6 +261,13 @@ enum DhtCommand { target: Option, value: Option>, to: Ipv4Peer, + preserve_tid: Option, + }, + SendReplyTo { + tid: u16, + target: Option, + to: Ipv4Peer, + value: Option>, }, SubscribeRequests { reply_tx: oneshot::Sender>, @@ -245,6 +290,19 @@ enum DhtCommand { ListenSocket { reply_tx: oneshot::Sender>, }, + RemoteAddress { + reply_tx: oneshot::Sender<(Option, u64)>, + }, + #[allow(dead_code)] // constructed by T6 forwarder task; variant + handler land in T4 + InboundReplyBytes { + addr: SocketAddr, + data: Vec, + }, + PingViaSocket { + target: Ipv4Peer, + socket: UdxSocket, + reply_tx: oneshot::Sender>, + }, } // ── Standalone (non-query) inflight tracking ────────────────────────────────── @@ -279,6 +337,7 @@ struct DeferredReply { pub struct DhtHandle { cmd_tx: mpsc::UnboundedSender, wire: crate::io::WireCounters, + table: Arc>, } impl DhtHandle { @@ -295,6 +354,48 @@ impl DhtHandle { pub fn wire_counters(&self) -> crate::io::WireCounters { self.wire.clone() } + + /// Return up to `limit` recently-responsive DHT nodes from the local + /// routing table, ordered by `seen_tick` descending. Used by + /// [`crate::nat::Nat::auto_sample`] to pick ping targets that are + /// likely to respond. Applies Node's skip-5-if-cache≥8 rule so the + /// freshest nodes (probably engaged in current traffic) are not + /// re-used as NAT-sample targets. + pub(crate) fn recent_nodes(&self, limit: usize) -> Vec { + let table = match self.table.lock() { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + table + .recent(limit) + .into_iter() + .map(|n| Ipv4Peer { + host: n.host.clone(), + port: n.port, + }) + .collect() + } + + /// Forward inbound reply bytes from a non-`Io` socket (puncher socket) + /// to the actor for TID matching. Used by the auto_sample forwarder + /// task. Fire-and-forget; the actor is the source of truth for TID + /// resolution. + pub(crate) fn forward_inbound_reply_bytes(&self, addr: SocketAddr, data: Vec) { + let _ = self + .cmd_tx + .send(DhtCommand::InboundReplyBytes { addr, data }); + } + + /// Insert a node directly into the local routing table, bypassing the + /// normal discovery/query path. Used only by unit tests that need + /// deterministic `recent_nodes()` candidates (e.g. `Nat::auto_sample` + /// tests) without a live bootstrap round-trip. + #[cfg(test)] + pub(crate) fn insert_node_for_test(&self, node: crate::routing_table::Node) { + if let Ok(mut table) = self.table.lock() { + table.add(node); + } + } } impl DhtHandle { @@ -320,6 +421,29 @@ impl DhtHandle { rx.await.map_err(|_| DhtError::ChannelClosed)? } + /// Send a one-shot ping out via the supplied `socket` (typically a + /// puncher socket) and resolve with the reply. Used by + /// [`crate::nat::Nat::auto_sample`] to seed reflexive NAT samples + /// from the puncher socket's own UDP binding, since the reflexive + /// observation must reflect that socket's NAT mapping — not the + /// main DHT socket's. No automatic retry. + #[allow(dead_code)] // wired by T6 (Nat::auto_sample) + pub(crate) async fn ping_via_socket( + &self, + target: Ipv4Peer, + socket: UdxSocket, + ) -> Result { + let (tx, rx) = oneshot::channel(); + self.cmd_tx + .send(DhtCommand::PingViaSocket { + target, + socket, + reply_tx: tx, + }) + .map_err(|_| DhtError::ChannelClosed)?; + rx.await.map_err(|_| DhtError::ChannelClosed)? + } + /// Runs a `find_node` query for `target`. pub async fn find_node(&self, target: NodeId) -> Result, DhtError> { let (tx, rx) = oneshot::channel(); @@ -378,6 +502,58 @@ impl DhtHandle { target, value, to: to.clone(), + preserve_tid: None, + }) + .map_err(|_| DhtError::ChannelClosed) + } + + /// Fire-and-forget relay send that PRESERVES the inbound request's tid. + /// + /// Used by handshake-routing logic when forwarding a response back through + /// a relay chain (mode FROM_SERVER). Mirrors Node `dht-rpc::Request.relay`: + /// the tid is kept end-to-end so the original requester's inflight entry + /// matches when the eventual REPLY arrives. Without this, the response is + /// silently dropped at the relay hop. + pub fn relay_with_tid( + &self, + command: u64, + target: Option, + value: Option>, + to: &Ipv4Peer, + tid: u16, + ) -> Result<(), DhtError> { + self.cmd_tx + .send(DhtCommand::Relay { + command, + target, + value, + to: to.clone(), + preserve_tid: Some(tid), + }) + .map_err(|_| DhtError::ChannelClosed) + } + + /// Fire-and-forget REPLY send to an arbitrary address using the supplied + /// tid. Mirrors Node `dht-rpc::Request.reply(value, { to })`. + /// + /// Used by handshake routing when an FE-holder finalises the tid-preserved + /// relay chain by sending the REPLY directly to the original client + /// (FROM_SERVER → REPLY transition). The `tid` must be the inbound + /// request's tid (= the original client's inflight tid, propagated end-to-end + /// via [`Self::relay_with_tid`] at the previous hop). + pub fn send_reply_to( + &self, + tid: u16, + target: Option, + to: &Ipv4Peer, + value: Option>, + ) -> Result<(), DhtError> { + self.cmd_tx + .send(DhtCommand::SendReplyTo { + tid, + target, + to: to.clone(), + value, }) .map_err(|_| DhtError::ChannelClosed) } @@ -444,6 +620,36 @@ impl DhtHandle { .map_err(|_| DhtError::ChannelClosed)?; rx.await.map_err(|_| DhtError::ChannelClosed) } + + /// Returns the externally-observed IPv4 address of this node, as inferred + /// from the `to` field of inbound DHT responses (NAT consensus). + /// + /// Returns `None` if the NAT analysis hasn't yet settled — typically until + /// at least 3 responses have been processed when running firewalled, or + /// 1 response when running unfirewalled. Mirrors Node Hyperswarm's + /// `dht.remoteAddress()`. + pub async fn remote_address(&self) -> Result, DhtError> { + let (tx, rx) = oneshot::channel(); + self.cmd_tx + .send(DhtCommand::RemoteAddress { reply_tx: tx }) + .map_err(|_| DhtError::ChannelClosed)?; + let (addr, _firewall) = rx.await.map_err(|_| DhtError::ChannelClosed)?; + Ok(addr) + } + + /// Returns the current firewall classification (one of `FIREWALL_OPEN`, + /// `FIREWALL_UNKNOWN`, `FIREWALL_CONSISTENT`, `FIREWALL_RANDOM` constants + /// from [`crate::hyperdht_messages`]). + /// + /// Mirrors Node Hyperswarm's `dht.firewalled`. + pub async fn firewalled(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.cmd_tx + .send(DhtCommand::RemoteAddress { reply_tx: tx }) + .map_err(|_| DhtError::ChannelClosed)?; + let (_addr, firewall) = rx.await.map_err(|_| DhtError::ChannelClosed)?; + Ok(firewall) + } } // ── DhtNode (internal background actor) ────────────────────────────────────── @@ -476,6 +682,7 @@ struct DhtNode { needs_id_update: bool, addr_samples: Vec, + nat: Nat, } impl DhtNode { @@ -596,8 +803,11 @@ impl DhtNode { } => { self.add_node_from_network(from.clone(), id); - if self.needs_id_update && to.port != 0 && !to.host.is_empty() { - self.addr_samples.push(to.clone()); + if to.port != 0 && !to.host.is_empty() { + if self.needs_id_update { + self.addr_samples.push(to.clone()); + } + self.nat.add(&to, &from); } let data = IoResponseData { @@ -768,6 +978,8 @@ impl DhtNode { command: CMD_PING, target: None, value: None, + timeout_ms: None, + retries: None, }; if let Some(tid) = self.io.create_request(params) { @@ -788,6 +1000,7 @@ impl DhtNode { let user_req = UserRequest { from: req.from.clone(), id: req.id, + tid: req.tid, token: req.token, command: req.command, target: req.target, @@ -819,18 +1032,21 @@ impl DhtNode { let tid = req.tid; let target = req.target; tokio::spawn(async move { - let (error, value) = match reply_rx.await { - Ok((e, v)) => (e, v), - Err(_) => (ERR_UNKNOWN_COMMAND, None), + let outcome = match reply_rx.await { + Ok((SUPPRESS_REPLY_SENTINEL, _)) => None, + Ok((error, value)) => Some((error, value)), + Err(_) => Some((ERR_UNKNOWN_COMMAND, None)), }; - let _ = deferred_tx.send(DeferredReply { - from, - reply_ctx, - tid, - target, - error, - value, - }); + if let Some((error, value)) = outcome { + let _ = deferred_tx.send(DeferredReply { + from, + reply_ctx, + tid, + target, + error, + value, + }); + } }); } } @@ -1029,6 +1245,8 @@ impl DhtNode { command: CMD_PING, target: None, value: None, + timeout_ms: None, + retries: None, }; if let Some(tid) = self.io.create_request(params) { self.standalone_tids.insert( @@ -1113,6 +1331,8 @@ impl DhtNode { command: CMD_FIND_NODE, target, value: None, + timeout_ms: None, + retries: None, }; if let Some(tid) = self.io.create_request(params) { self.standalone_tids @@ -1152,6 +1372,8 @@ impl DhtNode { command: params.command, target: params.target, value: params.value, + timeout_ms: params.timeout_ms, + retries: params.retries, }; if let Some(tid) = self.io.create_request(rparams) { self.standalone_tids @@ -1166,8 +1388,18 @@ impl DhtNode { target, value, to, + preserve_tid, + } => { + self.io.relay(command, target, value, &to, preserve_tid); + } + + DhtCommand::SendReplyTo { + tid, + target, + to, + value, } => { - self.io.relay(command, target, value, &to); + self.io.reply_to(tid, target, &to, value); } DhtCommand::SubscribeRequests { reply_tx } => { @@ -1209,6 +1441,44 @@ impl DhtNode { let socket = Some(self.io.server_socket()); let _ = reply_tx.send(socket); } + + DhtCommand::RemoteAddress { reply_tx } => { + let addr = self + .nat + .addresses + .as_ref() + .and_then(|addrs| addrs.first().cloned()); + let firewall = self.nat.firewall; + let _ = reply_tx.send((addr, firewall)); + } + DhtCommand::InboundReplyBytes { addr, data } => { + if let Some(event) = self.io.handle_inbound_reply_bytes(addr, data) { + self.handle_io_event(event); + } + } + DhtCommand::PingViaSocket { + target, + socket, + reply_tx, + } => { + let id_target = self.table.lock().ok().map(|t| *t.id()); + let params = RequestParams { + to: target, + token: None, + internal: true, + command: CMD_FIND_NODE, + target: id_target, + value: None, + timeout_ms: None, + retries: None, + }; + if let Some(tid) = self.io.send_request_via_socket(params, &socket) { + self.standalone_tids + .insert(tid, StandaloneRequest::Ping(reply_tx)); + } else { + let _ = reply_tx.send(Err(DhtError::Destroyed)); + } + } } false } @@ -1413,6 +1683,8 @@ impl DhtNode { command: CMD_PING, target: None, value: None, + timeout_ms: None, + retries: None, }; if let Some(tid) = self.io.create_request(params) { self.repinging += 1; @@ -1467,7 +1739,10 @@ impl DhtNode { } self.bootstrapped = true; - tracing::debug!("DHT node bootstrapped"); + tracing::info!( + target: "peeroxide::_events::dht::bootstrapped", + "DHT node bootstrapped" + ); for tx in self.bootstrap_waiters.drain(..) { let _ = tx.send(Ok(())); } @@ -1506,6 +1781,7 @@ pub async fn spawn( ) -> Result<(tokio::task::JoinHandle>, DhtHandle), DhtError> { let table_id: NodeId = rand::random(); let table = Arc::new(Mutex::new(RoutingTable::new(table_id))); + let table_for_handle = Arc::clone(&table); let ephemeral = config.ephemeral.unwrap_or(!config.bootstrap.is_empty()); let io_config = IoConfig { @@ -1537,6 +1813,8 @@ pub async fn spawn( let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); let (deferred_reply_tx, deferred_reply_rx) = mpsc::unbounded_channel(); + let firewalled = config.firewalled; + let node = DhtNode { io, table, @@ -1561,11 +1839,16 @@ pub async fn spawn( deferred_reply_rx, needs_id_update, addr_samples: Vec::new(), + nat: Nat::new(firewalled), }; let wire = node.io.wire_counters(); let handle = tokio::spawn(node.run()); - let dht_handle = DhtHandle { cmd_tx, wire }; + let dht_handle = DhtHandle { + cmd_tx, + wire, + table: Arc::clone(&table_for_handle), + }; Ok((handle, dht_handle)) } @@ -1630,4 +1913,55 @@ mod tests { assert_eq!(RECENT_NODE, 12); assert_eq!(OLD_NODE, 360); } + + #[tokio::test] + async fn remote_address_starts_unsettled() { + use libudx::UdxRuntime; + + let runtime = UdxRuntime::new().expect("runtime"); + let cfg = DhtConfig { + bootstrap: vec![], + ephemeral: Some(true), + ..DhtConfig::default() + }; + let (_task, handle) = spawn(&runtime, cfg).await.expect("spawn"); + + let addr = handle.remote_address().await.expect("remote_address"); + assert!( + addr.is_none(), + "remote_address should be None before any DHT responses arrive" + ); + + let firewall = handle.firewalled().await.expect("firewalled"); + assert_eq!( + firewall, + crate::hyperdht_messages::FIREWALL_UNKNOWN, + "firewalled flag should start as UNKNOWN for a firewalled node" + ); + + let _ = handle.destroy().await; + } + + #[tokio::test] + async fn remote_address_unfirewalled_starts_open() { + use libudx::UdxRuntime; + + let runtime = UdxRuntime::new().expect("runtime"); + let cfg = DhtConfig { + bootstrap: vec![], + firewalled: false, + ephemeral: Some(true), + ..DhtConfig::default() + }; + let (_task, handle) = spawn(&runtime, cfg).await.expect("spawn"); + + let firewall = handle.firewalled().await.expect("firewalled"); + assert_eq!( + firewall, + crate::hyperdht_messages::FIREWALL_OPEN, + "unfirewalled node should report FIREWALL_OPEN immediately" + ); + + let _ = handle.destroy().await; + } } diff --git a/peeroxide-dht/src/secretstream.rs b/peeroxide-dht/src/secretstream.rs index 8d40302..acea253 100644 --- a/peeroxide-dht/src/secretstream.rs +++ b/peeroxide-dht/src/secretstream.rs @@ -10,6 +10,8 @@ //! aad || pad16(aad) || encrypted_block\[64\] || encrypted_msg || pad_msg || le64(adlen) || le64(64+mlen) //! where pad_msg = (mlen & 0xf) bytes of zeros (NOT standard AEAD alignment). +#![allow(missing_docs)] + use chacha20::cipher::{KeyIvInit, StreamCipher}; use chacha20::hchacha; use poly1305::universal_hash::KeyInit; @@ -32,6 +34,7 @@ pub const TAG_FINAL: u8 = 0x03; static PAD0: [u8; 16] = [0u8; 16]; #[derive(Debug, Error)] +#[non_exhaustive] pub enum SecretstreamError { #[error("ciphertext too short")] CiphertextTooShort, diff --git a/peeroxide-dht/src/secure_payload.rs b/peeroxide-dht/src/secure_payload.rs index d4d31e0..ecf86cf 100644 --- a/peeroxide-dht/src/secure_payload.rs +++ b/peeroxide-dht/src/secure_payload.rs @@ -1,3 +1,17 @@ +//! Authenticated-encryption helper for short DHT-handshake payloads. +//! +//! `SecurePayload` wraps an XChaCha20-Poly1305 secretbox keyed off a +//! BLAKE2b namespace + remote-secret pairing, and is used by the swarm +//! and connect-handshake paths to attach encrypted application data +//! (peer addresses, holepunch instructions) to otherwise plaintext +//! `PEER_HANDSHAKE` / `PEER_HOLEPUNCH` messages. +//! +//! Errors are reported through `SecurePayloadError`. The encrypted output +//! also carries a short opaque token (see `SecurePayload::token`) that +//! callers use to bind a reply to the originating request. + +#![allow(missing_docs)] + use blake2::digest::consts::U32; use blake2::digest::{KeyInit, Mac}; use blake2::Blake2bMac; diff --git a/peeroxide-dht/src/socket_pool.rs b/peeroxide-dht/src/socket_pool.rs index 1d03328..80bc82b 100644 --- a/peeroxide-dht/src/socket_pool.rs +++ b/peeroxide-dht/src/socket_pool.rs @@ -1,3 +1,17 @@ +//! Shared UDP socket pool used by NAT hole-punching. +//! +//! `SocketPool` hands out `SocketRef` references to ephemeral UDP +//! sockets bound on local ports, multiplexing receive of incoming +//! `PEER_HOLEPUNCH` probes through a dedicated channel (`HolepunchEvent`). +//! The [`crate::holepuncher::Holepuncher`] uses this pool to launch the +//! birthday-attack probe sequence required to traverse symmetric NATs. +//! +//! Most consumers use the pool indirectly via +//! [`crate::hyperdht::HyperDhtHandle::connect`]; direct use is only needed +//! for custom DHT-server orchestration or low-level hole-punch experiments. + +#![allow(missing_docs)] + use std::net::SocketAddr; use libudx::{Datagram, UdxRuntime, UdxSocket}; @@ -33,13 +47,15 @@ impl SocketPool { socket.bind(addr).await?; let (hp_tx, hp_rx) = mpsc::unbounded_channel(); + let (dht_tx, dht_rx) = mpsc::unbounded_channel(); let recv_rx = socket.recv_start()?; - let recv_task = tokio::spawn(route_messages(recv_rx, hp_tx)); + let recv_task = tokio::spawn(route_messages(recv_rx, hp_tx, dht_tx)); Ok(SocketRef { socket, - holepunch_rx: hp_rx, + holepunch_rx: Some(hp_rx), + dht_reply_rx: Some(dht_rx), _recv_task: Some(recv_task), }) } @@ -48,31 +64,61 @@ impl SocketPool { async fn route_messages( mut recv_rx: mpsc::UnboundedReceiver, hp_tx: mpsc::UnboundedSender, + dht_tx: mpsc::UnboundedSender, ) { while let Some(dgram) = recv_rx.recv().await { - if dgram.data.len() <= 1 { - let _ = hp_tx.send(HolepunchEvent { addr: dgram.addr }); + match classify_inbound(&dgram.data) { + InboundClass::Holepunch => { + tracing::debug!(addr = %dgram.addr, len = dgram.data.len(), "socket_pool: holepunch probe received"); + let _ = hp_tx.send(HolepunchEvent { addr: dgram.addr }); + } + InboundClass::DhtResponse => { + tracing::trace!(addr = %dgram.addr, len = dgram.data.len(), "socket_pool: DHT response forwarded"); + let _ = dht_tx.send(dgram); + } + InboundClass::UdxFrame | InboundClass::Drop => { + // UDX frames are handled by libudx's own demux before fallback + // datagrams reach this lane; DHT requests are not expected on + // a holepunch socket; both are silently dropped. + } } - // DHT messages (>1 byte) on holepunch sockets are dropped — only the - // primary client/server sockets in io.rs handle DHT protocol traffic. } } #[derive(Debug)] +#[non_exhaustive] pub struct HolepunchEvent { pub addr: SocketAddr, } pub struct SocketRef { pub socket: UdxSocket, - pub holepunch_rx: mpsc::UnboundedReceiver, + pub holepunch_rx: Option>, + pub dht_reply_rx: Option>, _recv_task: Option>, } impl SocketRef { + /// The holepunch receiver is `Some(...)` immediately after `SocketPool::acquire()`. + /// The Holepuncher recv-adapter takes ownership through this accessor exactly once; + /// subsequent calls return `None`. + pub fn take_holepunch_rx( + &mut self, + ) -> Option> { + self.holepunch_rx.take() + } + + /// Take ownership of the DHT-reply receiver. Datagrams whose payload + /// `messages::decode_message` parses as a `Message::Response` are + /// forwarded here. Used by `Nat::auto_sample` to receive PING-via-socket + /// replies through the puncher socket's own NAT mapping. + pub fn take_dht_reply_rx(&mut self) -> Option> { + self.dht_reply_rx.take() + } + pub fn send_holepunch(&self, addr: SocketAddr, low_ttl: bool) -> Result<()> { - let _ttl = if low_ttl { HOLEPUNCH_TTL } else { DEFAULT_TTL }; - // TODO: TTL support requires udx_socket_set_ttl which isn't exposed yet. + let ttl = if low_ttl { HOLEPUNCH_TTL } else { DEFAULT_TTL }; + self.socket.set_ttl(ttl)?; self.socket.send_to(HOLEPUNCH_MSG, addr)?; Ok(()) } @@ -87,6 +133,39 @@ pub fn random_port() -> u16 { 1000 + (rand::random::() * 64536.0) as u16 } +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum InboundClass { + Holepunch, + UdxFrame, + DhtResponse, + Drop, +} + +/// Classify an inbound datagram on a holepunch socket. UDX frames are +/// libudx's responsibility (demuxed before they reach this lane) so they +/// are dropped defensively; `decode_message` parses the rest, forwarding +/// only well-formed `Message::Response` datagrams. +pub(crate) fn classify_inbound(buf: &[u8]) -> InboundClass { + if buf.len() <= 1 { + return InboundClass::Holepunch; + } + if buf.len() >= 20 && buf[0] == 0xFF { + return InboundClass::UdxFrame; + } + match crate::messages::decode_message(buf) { + Ok(crate::messages::Message::Response(_)) => InboundClass::DhtResponse, + _ => InboundClass::Drop, + } +} + +/// Map a raw `firewall` value into the punch-strategy bucket the Holepuncher +/// dispatcher understands. +/// +/// Matches Node `hyperdht/lib/holepuncher.js:333-335`: only `OPEN → +/// CONSISTENT`. `UNKNOWN` no longer gets silently coerced — by the time +/// `punch()` runs the puncher's `Nat` has been seeded by +/// [`crate::holepuncher::Holepuncher::auto_sample`] which settles the +/// firewall to `CONSISTENT` / `RANDOM` via real reflexive samples. pub fn coerce_firewall(fw: u64) -> u64 { use crate::hyperdht_messages::{FIREWALL_CONSISTENT, FIREWALL_OPEN}; if fw == FIREWALL_OPEN { @@ -130,4 +209,44 @@ mod tests { fn coerce_unknown_unchanged() { assert_eq!(coerce_firewall(FIREWALL_UNKNOWN), FIREWALL_UNKNOWN); } + + #[test] + fn demux_routes_one_byte_to_holepunch() { + let buf = [0u8; 1]; + assert_eq!(classify_inbound(&buf), InboundClass::Holepunch); + } + + #[test] + fn demux_drops_udx_framed_packet() { + let mut buf = vec![0u8; 25]; + buf[0] = 0xFF; + assert_eq!( + classify_inbound(&buf), + InboundClass::UdxFrame, + "25-byte 0xFF-prefix packet must classify as UdxFrame (stub returns Drop — RED)", + ); + } + + #[test] + fn demux_routes_dht_response_to_reply_lane() { + use crate::messages::{Ipv4Peer, Response, encode_response_to_bytes}; + let resp = Response { + tid: 1, + to: Ipv4Peer { + host: "127.0.0.1".into(), + port: 9000, + }, + id: None, + token: None, + closer_nodes: vec![], + error: 0, + value: None, + }; + let buf = encode_response_to_bytes(&resp).unwrap(); + assert_eq!( + classify_inbound(&buf), + InboundClass::DhtResponse, + "valid DHT Response bytes must classify as DhtResponse (stub returns Drop — RED)", + ); + } } diff --git a/peeroxide-dht/tests/blind_relay_golden_interop.rs b/peeroxide-dht/tests/blind_relay_golden_interop.rs deleted file mode 100644 index 46721f7..0000000 --- a/peeroxide-dht/tests/blind_relay_golden_interop.rs +++ /dev/null @@ -1,126 +0,0 @@ -use peeroxide_dht::blind_relay::{ - decode_pair_from_slice, decode_unpair_from_slice, encode_pair_to_vec, encode_unpair_to_vec, - PairMessage, UnpairMessage, -}; -use serde::Deserialize; - -#[derive(Deserialize)] -struct GoldenFile { - #[allow(dead_code)] - generated_by: String, - #[allow(dead_code)] - blind_relay_version: String, - fixtures: Vec, -} - -#[derive(Deserialize)] -struct Fixture { - label: String, - #[serde(rename = "type")] - typ: String, - hex: String, - decoded: serde_json::Value, -} - -fn load_fixtures() -> Vec { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../tests/interop/blind-relay-fixtures.json" - ); - let data = std::fs::read_to_string(path).unwrap_or_else(|e| { - panic!("Failed to read blind-relay fixtures at {path}: {e}. Run `node generate-blind-relay-golden.js` in tests/node/ first.") - }); - let file: GoldenFile = serde_json::from_str(&data) - .unwrap_or_else(|e| panic!("Failed to parse blind-relay fixtures: {e}")); - file.fixtures -} - -fn from_hex(s: &str) -> Vec { - hex::decode(s).unwrap_or_else(|e| panic!("Invalid hex '{s}': {e}")) -} - -fn token_from_hex(s: &str) -> [u8; 32] { - let v = from_hex(s); - let mut arr = [0u8; 32]; - arr.copy_from_slice(&v); - arr -} - -#[test] -fn golden_blind_relay_decode_pair() { - let fixtures = load_fixtures(); - let pairs: Vec<_> = fixtures.iter().filter(|f| f.typ == "pair").collect(); - assert!(!pairs.is_empty(), "no pair fixtures found"); - - for fix in pairs { - let raw = from_hex(&fix.hex); - let decoded = decode_pair_from_slice(&raw) - .unwrap_or_else(|e| panic!("[{}] decode failed: {e}", fix.label)); - - let d = &fix.decoded; - let expected = PairMessage { - is_initiator: d["is_initiator"].as_bool().unwrap(), - token: token_from_hex(d["token"].as_str().unwrap()), - id: d["id"].as_u64().unwrap(), - seq: d["seq"].as_u64().unwrap(), - }; - - assert_eq!(decoded, expected, "[{}] decode mismatch", fix.label); - } -} - -#[test] -fn golden_blind_relay_decode_unpair() { - let fixtures = load_fixtures(); - let unpairs: Vec<_> = fixtures.iter().filter(|f| f.typ == "unpair").collect(); - assert!(!unpairs.is_empty(), "no unpair fixtures found"); - - for fix in unpairs { - let raw = from_hex(&fix.hex); - let decoded = decode_unpair_from_slice(&raw) - .unwrap_or_else(|e| panic!("[{}] decode failed: {e}", fix.label)); - - let expected = UnpairMessage { - token: token_from_hex(fix.decoded["token"].as_str().unwrap()), - }; - - assert_eq!(decoded, expected, "[{}] decode mismatch", fix.label); - } -} - -#[test] -fn golden_blind_relay_encode_roundtrip() { - let fixtures = load_fixtures(); - - for fix in &fixtures { - let expected_bytes = from_hex(&fix.hex); - let d = &fix.decoded; - - let encoded = match fix.typ.as_str() { - "pair" => { - let msg = PairMessage { - is_initiator: d["is_initiator"].as_bool().unwrap(), - token: token_from_hex(d["token"].as_str().unwrap()), - id: d["id"].as_u64().unwrap(), - seq: d["seq"].as_u64().unwrap(), - }; - encode_pair_to_vec(&msg) - } - "unpair" => { - let msg = UnpairMessage { - token: token_from_hex(d["token"].as_str().unwrap()), - }; - encode_unpair_to_vec(&msg) - } - other => panic!("[{}] unknown fixture type: {other}", fix.label), - }; - - assert_eq!( - encoded, expected_bytes, - "[{}] encode roundtrip mismatch\n encoded: {}\n expected: {}", - fix.label, - hex::encode(&encoded), - fix.hex, - ); - } -} diff --git a/peeroxide-dht/tests/golden_interop.rs b/peeroxide-dht/tests/golden_interop.rs deleted file mode 100644 index 6270759..0000000 --- a/peeroxide-dht/tests/golden_interop.rs +++ /dev/null @@ -1,475 +0,0 @@ -//! Golden fixture interop tests — verifies our compact-encoding Rust implementation -//! produces byte-identical output to the Node.js `compact-encoding` npm package. -//! -//! The golden fixtures are generated by `tests/node/generate-golden.js`. -//! Run `node generate-golden.js` in `tests/node/` to regenerate. - -use peeroxide_dht::compact_encoding::*; -use serde::Deserialize; - -#[derive(Deserialize)] -struct GoldenFile { - #[allow(dead_code)] - generated_by: String, - #[allow(dead_code)] - version: String, - fixtures: Vec, -} - -#[derive(Deserialize)] -struct Fixture { - #[serde(rename = "type")] - typ: String, - label: String, - value: serde_json::Value, - hex: String, -} - -fn load_fixtures() -> Vec { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../tests/interop/golden-fixtures.json" - ); - let data = std::fs::read_to_string(path) - .unwrap_or_else(|e| panic!("Failed to read golden fixtures at {path}: {e}. Run `node generate-golden.js` in tests/node/ first.")); - let file: GoldenFile = serde_json::from_str(&data) - .unwrap_or_else(|e| panic!("Failed to parse golden fixtures: {e}")); - file.fixtures -} - -fn expected_bytes(hex_str: &str) -> Vec { - hex::decode(hex_str).unwrap_or_else(|e| panic!("Invalid hex '{hex_str}': {e}")) -} - -fn encode_with_pre(pre_fn: impl FnOnce(&mut State), enc_fn: impl FnOnce(&mut State)) -> Vec { - let mut state = State::new(); - pre_fn(&mut state); - state.alloc(); - enc_fn(&mut state); - state.buffer -} - -fn val_u64(v: &serde_json::Value) -> u64 { - match v { - serde_json::Value::Number(n) => n.as_u64().unwrap(), - serde_json::Value::String(s) => s.parse::().unwrap(), - _ => panic!("Expected number or string for u64, got {v:?}"), - } -} - -fn val_i64(v: &serde_json::Value) -> i64 { - match v { - serde_json::Value::Number(n) => n.as_i64().unwrap(), - serde_json::Value::String(s) => s.parse::().unwrap(), - _ => panic!("Expected number or string for i64, got {v:?}"), - } -} - -fn val_f64(v: &serde_json::Value) -> f64 { - v.as_f64().unwrap() -} - -fn val_f32(v: &serde_json::Value) -> f32 { - v.as_f64().unwrap() as f32 -} - -fn val_str(v: &serde_json::Value) -> &str { - v.as_str().unwrap() -} - -fn val_bool(v: &serde_json::Value) -> bool { - v.as_bool().unwrap() -} - -#[test] -fn golden_uint8() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint8") { - let val = val_u64(&f.value) as u8; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint8(s, val), |s| encode_uint8(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint8(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}: expected {val}, got {decoded}", f.label); - } -} - -#[test] -fn golden_uint16() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint16") { - let val = val_u64(&f.value) as u16; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint16(s, val), |s| encode_uint16(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint16(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_uint24() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint24") { - let val = val_u64(&f.value) as u32; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint24(s, val), |s| encode_uint24(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint24(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_uint32() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint32") { - let val = val_u64(&f.value) as u32; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint32(s, val), |s| encode_uint32(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint32(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_uint64() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint64") { - let val = val_u64(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint64(s, val), |s| encode_uint64(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint64(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_uint_varint() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint") { - let val = val_u64(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_uint(s, val), |s| encode_uint(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_int_zigzag() { - for f in load_fixtures().iter().filter(|f| f.typ == "int") { - let val = val_i64(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_int(s, val), |s| encode_int(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_int(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_int8() { - for f in load_fixtures().iter().filter(|f| f.typ == "int8") { - let val = val_i64(&f.value) as i8; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_int8(s, val), |s| encode_int8(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_int8(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_int16() { - for f in load_fixtures().iter().filter(|f| f.typ == "int16") { - let val = val_i64(&f.value) as i16; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_int16(s, val), |s| encode_int16(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_int16(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_int32() { - for f in load_fixtures().iter().filter(|f| f.typ == "int32") { - let val = val_i64(&f.value) as i32; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_int32(s, val), |s| encode_int32(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_int32(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_int64() { - for f in load_fixtures().iter().filter(|f| f.typ == "int64") { - let val = val_i64(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_int64(s, val), |s| encode_int64(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_int64(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_float32() { - for f in load_fixtures().iter().filter(|f| f.typ == "float32") { - let val = val_f32(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_float32(s, val), |s| encode_float32(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_float32(&mut state).unwrap(); - assert_eq!(decoded.to_bits(), val.to_bits(), "DECODE {}", f.label); - } -} - -#[test] -fn golden_float64() { - for f in load_fixtures().iter().filter(|f| f.typ == "float64") { - let val = val_f64(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_float64(s, val), |s| encode_float64(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_float64(&mut state).unwrap(); - assert_eq!(decoded.to_bits(), val.to_bits(), "DECODE {}", f.label); - } -} - -#[test] -fn golden_bool() { - for f in load_fixtures().iter().filter(|f| f.typ == "bool") { - let val = val_bool(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_bool(s, val), |s| encode_bool(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_bool(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_buffer() { - for f in load_fixtures().iter().filter(|f| f.typ == "buffer") { - let expected = expected_bytes(&f.hex); - - let val: Option> = if f.value.is_null() { - None - } else { - let hex_str = val_str(&f.value); - if hex_str.is_empty() { - // Node.js encodes both null and empty buffer as 0x00 - None - } else { - Some(hex::decode(hex_str).unwrap()) - } - }; - - let val_slice: Option<&[u8]> = val.as_deref(); - let encoded = encode_with_pre( - |s| preencode_buffer(s, val_slice), - |s| encode_buffer(s, val_slice), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_buffer(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_string() { - for f in load_fixtures().iter().filter(|f| f.typ == "string") { - let val = val_str(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre(|s| preencode_string(s, val), |s| encode_string(s, val)); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_string(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_fixed32() { - for f in load_fixtures().iter().filter(|f| f.typ == "fixed32") { - let val_vec = hex::decode(val_str(&f.value)).unwrap(); - let val: [u8; 32] = val_vec.try_into().unwrap(); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| { preencode_fixed32(s, &val).unwrap(); }, - |s| encode_fixed32(s, &val), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_fixed32(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_fixed64() { - for f in load_fixtures().iter().filter(|f| f.typ == "fixed64") { - let val_vec = hex::decode(val_str(&f.value)).unwrap(); - let val: [u8; 64] = val_vec.try_into().unwrap(); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| { preencode_fixed64(s, &val).unwrap(); }, - |s| encode_fixed64(s, &val), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_fixed64(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_ipv4() { - for f in load_fixtures().iter().filter(|f| f.typ == "ipv4") { - let val = val_str(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_ipv4(s, val), - |s| encode_ipv4(s, val).unwrap(), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_ipv4(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_ipv6() { - for f in load_fixtures().iter().filter(|f| f.typ == "ipv6") { - let val = val_str(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_ipv6(s, val), - |s| encode_ipv6(s, val).unwrap(), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - // Our decoder outputs expanded form; compare via Ipv6Addr for canonical equivalence - let mut state = State::from_buffer(&expected); - let decoded = decode_ipv6(&mut state).unwrap(); - let expected_addr: std::net::Ipv6Addr = val.parse().unwrap(); - let decoded_addr: std::net::Ipv6Addr = decoded.parse().unwrap(); - assert_eq!(decoded_addr, expected_addr, "DECODE {}: expected {expected_addr}, got {decoded_addr}", f.label); - } -} - -#[test] -fn golden_ip_dual() { - for f in load_fixtures().iter().filter(|f| f.typ == "ip") { - let val = val_str(&f.value); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_ip(s, val), - |s| encode_ip(s, val).unwrap(), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_ip(&mut state).unwrap(); - - // IPv6 needs canonical comparison since our decoder fully expands addresses - if val.contains(':') { - let expected_addr: std::net::Ipv6Addr = val.parse().unwrap(); - let decoded_addr: std::net::Ipv6Addr = decoded.parse().unwrap(); - assert_eq!(decoded_addr, expected_addr, "DECODE {}", f.label); - } else { - assert_eq!(decoded, val, "DECODE {}", f.label); - } - } -} - -#[test] -fn golden_ipv4_address() { - for f in load_fixtures().iter().filter(|f| f.typ == "ipv4Address") { - let obj = f.value.as_object().unwrap(); - let host = obj["host"].as_str().unwrap(); - let port = obj["port"].as_u64().unwrap() as u16; - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_ipv4_address(s, host, port), - |s| encode_ipv4_address(s, host, port).unwrap(), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let (decoded_host, decoded_port) = decode_ipv4_address(&mut state).unwrap(); - assert_eq!(decoded_host, host, "DECODE host {}", f.label); - assert_eq!(decoded_port, port, "DECODE port {}", f.label); - } -} - -#[test] -fn golden_uint_array() { - for f in load_fixtures().iter().filter(|f| f.typ == "uint_array") { - let val: Vec = f.value.as_array().unwrap().iter().map(val_u64).collect(); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_uint_array(s, &val), - |s| encode_uint_array(s, &val), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_uint_array(&mut state).unwrap(); - assert_eq!(decoded, val, "DECODE {}", f.label); - } -} - -#[test] -fn golden_string_array() { - for f in load_fixtures().iter().filter(|f| f.typ == "string_array") { - let val: Vec<&str> = f.value.as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); - let expected = expected_bytes(&f.hex); - let encoded = encode_with_pre( - |s| preencode_string_array(s, &val), - |s| encode_string_array(s, &val), - ); - assert_eq!(encoded, expected, "ENCODE {}: expected {}, got {}", f.label, f.hex, hex::encode(&encoded)); - - let mut state = State::from_buffer(&expected); - let decoded = decode_string_array(&mut state).unwrap(); - let val_owned: Vec = val.iter().map(|s| s.to_string()).collect(); - assert_eq!(decoded, val_owned, "DECODE {}", f.label); - } -} diff --git a/peeroxide-dht/tests/hyperdht_connect_interop.rs b/peeroxide-dht/tests/hyperdht_connect_interop.rs index 752d78c..5bbfa4b 100644 --- a/peeroxide-dht/tests/hyperdht_connect_interop.rs +++ b/peeroxide-dht/tests/hyperdht_connect_interop.rs @@ -13,7 +13,7 @@ use peeroxide_dht::hyperdht_messages::{ use peeroxide_dht::messages::Ipv4Peer; use peeroxide_dht::noise::Keypair as NoiseKeypair; use peeroxide_dht::noise_wrap::NoiseWrap; -use peeroxide_dht::router::Router; +use peeroxide_dht::Router; use peeroxide_dht::rpc::{DhtConfig, UserRequestParams}; fn to_noise_kp(kp: &KeyPair) -> NoiseKeypair { @@ -74,7 +74,7 @@ async fn run_handshake_test() -> Result<(), Box> { let server_config = ServerConfig::new(server_kp.clone(), 0); let server_rt = UdxRuntime::new()?; - let server_task = tokio::spawn(run_server(srv_rx, server_config, server_rt)); + let server_task = tokio::spawn(run_server(srv_rx, server_config, server_rt, srv_handle.dht().clone())); // ── 3. Client node ──────────────────────────────────────────────────── let mut cli_dht = DhtConfig::default(); @@ -116,15 +116,14 @@ async fn run_handshake_test() -> Result<(), Box> { let hs_value = Router::encode_client_handshake(noise_bytes, None, Some(srv_peer.clone()))?; tracing::info!("sending PEER_HANDSHAKE to server at 127.0.0.1:{srv_port}"); + let mut params = UserRequestParams::default(); + params.command = PEER_HANDSHAKE; + params.target = Some(target); + params.value = Some(hs_value); let resp = cli_handle .dht() .request( - UserRequestParams { - token: None, - command: PEER_HANDSHAKE, - target: Some(target), - value: Some(hs_value), - }, + params, "127.0.0.1", srv_port, ) diff --git a/peeroxide-dht/tests/live_announce_lookup.rs b/peeroxide-dht/tests/live_announce_lookup.rs index 7e8d1be..39e50fd 100644 --- a/peeroxide-dht/tests/live_announce_lookup.rs +++ b/peeroxide-dht/tests/live_announce_lookup.rs @@ -56,6 +56,26 @@ async fn run_rust_only() -> Result<(), Box> { handle_a.announce(topic, &kp, &[]).await?; tracing::info!("announce complete"); + let relays = handle_a.current_relay_addresses(); + tracing::info!("node A current_relay_addresses() returned {} entries", relays.len()); + assert!( + !relays.is_empty(), + "current_relay_addresses() should be populated after a successful announce" + ); + assert!( + relays.len() <= 3, + "current_relay_addresses() should be capped at 3 (got {})", + relays.len() + ); + for relay in &relays { + assert_ne!( + relay.host, "127.0.0.1", + "relay address should NOT be loopback (Bug A)" + ); + assert!(!relay.host.is_empty(), "relay host should be non-empty"); + assert_ne!(relay.port, 0, "relay port should be non-zero"); + } + tokio::time::sleep(Duration::from_millis(500)).await; tracing::info!("node B looking up..."); diff --git a/peeroxide/CHANGELOG.md b/peeroxide/CHANGELOG.md index 3f603db..60b0445 100644 --- a/peeroxide/CHANGELOG.md +++ b/peeroxide/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed a cross-crate UDX stream-id collision in `swarm.rs`'s `create_server_relay_connection`: it reused a `local_stream_id` allocated from `peeroxide`'s own stream-id counter for the blind-relay data stream, but the control connection to the relay (established via `peeroxide-dht`'s `connect_to`) allocates from a separate counter — both starting at 1 independently, they could collide on the same socket and silently clobber the control channel's UDX demux registration. Now uses the new `peeroxide_dht::hyperdht::alloc_stream_id()` to allocate from the same counter `connect_to` uses. + ## [1.3.0](https://github.com/Rightbracket/peeroxide/compare/peeroxide-v1.2.0...peeroxide-v1.3.0) - 2026-05-14 ### Other diff --git a/peeroxide/Cargo.toml b/peeroxide/Cargo.toml index 4e0c628..aec4292 100644 --- a/peeroxide/Cargo.toml +++ b/peeroxide/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "peeroxide" -version = "1.3.1" +version = "1.3.2" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -15,8 +15,8 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -peeroxide-dht = { path = "../peeroxide-dht", version = "1.3.1" } -libudx = { path = "../libudx", version = "1.3.1" } +peeroxide-dht = { path = "../peeroxide-dht", version = "1.7.0" } +libudx = { path = "../libudx", version = "1.4.1" } tokio = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } diff --git a/peeroxide/src/peer_discovery.rs b/peeroxide/src/peer_discovery.rs index 76b3ee3..1263702 100644 --- a/peeroxide/src/peer_discovery.rs +++ b/peeroxide/src/peer_discovery.rs @@ -69,10 +69,38 @@ async fn do_refresh( event_tx: &mpsc::UnboundedSender, ) { if config.is_server { - match dht.announce(config.topic, key_pair, relay_addresses).await { + // Run topic-announce and self-announce on hash(pk) in parallel. + // - Self-announce stores ForwardEntries on hash(pk)-close nodes, + // making them PEER_HANDSHAKE FE-holders for receivers' Phase 2 + // (query_find_peer in connect_with_nodes). + // - Topic-announce stores peer records on topic-close nodes, which + // are returned by `lookup(topic)` to discover this peer. + // Both are independent queries and parallelizing halves refresh + // latency on first start (which matters for short test windows). + // + // On first refresh `current_relay_addresses` is empty — the topic + // record then carries no relay hints; receivers fall through Phase 1 + // immediately into Phase 2 which queries FE-holders directly via + // FIND_PEER on hash(pk). On subsequent refreshes the prior cycle's + // self-announce will have populated `current_relay_addresses` from + // the hash(pk) acker set, which the topic-announce then propagates. + // Mirrors Node's `Announcer.relayAddresses` semantics. + let pk_target = hash(&key_pair.public_key); + let topic_relays: Vec = if relay_addresses.is_empty() { + dht.current_relay_addresses() + } else { + relay_addresses.to_vec() + }; + + let topic_announce = dht.announce(config.topic, key_pair, &topic_relays); + let self_announce = dht.announce(pk_target, key_pair, relay_addresses); + let (topic_res, self_res) = tokio::join!(topic_announce, self_announce); + + match topic_res { Ok(r) => { tracing::debug!( closest = r.closest_nodes.len(), + advertised_relays = topic_relays.len(), "announce complete" ); } @@ -80,12 +108,7 @@ async fn do_refresh( tracing::warn!(err = %e, "announce failed"); } } - - // Self-announce: announce hash(publicKey) so that nodes closest to our - // public key store a ForwardEntry. This is how PEER_HANDSHAKE requests - // get routed — Node.js does this in persistent.js announce(). - let pk_target = hash(&key_pair.public_key); - match dht.announce(pk_target, key_pair, relay_addresses).await { + match self_res { Ok(r) => { tracing::debug!( closest = r.closest_nodes.len(), @@ -114,14 +137,19 @@ async fn do_refresh( relays = ?peer.relay_addresses.iter().map(|a| format!("{}:{}", a.host, a.port)).collect::>(), "discovered peer" ); - let relay_addresses = if peer.relay_addresses.is_empty() { - vec![result.from.clone()] - } else { - peer.relay_addresses - }; + // Forward the peer's own advertised relays only. Do NOT + // fall back to `result.from` (the lookup-responder), + // because that node holds a ForwardEntry keyed on the + // topic, not on hash(peer.public_key). PEER_HANDSHAKE + // targets the latter, so using a topic-relay as a + // PEER_HANDSHAKE forwarder is guaranteed to fail with + // CLOSER_NODES → empty-reply. An empty relay list lets + // connect_with_nodes drop straight into its Phase 2 + // FIND_NODE walk on hash(peer.public_key), which finds + // the nodes the sender actually self-announced to. let _ = event_tx.send(DiscoveryEvent::PeerFound { public_key: peer.public_key, - relay_addresses, + relay_addresses: peer.relay_addresses, topic: config.topic, }); } diff --git a/peeroxide/src/swarm.rs b/peeroxide/src/swarm.rs index 58f9dbe..0a2dde0 100644 --- a/peeroxide/src/swarm.rs +++ b/peeroxide/src/swarm.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fmt; +use std::net::SocketAddr; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; @@ -9,19 +10,24 @@ use tokio::task::JoinHandle; use std::sync::Arc; -use libudx::{RuntimeHandle, UdxRuntime}; +use libudx::{RuntimeHandle, UdxRuntime, UdxSocket, UdxStream}; use peeroxide_dht::crypto::hash; +use peeroxide_dht::holepuncher::{HolepunchEvent, Holepuncher}; use peeroxide_dht::hyperdht::{ self, HyperDhtConfig, HyperDhtHandle, KeyPair, PeerConnection, ServerEvent, }; use peeroxide_dht::hyperdht_messages::{ - encode_handshake_to_bytes, HandshakeMessage, NoisePayload, RelayThroughInfo, SecretStreamInfo, - UdxInfo, MODE_REPLY, + encode_handshake_to_bytes, encode_holepunch_msg_to_bytes, HandshakeMessage, HolepunchInfo, + HolepunchMessage, HolepunchPayload, NoisePayload, RelayInfo, RelayThroughInfo, + SecretStreamInfo, UdxInfo, ERROR_NONE, FIREWALL_CONSISTENT, FIREWALL_RANDOM, FIREWALL_UNKNOWN, + MODE_FROM_RELAY, MODE_FROM_SECOND_RELAY, MODE_FROM_SERVER, MODE_REPLY, }; use peeroxide_dht::messages::Ipv4Peer; use peeroxide_dht::noise::Keypair as NoiseKeypair; -use peeroxide_dht::noise_wrap::NoiseWrap; +use peeroxide_dht::noise_wrap::{NoiseWrap, NoiseWrapResult}; use peeroxide_dht::secret_stream::SecretStream; +use peeroxide_dht::secure_payload::SecurePayload; +use peeroxide_dht::socket_pool::SocketPool; use crate::connection_set::{ConnectionInfo, ConnectionSet}; use crate::error::SwarmError; @@ -94,6 +100,14 @@ pub struct SwarmConfig { /// Socket address of the relay node. When provided alongside `relay_through`, /// the server connects to the relay directly instead of discovering it via DHT. pub relay_address: Option, + /// Enable the same-NAT LAN-shortcut (default `true`). When `false`, + /// receivers ignore the loopback/private addresses advertised in the + /// server's `addresses4` and always dial the public IP (= FE-holder's + /// peer_address tag). Mirrors Node hyperdht `opts.localConnection`. + /// + /// Tests that want to exercise the real network path (without leaning + /// on same-host loopback) should set this to `false`. + pub local_connection: bool, } impl Default for SwarmConfig { @@ -106,6 +120,7 @@ impl Default for SwarmConfig { firewall: 0, relay_through: None, relay_address: None, + local_connection: true, } } } @@ -301,6 +316,7 @@ struct ActorConfig { firewall: u64, relay_through: Option<[u8; 32]>, relay_address: Option, + local_connection: bool, } struct SwarmActor { @@ -308,6 +324,7 @@ struct SwarmActor { dht: HyperDhtHandle, config: ActorConfig, runtime_handle: Arc, + local_port: u16, topics: HashMap<[u8; 32], TopicState>, discovery_event_tx: mpsc::UnboundedSender, @@ -323,6 +340,65 @@ struct SwarmActor { active_connects: usize, flush_waiters: Vec>>, + server_failure_tx: Option>, + + // Passive-holepunch state. When the server is firewalled, each accepted + // handshake stages an InFlightHolepunch entry instead of immediately + // creating a UDX connection. The entry persists across PEER_HOLEPUNCH + // rounds (preserving SecurePayload tokens + the Holepuncher's NAT + // analysis) and is resolved into a real SwarmConnection only when the + // firewall hook on the puncher's primary socket commits the 4-tuple. + connects: HashMap, + pending_handshakes: HashMap<[u8; 32], u64>, + next_holepunch_id: u64, + passive_hp_event_tx: mpsc::UnboundedSender, +} + +/// State carried between the initial server handshake and the moment the +/// passive Holepuncher's primary socket sees its first probe. Owned +/// directly by `SwarmActor` (single-threaded mutation, no `Arc`). +struct InFlightHolepunch { + remote_pk: [u8; 32], + /// Persists across rounds so `token()` derivation is stable per Node + /// `lib/holepuncher.js`. + payload: SecurePayload, + /// `None` once `punch()` has been spawned (the task takes ownership). + /// Round handling between spawn and punch-land only updates state that + /// can be reconstructed from the incoming round. + puncher: Option, + /// Listening-mode UDX stream whose `set_firewall_hook` will commit the + /// 4-tuple when the first probe lands. + udx_stream: UdxStream, + /// Clone of the puncher's primary `UdxSocket`. Held independently so + /// the live UDP socket survives even after `puncher` is moved into a + /// spawned `punch()` task, and so we have something to hand to + /// `PeerConnection::new` when finalizing. + udx_socket: UdxSocket, + /// Kept verbatim from the original handshake; consumed by `SecretStream::from_session` + /// when the punch lands. + noise_result: NoiseWrapResult, + /// Highest round number we have accepted so far. Used purely to detect + /// reordered PEER_HOLEPUNCH duplicates; replies always echo + /// `remote_hp.round` to stay in lockstep with the client loop. + round: u64, + /// 10s deadline. Cancelled when the firewall hook fires or when a new + /// handshake from the same remote_pk preempts this entry. + abort_task: Option>, + /// Pre-computed addresses advertised in PEER_HOLEPUNCH reply + /// `addresses`. MUST point at OUR puncher socket, not echo back the + /// initiator's reflexive address (Phase 3 MVP: loopback only). + local_punch_addrs: Vec, +} + +/// Internal actor-loop events fired by the passive-holepunch path. +/// Decoupled from `SwarmCommand` because the firewall-hook closure must be +/// `FnOnce + Send + 'static` and cannot await the public command channel. +enum PassiveHolepunchEvent { + /// Firewall hook fired — the puncher's primary socket has bound a + /// remote 4-tuple at `addr`. Time to finalize the SecretStream. + Punched { id: u64, addr: SocketAddr }, + /// 10s deadline elapsed without the hook firing. + Abort { id: u64 }, } struct ConnectAttemptResult { @@ -346,16 +422,30 @@ pub async fn spawn( dht.bootstrapped().await?; let local_port = dht.dht().local_port().await?; - let relay_address = Ipv4Peer { - host: "127.0.0.1".to_string(), - port: local_port, + // Fetch the actual port of the socket the server-side firewall hook + // listens on (matches `dht.server_socket()` = primary_socket port). This + // is what we must advertise in `addresses4` so the receiver dials the + // same UDP socket where the sender's UDX demux is registered. For + // firewalled nodes (default) this is the client_socket port, NOT the + // dht `local_port` (which is the server_socket port). + let primary_socket_port = match dht.server_socket().await? { + Some(s) => match s.local_addr().await { + Ok(addr) => addr.port(), + Err(_) => local_port, + }, + None => local_port, }; - - tracing::info!(port = local_port, "swarm started"); + tracing::info!( + target: "peeroxide::_events::swarm::started", + port = local_port, + primary_port = primary_socket_port, + "swarm started" + ); let (cmd_tx, cmd_rx) = mpsc::channel(64); let (conn_tx, conn_rx) = mpsc::channel(64); let (discovery_event_tx, discovery_event_rx) = mpsc::unbounded_channel(); + let (passive_hp_event_tx, passive_hp_event_rx) = mpsc::unbounded_channel(); let handle_dht = dht.clone(); let handle_key_pair = key_pair.clone(); @@ -369,8 +459,10 @@ pub async fn spawn( firewall: config.firewall, relay_through: config.relay_through, relay_address: config.relay_address, + local_connection: config.local_connection, }, runtime_handle: runtime.handle(), + local_port: primary_socket_port, topics: HashMap::new(), discovery_event_tx, peers: HashMap::new(), @@ -378,16 +470,24 @@ pub async fn spawn( queue: Vec::new(), conn_tx, server_registered: false, - relay_address: Some(relay_address), + relay_address: config.relay_address.map(|addr| Ipv4Peer { + host: addr.ip().to_string(), + port: addr.port(), + }), active_connects: 0, flush_waiters: Vec::new(), + server_failure_tx: None, + connects: HashMap::new(), + pending_handshakes: HashMap::new(), + next_holepunch_id: 0, + passive_hp_event_tx, }; // Keep the DHT runtime alive for the swarm's lifetime. // We must await dht_join AFTER actor.run() (which calls dht.destroy()), // so the DhtNode finishes closing its IO sockets before we drop the runtime. let join = tokio::spawn(async move { - actor.run(cmd_rx, discovery_event_rx, server_rx).await; + actor.run(cmd_rx, discovery_event_rx, server_rx, passive_hp_event_rx).await; let _ = dht_join.await; drop(runtime); }); @@ -408,9 +508,18 @@ impl SwarmActor { mut cmd_rx: mpsc::Receiver, mut discovery_rx: mpsc::UnboundedReceiver, mut server_rx: mpsc::UnboundedReceiver, + mut passive_hp_event_rx: mpsc::UnboundedReceiver, ) { let (connect_result_tx, mut connect_result_rx) = mpsc::unbounded_channel::(); + // Reverse channel: spawned create_server_connection tasks signal back + // on failure so the actor can release the pre-emptively reserved + // `connections` slot. Without this, a single failed handshake stream + // would lock that peer's slot forever, blocking any retry from a + // different relay path. + let (server_failure_tx, mut server_failure_rx) = + mpsc::unbounded_channel::<[u8; 32]>(); + self.server_failure_tx = Some(server_failure_tx); loop { tokio::select! { @@ -427,7 +536,7 @@ impl SwarmActor { } event = server_rx.recv() => { if let Some(event) = event { - self.handle_server_event(event); + self.handle_server_event(event).await; } } result = connect_result_rx.recv() => { @@ -435,6 +544,18 @@ impl SwarmActor { self.handle_connect_result(result, &connect_result_tx); } } + pk = server_failure_rx.recv() => { + if let Some(pk) = pk { + if self.connections.remove(&pk) { + tracing::debug!(pk = %short_hex(&pk), "server: stream failed, released connection slot"); + } + } + } + event = passive_hp_event_rx.recv() => { + if let Some(event) = event { + self.handle_passive_holepunch_event(event).await; + } + } } } @@ -597,13 +718,22 @@ impl SwarmActor { info.queued = true; info.priority = info.get_priority(); self.queue.push(public_key); - self.attempt_connections(connect_result_tx); + // Defer the dial attempt until RefreshComplete to avoid a + // queue-time relay-snapshot race. A single lookup typically + // yields multiple PeerFound events per peer (one per + // responding FE-holder) and relay_addresses accumulates + // across them. Spawning the dial on the first event would + // snapshot a partial list; the spawned task can't pick up + // later relay additions. RefreshComplete fires once all + // PeerFound events for a refresh have been processed, so + // info.relay_addresses is at its widest by then. } } DiscoveryEvent::RefreshComplete { topic } => { if let Some(state) = self.topics.get_mut(&topic) { state.refreshed = true; } + self.attempt_connections(connect_result_tx); self.check_flush_waiters(); } } @@ -642,23 +772,34 @@ impl SwarmActor { let key_pair = self.key_pair.clone(); let result_tx = connect_result_tx.clone(); let rh = self.runtime_handle.clone(); + let mut connect_opts = peeroxide_dht::hyperdht::ConnectOpts::default(); + connect_opts.local_connection = self.config.local_connection; tokio::spawn(async move { let conn_runtime = UdxRuntime::shared(rh); tracing::debug!(pk = %short_hex(&pk), "connecting to peer"); match dht - .connect_with_nodes(&key_pair, pk, &relay_addrs, &conn_runtime) + .connect_with_options(&key_pair, pk, &relay_addrs, &conn_runtime, connect_opts) .await { Ok(conn) => { - tracing::debug!(pk = %short_hex(&pk), "peer connected"); + tracing::info!( + target: "peeroxide::_events::peer::connected", + pk = %short_hex(&pk), + "peer connected" + ); let _ = result_tx.send(ConnectAttemptResult { public_key: pk, result: Ok((conn, conn_runtime)), }); } Err(e) => { - tracing::debug!(pk = %short_hex(&pk), err = %e, "peer connect failed"); + tracing::info!( + target: "peeroxide::_events::peer::connect_failed", + pk = %short_hex(&pk), + err = %e, + "peer connect failed" + ); let _ = result_tx.send(ConnectAttemptResult { public_key: pk, result: Err(SwarmError::Dht(e)), @@ -750,31 +891,48 @@ impl SwarmActor { }); } - fn handle_server_event(&mut self, event: ServerEvent) { + async fn handle_server_event(&mut self, event: ServerEvent) { match event { ServerEvent::PeerHandshake { msg, from, + peer_address, target: _, reply_tx, } => { - self.handle_server_handshake(msg, from, reply_tx); + self.handle_server_handshake(msg, from, peer_address, reply_tx).await; } - ServerEvent::PeerHolepunch { reply_tx, .. } => { - // Holepunch handling requires libudx incoming-stream support. - // Acknowledge without creating a connection. - let _ = reply_tx.send(None); + ServerEvent::PeerHolepunch { + msg, + from: _, + peer_address, + target: _, + reply_tx, + } => { + self.handle_peer_holepunch(msg, peer_address, reply_tx).await; } _ => {} } } - fn handle_server_handshake( + async fn handle_server_handshake( &mut self, msg: HandshakeMessage, from: Ipv4Peer, + peer_address: Option, reply_tx: oneshot::Sender>>, ) { + let initial_client_address = peer_address.clone().unwrap_or_else(|| from.clone()); + let is_forwarded = peer_address.is_some(); + + if is_forwarded { + tracing::debug!( + relay = %format!("{}:{}", from.host, from.port), + peer = %format!("{}:{}", initial_client_address.host, initial_client_address.port), + "server handshake: forwarded — dialing peer_address" + ); + } + let noise_kp = NoiseKeypair { public_key: self.key_pair.public_key, secret_key: self.key_pair.secret_key, @@ -796,6 +954,8 @@ impl SwarmActor { return; } + let client_address = initial_client_address; + let local_stream_id = next_stream_id(); let (relay_token, relay_through_info) = if let Some(relay_pk) = self.config.relay_through { @@ -810,12 +970,76 @@ impl SwarmActor { (None, None) }; + // Decide whether this reply will stage a passive holepunch: + // - the DHT has settled on a firewalled classification + // (CONSISTENT or RANDOM — not OPEN, not UNKNOWN); UNKNOWN + // means NAT analysis hasn't gathered enough samples to be + // sure, in which case the existing direct path is the safer + // choice (Node hyperdht has the same gate), AND + // - we are not forcing all server connections through a relay + // (relay_through bypasses NAT traversal entirely). + let firewall_state = self.dht.firewalled().await.unwrap_or(FIREWALL_UNKNOWN); + let attempt_holepunch = matches!(firewall_state, FIREWALL_CONSISTENT | FIREWALL_RANDOM) + && relay_through_info.is_none(); + + // Try to stage the passive Holepuncher up front so the reply we + // send in this handshake can advertise the puncher's bound port + // and the holepunch session id. If staging fails, fall back to a + // reply with `holepunch: None` (the existing direct path); a + // genuinely firewalled receiver will then time out and retry via + // discovery rather than getting stuck on a broken half-state. + let holepunch_setup = if attempt_holepunch { + match self + .try_setup_passive_holepunch( + remote_payload.firewall, + local_stream_id, + remote_payload.udx.as_ref(), + ) + .await + { + Ok(setup) => Some(setup), + Err(e) => { + tracing::debug!(err = %e, "passive holepunch setup failed; sending direct-path reply"); + None + } + } + } else { + None + }; + + // addresses4 advertised in our noise reply: real LAN interfaces + // (Node parity, `hyperdht/lib/server.js:277-284`). The port we + // pair with each address is the puncher socket's bound port when + // a passive holepunch is staged — that's where the firewall hook + // is registered, so same-host receivers dialling it land on a + // real punch. Otherwise advertise the DHT primary socket port. + let advertise_port = holepunch_setup + .as_ref() + .map_or(self.local_port, |s| s.puncher_port); + let addresses4 = self.dht.noise_addresses4(advertise_port).await; + + let holepunch_info = holepunch_setup.as_ref().map(|setup| { + let relays: Vec = self + .dht + .current_relay_addresses() + .into_iter() + .map(|relay_addr| RelayInfo { + relay_address: relay_addr, + peer_address: client_address.clone(), + }) + .collect(); + HolepunchInfo { + id: setup.id, + relays, + } + }); + let reply_payload = NoisePayload { version: 1, error: 0, firewall: self.config.firewall, - holepunch: None, - addresses4: vec![], + holepunch: holepunch_info, + addresses4, addresses6: vec![], udx: Some(UdxInfo { version: 1, @@ -826,7 +1050,7 @@ impl SwarmActor { secret_stream: Some(SecretStreamInfo { version: 1 }), relay_through: relay_through_info, relay_addresses: self.config.relay_address.map(|addr| { - vec![peeroxide_dht::messages::Ipv4Peer { + vec![Ipv4Peer { host: addr.ip().to_string(), port: addr.port(), }] @@ -837,6 +1061,9 @@ impl SwarmActor { Ok(b) => b, Err(e) => { tracing::debug!(err = %e, "server handshake: noise send failed"); + if let Some(setup) = holepunch_setup { + setup.abort_task.abort(); + } let _ = reply_tx.send(None); return; } @@ -846,21 +1073,48 @@ impl SwarmActor { Ok(r) => r, Err(e) => { tracing::debug!(err = %e, "server handshake: noise finalize failed"); + if let Some(setup) = holepunch_setup { + setup.abort_task.abort(); + } let _ = reply_tx.send(None); return; } }; + // Encode reply with mode + peer_address derived from inbound mode. + // Per Node `lib/server.js _addHandshake`: + // - inbound FROM_CLIENT → respond MODE_REPLY (direct reply path). + // - inbound FROM_RELAY / FROM_SECOND_RELAY → respond MODE_FROM_SERVER + // with peer_address pointing to the originating client (carried in + // the inbound msg.peer_address). The dht layer will dispatch this + // via dht.relay_with_tid back to the FE-holder, which then routes + // a tid-preserved REPLY to the receiver. + let (reply_mode, reply_peer_address) = match msg.mode { + MODE_FROM_RELAY | MODE_FROM_SECOND_RELAY => { + (MODE_FROM_SERVER, peer_address.clone()) + } + _ => (MODE_REPLY, None), + }; + let reply_msg = HandshakeMessage { - mode: MODE_REPLY, + mode: reply_mode, noise: noise_reply, - peer_address: None, + peer_address: reply_peer_address, relay_address: None, }; let _ = reply_tx.send(encode_handshake_to_bytes(&reply_msg).ok()); let remote_pk = nw_result.remote_public_key; + // If we staged a passive holepunch above, hand ownership of the + // staged state into the actor's `connects` map and return. Slot + // accounting (self.connections.add) is deferred until the firewall + // hook fires — see `PassiveHolepunchEvent::Punched` handling. + if let Some(setup) = holepunch_setup { + self.commit_passive_holepunch(setup, remote_pk, nw_result); + return; + } + if self.connections.has(&remote_pk) { tracing::debug!(pk = %short_hex(&remote_pk), "server: already connected"); return; @@ -888,6 +1142,7 @@ impl SwarmActor { let key_pair = self.key_pair.clone(); let relay_addr = self.config.relay_address; let rh = self.runtime_handle.clone(); + let failure_tx = self.server_failure_tx.clone(); tokio::spawn(async move { match create_server_relay_connection( rh, @@ -896,7 +1151,6 @@ impl SwarmActor { relay_pk, relay_addr, token, - local_stream_id, nw_result, ) .await @@ -914,14 +1168,19 @@ impl SwarmActor { } Err(e) => { tracing::debug!(err = %e, "server: relay connection failed"); + if let Some(tx) = failure_tx { + let _ = tx.send(remote_pk); + } } } }); } else { let rh = self.runtime_handle.clone(); let dht = self.dht.clone(); + let client_addr_for_task = client_address.clone(); + let failure_tx = self.server_failure_tx.clone(); tokio::spawn(async move { - match create_server_connection(rh, dht, local_stream_id, &remote_udx, &from, &nw_result) + match create_server_connection(rh, dht, local_stream_id, &remote_udx, &client_addr_for_task, &nw_result) .await { Ok((conn, runtime)) => { @@ -937,6 +1196,9 @@ impl SwarmActor { } Err(e) => { tracing::debug!(err = %e, "server: stream establishment failed"); + if let Some(tx) = failure_tx { + let _ = tx.send(remote_pk); + } } } }); @@ -954,6 +1216,371 @@ impl SwarmActor { } } } + + /// Stage a passive Holepuncher, create its listening UdxStream, wire + /// the firewall hook, and arm the 10s abort timer — all the work that + /// must happen *before* the handshake reply goes out (the reply has + /// to advertise the puncher's bound port and the session id). + async fn try_setup_passive_holepunch( + &mut self, + remote_firewall: u64, + local_stream_id: u32, + remote_udx: Option<&UdxInfo>, + ) -> Result { + use peeroxide_dht::hyperdht::HyperDhtError; + + let remote_udx = remote_udx.ok_or_else(|| { + SwarmError::Dht(HyperDhtError::HandshakeFailed( + "passive holepunch: client did not advertise UDX info".into(), + )) + })?; + + let remote_id = u32::try_from(remote_udx.id).map_err(|_| { + SwarmError::Dht(HyperDhtError::StreamEstablishment( + "remote UDX id out of u32 range".into(), + )) + })?; + + let pool = SocketPool::new("0.0.0.0".into()); + let runtime = UdxRuntime::shared(self.runtime_handle.clone()); + + // Per the Holepuncher contract this is_initiator=false, firewalled=true + // construction is the passive side of the punch. The HolepunchEvent + // channel is intentionally discarded (`_event_rx` is dropped): the + // passive puncher never emits `Connected` itself — the libudx + // firewall hook on its primary socket is the authoritative + // punch-landed signal for the server side. + let (event_tx, _event_rx) = mpsc::unbounded_channel::(); + let mut puncher = Holepuncher::new(&pool, &runtime, true, false, remote_firewall, event_tx) + .await + .map_err(|e| { + SwarmError::Dht(HyperDhtError::HandshakeFailed(format!( + "passive holepunch: socket pool acquire failed: {e}" + ))) + })?; + + // Seed the passive puncher's NAT BEFORE punch() can run. auto_sample + // takes ownership of the puncher socket's dht_reply_rx and must be + // the only consumer — running it first guarantees that. Without this + // the passive NAT stays UNKNOWN and the coerce_firewall revert + // (removing UNKNOWN→CONSISTENT) would break the passive punch path. + // Mirrors Node lib/holepuncher.js:13-20. + let _added = puncher.auto_sample(self.dht.dht()).await; + + let socket_ref = puncher.primary_socket().ok_or_else(|| { + SwarmError::Dht(HyperDhtError::StreamEstablishment( + "puncher returned no primary socket".into(), + )) + })?; + let socket = socket_ref.socket.clone(); + let socket_addr = socket.local_addr().await.map_err(|e| { + SwarmError::Dht(HyperDhtError::StreamEstablishment(format!( + "puncher socket local_addr failed: {e}" + ))) + })?; + let puncher_port = socket_addr.port(); + + let udx_stream = runtime.create_stream(local_stream_id).await?; + + let holepunch_id = self.next_holepunch_id; + self.next_holepunch_id = self.next_holepunch_id.wrapping_add(1); + + // Single-fire hook: the FIRST inbound packet whose 4-tuple matches + // (local_id == this stream, source is currently unknown) commits + // the remote address and unblocks the connection. The closure must + // be FnOnce + Send + 'static, so we send the result to the actor + // through an mpsc channel rather than awaiting anything inline. + let event_tx = self.passive_hp_event_tx.clone(); + udx_stream.set_firewall_hook(&socket, remote_id, move |_sock, port, ip| { + let addr = SocketAddr::new(ip, port); + let _ = event_tx.send(PassiveHolepunchEvent::Punched { + id: holepunch_id, + addr, + }); + true + })?; + + let abort_tx = self.passive_hp_event_tx.clone(); + let abort_task = tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(10)).await; + let _ = abort_tx.send(PassiveHolepunchEvent::Abort { id: holepunch_id }); + }); + + Ok(HolepunchSetup { + id: holepunch_id, + puncher_port, + puncher, + udx_stream, + udx_socket: socket, + abort_task, + }) + } + + /// Move a successfully-staged setup into the `connects` map. Performs + /// remote_pk dedup: if a previous handshake from this same peer is + /// still in-flight, its puncher/stream/timer are dropped here so the + /// new entry replaces them cleanly. + fn commit_passive_holepunch( + &mut self, + setup: HolepunchSetup, + remote_pk: [u8; 32], + noise_result: NoiseWrapResult, + ) { + if let Some(old_id) = self.pending_handshakes.insert(remote_pk, setup.id) { + if let Some(old_entry) = self.connects.remove(&old_id) { + if let Some(task) = old_entry.abort_task { + task.abort(); + } + tracing::debug!( + pk = %short_hex(&remote_pk), + old_id, + new_id = setup.id, + "passive holepunch: replacing stale in-flight entry" + ); + } + } + + let payload = SecurePayload::new(noise_result.holepunch_secret); + + // Holepunch payload `addresses` list (Algorithm B): prefer + // autoSample-derived reflexive samples on the puncher; fall + // back to local LAN interfaces when autoSample produced none. + // Mirrors Node `hyperdht/lib/holepuncher.js:221-227`. + let local_punch_addrs = setup.puncher.punch_addresses(setup.puncher_port); + + self.connects.insert( + setup.id, + InFlightHolepunch { + remote_pk, + payload, + puncher: Some(setup.puncher), + udx_stream: setup.udx_stream, + udx_socket: setup.udx_socket, + noise_result, + round: 0, + abort_task: Some(setup.abort_task), + local_punch_addrs, + }, + ); + } + + async fn handle_peer_holepunch( + &mut self, + msg: HolepunchMessage, + peer_address: Ipv4Peer, + reply_tx: oneshot::Sender>>, + ) { + let Some(entry) = self.connects.get_mut(&msg.id) else { + tracing::debug!(id = msg.id, "peer holepunch: unknown id"); + let _ = reply_tx.send(None); + return; + }; + + let remote_hp = match entry.payload.decrypt(&msg.payload) { + Ok(p) => p, + Err(e) => { + tracing::debug!(id = msg.id, err = %e, "peer holepunch: decrypt failed"); + let _ = reply_tx.send(None); + return; + } + }; + + // Update the puncher's view of the remote side. Once `punch()` has + // been moved into its spawned task, the puncher slot is `None` and + // further updates are no-ops — by that point the probe loop is + // already running with the verified address it captured. + if let Some(puncher) = entry.puncher.as_mut() { + if let Some(addrs) = &remote_hp.addresses { + puncher.update_remote( + remote_hp.punching, + remote_hp.firewall, + addrs, + Some(peer_address.host.as_str()), + ); + } else { + puncher.update_remote( + remote_hp.punching, + remote_hp.firewall, + &[], + Some(peer_address.host.as_str()), + ); + } + // The passive puncher's NAT sampler is never fed (only the + // active/initiator side's `run_holepunch_rounds` calls + // `puncher.nat.add(...)` from PEER_HOLEPUNCH replies), so + // `puncher.analyze(false).await` would deadlock forever + // here, blocking the entire SwarmActor event loop. The + // passive side's success signal is the firewall hook on + // the puncher's primary UDX stream firing when the punch + // probe lands — not a NAT classification gate. + } + + if remote_hp.round > entry.round { + entry.round = remote_hp.round; + } + + // Spawn the active probe task only on the first round where the + // initiator asks us to punch. `puncher.punch()` takes &mut self and + // runs a long-lived probe loop, so the puncher is moved out and + // owned by the task — subsequent rounds just keep building replies. + if remote_hp.punching && entry.puncher.is_some() { + if let Some(mut puncher) = entry.puncher.take() { + tokio::spawn(async move { + let pool = SocketPool::new("0.0.0.0".into()); + if let Ok(rt) = UdxRuntime::new() { + puncher.punch(&pool, &rt).await; + } + }); + } + } + + let server_firewall = self.dht.firewalled().await.unwrap_or(FIREWALL_UNKNOWN); + let reply_hp = HolepunchPayload { + error: ERROR_NONE, + firewall: server_firewall, + round: remote_hp.round, + connected: false, + punching: remote_hp.punching, + addresses: Some(entry.local_punch_addrs.clone()), + remote_address: Some(peer_address.clone()), + token: Some(entry.payload.token(&peer_address.host)), + remote_token: remote_hp.token, + }; + + let encrypted = match entry.payload.encrypt(&reply_hp) { + Ok(b) => b, + Err(e) => { + tracing::debug!(id = msg.id, err = %e, "peer holepunch: encrypt failed"); + let _ = reply_tx.send(None); + return; + } + }; + + // Mirror item-8 handshake reply-mode selection: relayed inbound + // (FROM_RELAY / FROM_SECOND_RELAY) MUST be answered with mode + // FROM_SERVER so the FE-holder's router dispatches a tid-preserved + // ReplyTo back to the original client. A direct FROM_CLIENT inbound + // (currently unused for holepunch, but kept for symmetry) is + // answered with MODE_REPLY. + let reply_mode = match msg.mode { + MODE_FROM_RELAY | MODE_FROM_SECOND_RELAY => MODE_FROM_SERVER, + _ => MODE_REPLY, + }; + + let reply_msg = HolepunchMessage { + mode: reply_mode, + id: msg.id, + payload: encrypted, + peer_address: Some(peer_address), + }; + + let _ = reply_tx.send(encode_holepunch_msg_to_bytes(&reply_msg).ok()); + } + + async fn handle_passive_holepunch_event(&mut self, event: PassiveHolepunchEvent) { + match event { + PassiveHolepunchEvent::Punched { id, addr } => { + let Some(mut entry) = self.connects.remove(&id) else { + return; + }; + if let Some(task) = entry.abort_task.take() { + task.abort(); + } + self.pending_handshakes.remove(&entry.remote_pk); + + if self.connections.has(&entry.remote_pk) { + tracing::debug!( + pk = %short_hex(&entry.remote_pk), + "passive holepunch landed but already connected; dropping" + ); + return; + } + if self.connections.len() >= self.config.max_peers { + tracing::debug!("passive holepunch landed but at max connections"); + return; + } + + tracing::debug!( + id, + pk = %short_hex(&entry.remote_pk), + ?addr, + "passive holepunch landed; finalizing SecretStream" + ); + + self.connections + .add(entry.remote_pk, ConnectionInfo { is_initiator: false }); + + let runtime = UdxRuntime::shared(self.runtime_handle.clone()); + let conn_tx = self.conn_tx.clone(); + let failure_tx = self.server_failure_tx.clone(); + let remote_pk = entry.remote_pk; + + tokio::spawn(async move { + let async_stream = entry.udx_stream.into_async_stream(); + let ss = match SecretStream::from_session( + false, + async_stream, + entry.noise_result.tx, + entry.noise_result.rx, + entry.noise_result.handshake_hash, + entry.noise_result.remote_public_key, + ) + .await + { + Ok(s) => s, + Err(e) => { + tracing::debug!(err = %e, "passive holepunch: SecretStream failed"); + if let Some(tx) = failure_tx { + let _ = tx.send(remote_pk); + } + return; + } + }; + + let conn = PeerConnection::new(ss, remote_pk, entry.udx_socket, None); + let swarm_conn = SwarmConnection { + peer: conn, + is_initiator: false, + topics: vec![], + _runtime: runtime, + }; + if conn_tx.send(swarm_conn).await.is_err() { + tracing::warn!("connection channel closed"); + } + }); + } + PassiveHolepunchEvent::Abort { id } => { + let Some(mut entry) = self.connects.remove(&id) else { + return; + }; + if let Some(task) = entry.abort_task.take() { + task.abort(); + } + self.pending_handshakes.remove(&entry.remote_pk); + if let Some(mut puncher) = entry.puncher.take() { + puncher.destroy(); + } + tracing::warn!( + id, + pk = %short_hex(&entry.remote_pk), + "passive holepunch timed out" + ); + } + } + } +} + +/// Output of [`SwarmActor::try_setup_passive_holepunch`]. Holds everything +/// the actor needs to (a) build the handshake reply and (b) install the +/// in-flight entry once Noise finalisation has produced a `remote_pk` + +/// holepunch secret. +struct HolepunchSetup { + id: u64, + puncher_port: u16, + puncher: Holepuncher, + udx_stream: UdxStream, + udx_socket: UdxSocket, + abort_task: JoinHandle<()>, } async fn create_server_connection( @@ -961,7 +1588,7 @@ async fn create_server_connection( dht: HyperDhtHandle, local_stream_id: u32, remote_udx: &UdxInfo, - client_address: &Ipv4Peer, + _client_address: &Ipv4Peer, noise_result: &peeroxide_dht::noise_wrap::NoiseWrapResult, ) -> Result<(PeerConnection, UdxRuntime), SwarmError> { let runtime = UdxRuntime::shared(runtime_handle); @@ -972,27 +1599,18 @@ async fn create_server_connection( )) })?; - let addr: std::net::SocketAddr = std::net::SocketAddr::new( - client_address.host.parse().map_err(|_| { - SwarmError::Dht(peeroxide_dht::hyperdht::HyperDhtError::StreamEstablishment( - "invalid client address".into(), - )) - })?, - client_address.port, - ); - let socket = dht - .listen_socket() + .server_socket() .await .map_err(SwarmError::Dht)? .ok_or_else(|| { SwarmError::Dht(peeroxide_dht::hyperdht::HyperDhtError::StreamEstablishment( - "DHT listen socket not available".into(), + "DHT server socket not available".into(), )) })?; let stream = runtime.create_stream(local_stream_id).await?; - stream.connect(&socket, remote_id, addr).await?; + stream.set_firewall_hook(&socket, remote_id, |_, _, _| true)?; let async_stream = stream.into_async_stream(); let ss = SecretStream::from_session( @@ -1006,7 +1624,7 @@ async fn create_server_connection( .await .map_err(|e| SwarmError::Dht(peeroxide_dht::hyperdht::HyperDhtError::SecretStream(e)))?; - let conn = PeerConnection::with_remote_addr(ss, noise_result.remote_public_key, addr, socket, None); + let conn = PeerConnection::new(ss, noise_result.remote_public_key, socket, None); Ok((conn, runtime)) } @@ -1018,7 +1636,6 @@ async fn create_server_relay_connection( relay_pk: [u8; 32], relay_addr: Option, token: [u8; 32], - local_stream_id: u32, noise_result: peeroxide_dht::noise_wrap::NoiseWrapResult, ) -> Result<(PeerConnection, UdxRuntime), SwarmError> { use peeroxide_dht::blind_relay::BlindRelayClient; @@ -1067,8 +1684,21 @@ async fn create_server_relay_connection( .await .map_err(|e| SwarmError::Dht(peeroxide_dht::hyperdht::HyperDhtError::Relay(e)))?; + // Mint a fresh data-stream id from the *same* counter `dht.connect_to` + // used internally for the control connection above (both reuse + // `relay_conn.socket`). Reusing the `local_stream_id` parameter here + // (originally allocated from this crate's own, separately-counted + // `next_stream_id()` and already advertised in the direct-connect + // handshake reply) risks colliding with whatever id the control + // connection registered on that same socket, since the two counters + // are independent and can coincidentally produce the same value — + // silently clobbering the control channel's demux registration and + // breaking it out from under the still-running Protomux/blind-relay + // exchange (observed as an immediate spurious "stream closed"). + let data_stream_id = peeroxide_dht::hyperdht::alloc_stream_id(); + let pair_response = relay_client - .pair(true, &token, u64::from(local_stream_id)) + .pair(true, &token, u64::from(data_stream_id)) .await .map_err(|e| SwarmError::Dht(peeroxide_dht::hyperdht::HyperDhtError::Relay(e)))?; @@ -1080,7 +1710,7 @@ async fn create_server_relay_connection( // 4. Connect data UDX stream through the relay, reusing the control // channel's socket so the relay sees traffic from the same source address. - let data_stream = runtime.create_stream(local_stream_id).await?; + let data_stream = runtime.create_stream(data_stream_id).await?; data_stream .connect(&relay_conn.socket, remote_id, relay_addr) .await?; diff --git a/tests/docker/Dockerfile.node-peer b/tests/docker/Dockerfile.node-peer index ed0885d..482dee3 100644 --- a/tests/docker/Dockerfile.node-peer +++ b/tests/docker/Dockerfile.node-peer @@ -6,10 +6,14 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY peeroxide/tests/node/package.json peeroxide/tests/node/package-lock.json* ./ +COPY tests/node/package.json tests/node/package-lock.json* ./ RUN npm install -COPY peeroxide/tests/node/ ./ -COPY peeroxide/tests/docker/scripts/ ./scripts/ +COPY tests/node/ ./ +COPY tests/docker/scripts/ ./scripts/ -ENTRYPOINT ["/bin/bash"] +# No ENTRYPOINT: docker-compose.nat.yml's `command:` (e.g. ["node", +# "nat-bootstrap.js", ...]) must run directly. An `ENTRYPOINT ["/bin/bash"]` +# here would instead make `command` a list of *arguments to bash*, so +# `command: ["node", ...]` would try (and fail) to run `bash node ...`, +# i.e. execute the `node` binary as a shell script. diff --git a/tests/docker/Dockerfile.rust-peer b/tests/docker/Dockerfile.rust-peer index 5d9b151..bbc7c70 100644 --- a/tests/docker/Dockerfile.rust-peer +++ b/tests/docker/Dockerfile.rust-peer @@ -22,6 +22,8 @@ RUN apt-get update && apt-get install -y \ COPY --from=builder /src/target/release/peeroxide /usr/local/bin/peeroxide WORKDIR /app -COPY peeroxide/tests/docker/scripts/ /app/scripts/ +COPY tests/docker/scripts/ /app/scripts/ -ENTRYPOINT ["/bin/bash"] +# No ENTRYPOINT here either - see Dockerfile.node-peer for why. The +# compose `command:` (e.g. ["bash", "/app/scripts/run-peer.sh", "sender"]) +# must run directly, not as arguments to an implicit bash entrypoint. diff --git a/tests/docker/docker-compose.nat.yml b/tests/docker/docker-compose.nat.yml index 8d6ed1e..0fc72ac 100644 --- a/tests/docker/docker-compose.nat.yml +++ b/tests/docker/docker-compose.nat.yml @@ -1,55 +1,182 @@ -# M6 NAT simulation: 3 containers (bootstrap, rust-peer, node-peer) -# on separate networks with NAT gateways. +# M6 NAT simulation: a 6-node "public" HyperDHT mesh (mixing real Node.js +# and Rust DHT nodes, each its own container with its own IP) plus two +# `peeroxide` peers (rust-sender, rust-receiver), each behind its own NAT +# gateway on a separate network, proving a real holepunched `cp send`/ +# `cp recv` file transfer across two independent NATs. # # Requires: docker compose, NET_ADMIN capability -# Run: docker compose -f docker-compose.nat.yml up --build +# Run: docker compose -f docker-compose.nat.yml up --build --abort-on-container-exit --exit-code-from rust-receiver +# Each network's IPAM gateway is pinned away from .1 so it doesn't collide +# with the static .1 address we give our own nat-gateway-{a,b} containers; +# containers must explicitly `ip route add default via ` +# (see scripts/run-peer.sh) to route through our simulated NAT instead of +# Docker's own bridge gateway. networks: public: driver: bridge ipam: config: - subnet: 172.30.0.0/24 + gateway: 172.30.0.254 nat-a: driver: bridge ipam: config: - subnet: 10.0.1.0/24 + gateway: 10.0.1.254 nat-b: driver: bridge ipam: config: - subnet: 10.0.2.0/24 + gateway: 10.0.2.254 services: - bootstrap: + # 6 real HyperDHT nodes forming the "public" swarm, each its own + # container with its own static IP on the `public` network, all on the + # same port (49737) - this is what actually lets each node's own + # address-discovery (dht-rpc's NatSampler, a STUN-like mechanism) + # converge correctly: every node's peers are genuinely reached via a + # distinct routable container IP, not via loopback, so no manual + # address override is needed anywhere. Deliberately a mix of the + # Node.js reference implementation (tests/node/dht-node.js) and the + # real `peeroxide node` Rust binary, so this also exercises Rust/Node.js + # DHT wire-protocol interop. + # + # dht-node-1 is the sole genesis node (empty --bootstrap, so it starts + # instantly with no dependency on anyone else being up yet). Every other + # node is given the FULL list of all other static IPs (not just + # dht-node-1) as its --bootstrap contacts, not a pure star off node-1: a + # pure star was tried first and reliably converged with only 1-2 nodes' + # worth of real routing-table knowledge by the time rust-sender/receiver + # queried it, because each secondary only ever learns whichever peers + # dht-node-1's table happened to already contain at the moment it joined + # (dht-rpc has no fast sub-second re-convergence beyond initial + # bootstrap). Since dht-node-2..6 all start in parallel, giving each of + # them every other address means each one picks up replies from + # whichever peers are already up by the time it bootstraps - unreachable + # contacts (not yet started) are simply skipped, not fatal - so the mesh + # cross-populates much faster and more completely than a strict star. + # 6 total nodes is a hard requirement, not just nice-to-have: smaller + # networks (1-3 nodes) reliably fail to produce any closest_nodes on + # announce/lookup - see tests/node/dht-node.js for why. + dht-node-1: build: - context: ../../../ - dockerfile: peeroxide/tests/docker/Dockerfile.node-peer + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer networks: public: - ipv4_address: 172.30.0.10 - command: ["node", "hyperdht-peer.js"] - healthcheck: - test: ["CMD", "node", "-e", "process.exit(0)"] - interval: 2s - timeout: 5s - retries: 5 + ipv4_address: 172.30.0.11 + command: ["node", "dht-node.js", "49737"] + + dht-node-2: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer + networks: + public: + ipv4_address: 172.30.0.12 + depends_on: + - dht-node-1 + command: ["/usr/local/bin/peeroxide", "--no-public", + "--bootstrap", "172.30.0.11:49737", + "--bootstrap", "172.30.0.13:49737", + "--bootstrap", "172.30.0.14:49737", + "--bootstrap", "172.30.0.15:49737", + "--bootstrap", "172.30.0.16:49737", + "node", "--host", "0.0.0.0", "--port", "49737"] + + dht-node-3: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer + networks: + public: + ipv4_address: 172.30.0.13 + depends_on: + - dht-node-1 + command: ["node", "dht-node.js", "49737", + "172.30.0.11:49737", "172.30.0.12:49737", + "172.30.0.14:49737", "172.30.0.15:49737", "172.30.0.16:49737"] + + dht-node-4: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer + networks: + public: + ipv4_address: 172.30.0.14 + depends_on: + - dht-node-1 + command: ["/usr/local/bin/peeroxide", "--no-public", + "--bootstrap", "172.30.0.11:49737", + "--bootstrap", "172.30.0.12:49737", + "--bootstrap", "172.30.0.13:49737", + "--bootstrap", "172.30.0.15:49737", + "--bootstrap", "172.30.0.16:49737", + "node", "--host", "0.0.0.0", "--port", "49737"] + + dht-node-5: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer + networks: + public: + ipv4_address: 172.30.0.15 + depends_on: + - dht-node-1 + command: ["node", "dht-node.js", "49737", + "172.30.0.11:49737", "172.30.0.12:49737", + "172.30.0.13:49737", "172.30.0.14:49737", "172.30.0.16:49737"] + + dht-node-6: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer + networks: + public: + ipv4_address: 172.30.0.16 + depends_on: + - dht-node-1 + command: ["/usr/local/bin/peeroxide", "--no-public", + "--bootstrap", "172.30.0.11:49737", + "--bootstrap", "172.30.0.12:49737", + "--bootstrap", "172.30.0.13:49737", + "--bootstrap", "172.30.0.14:49737", + "--bootstrap", "172.30.0.15:49737", + "node", "--host", "0.0.0.0", "--port", "49737"] nat-gateway-a: image: debian:bookworm-slim cap_add: - NET_ADMIN + # ip_forward must be set via compose `sysctls:`, not a runtime + # `echo 1 > /proc/sys/...` in the container command - Docker Desktop + # mounts /proc/sys read-only inside the container even with + # NET_ADMIN, so an in-container write fails; `sysctls:` applies it + # at container-create time through the Docker API instead. + sysctls: + - net.ipv4.ip_forward=1 networks: public: ipv4_address: 172.30.0.20 nat-a: ipv4_address: 10.0.1.1 + # NOTE: which interface ends up "eth0" vs "eth1" inside the container is + # NOT deterministic across services/runs (observed nat-gateway-a getting + # eth0=nat-a/eth1=public while nat-gateway-b got eth0=public/eth1=nat-b + # in the same run) - hardcoding `-o eth0` for MASQUERADE silently breaks + # the NAT on whichever gateway doesn't get that assignment, since + # forwarded packets then leave un-masqueraded with a private source IP + # the other side can't route a reply back to. Resolve the public-facing + # interface by its known IP/subnet instead. command: > bash -c " apt-get update && apt-get install -y iptables iproute2 && - echo 1 > /proc/sys/net/ipv4/ip_forward && - iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -o eth0 -j MASQUERADE && + PUB_IFACE=$$(ip -4 -o addr show | awk '/172\.30\.0\./ {print $$2}') && + echo \"public iface: $$PUB_IFACE\" && + iptables -t nat -A POSTROUTING -s 10.0.1.0/24 -o \"$$PUB_IFACE\" -j MASQUERADE && iptables -A FORWARD -j ACCEPT && tail -f /dev/null " @@ -58,6 +185,8 @@ services: image: debian:bookworm-slim cap_add: - NET_ADMIN + sysctls: + - net.ipv4.ip_forward=1 networks: public: ipv4_address: 172.30.0.30 @@ -66,16 +195,23 @@ services: command: > bash -c " apt-get update && apt-get install -y iptables iproute2 && - echo 1 > /proc/sys/net/ipv4/ip_forward && - iptables -t nat -A POSTROUTING -s 10.0.2.0/24 -o eth0 -j MASQUERADE && + PUB_IFACE=$$(ip -4 -o addr show | awk '/172\.30\.0\./ {print $$2}') && + echo \"public iface: $$PUB_IFACE\" && + iptables -t nat -A POSTROUTING -s 10.0.2.0/24 -o \"$$PUB_IFACE\" -j MASQUERADE && iptables -A FORWARD -j ACCEPT && tail -f /dev/null " - rust-peer: + # Both peers run the real `peeroxide` binary behind independent simulated + # NATs (nat-a / nat-b) and speak only through the shared `public` DHT + # mesh (dht-node-1..6) - this is what actually exercises the Rust + # holepunch/relay implementation end-to-end, not just bootstrap + # reachability. Only dht-node-1's address is given as the initial + # contact; the DHT itself takes care of discovering the rest. + rust-sender: build: - context: ../../../ - dockerfile: peeroxide/tests/docker/Dockerfile.rust-peer + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer cap_add: - NET_ADMIN networks: @@ -83,13 +219,15 @@ services: ipv4_address: 10.0.1.100 depends_on: - nat-gateway-a - - bootstrap - command: ["bash", "/app/scripts/run-peer.sh", "rust"] + - dht-node-1 + environment: + - GATEWAY_IP=10.0.1.1 + command: ["bash", "/app/scripts/run-peer.sh", "sender"] - node-peer: + rust-receiver: build: - context: ../../../ - dockerfile: peeroxide/tests/docker/Dockerfile.node-peer + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer cap_add: - NET_ADMIN networks: @@ -97,5 +235,8 @@ services: ipv4_address: 10.0.2.100 depends_on: - nat-gateway-b - - bootstrap - command: ["bash", "/app/scripts/run-peer.sh", "node"] + - dht-node-1 + - rust-sender + environment: + - GATEWAY_IP=10.0.2.1 + command: ["bash", "/app/scripts/run-peer.sh", "receiver"] diff --git a/tests/docker/docker-compose.relay-interop-a.yml b/tests/docker/docker-compose.relay-interop-a.yml new file mode 100644 index 0000000..6383a0f --- /dev/null +++ b/tests/docker/docker-compose.relay-interop-a.yml @@ -0,0 +1,51 @@ +# Relay interop overlay, scenario A: Rust peers bridged by the Node.js +# reference blind-relay implementation (relay-node), proving our Rust +# blind-relay *client* code interoperates with a real Node relay server. +# +# Layer on top of the base NAT rig only (not docker-compose.relay.yml, +# which defines relay-rust — this scenario doesn't need it): +# +# docker compose -f docker-compose.nat.yml \ +# -f docker-compose.relay-interop-a.yml \ +# up --build --abort-on-container-exit --exit-code-from rust-receiver +# +# (See run-relay-interop-test.sh.) + +services: + relay-node: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer + networks: + public: + ipv4_address: 172.30.0.41 + depends_on: + - dht-node-1 + # Fixed --key-seed so the relay's public key is deterministic across + # runs (matches relay-rust's approach in docker-compose.relay.yml). + # Pubkey for this seed (verified identical whether derived via Node's + # DHT.keyPair(seed) or Rust's KeyPair::from_seed — both are a plain + # Ed25519 seed-expansion): 011371559f0b19d3f5bd4283e0846101820e9d0aa0f54cc7287ed04724529768 + command: ["node", "blind-relay-server.js", + "--host", "0.0.0.0", "--port", "49737", + "--bootstrap", "172.30.0.11:49737", + "--bootstrap", "172.30.0.12:49737", + "--bootstrap", "172.30.0.13:49737", + "--bootstrap", "172.30.0.14:49737", + "--bootstrap", "172.30.0.15:49737", + "--bootstrap", "172.30.0.16:49737", + "--key-seed", "4e6f6465526561634e6f6465526561634e6f6465526561634e6f646552656163"] + + rust-sender: + depends_on: + - relay-node + environment: + - FORCE_RELAY_PUBKEY=011371559f0b19d3f5bd4283e0846101820e9d0aa0f54cc7287ed04724529768 + - FORCE_RELAY_ADDR=172.30.0.41:49737 + + rust-receiver: + depends_on: + - relay-node + environment: + - FORCE_RELAY_PUBKEY=011371559f0b19d3f5bd4283e0846101820e9d0aa0f54cc7287ed04724529768 + - FORCE_RELAY_ADDR=172.30.0.41:49737 diff --git a/tests/docker/docker-compose.relay-interop-b.yml b/tests/docker/docker-compose.relay-interop-b.yml new file mode 100644 index 0000000..71f31e9 --- /dev/null +++ b/tests/docker/docker-compose.relay-interop-b.yml @@ -0,0 +1,58 @@ +# Relay interop overlay, scenario B: Node.js Hyperswarm peers bridged by +# our Rust blind-relay server (relay-rust), proving our Rust blind-relay +# *server* code interoperates with the real Node.js Hyperswarm client. +# +# Layer on top of the base NAT rig + docker-compose.relay.yml (which +# defines relay-rust and points rust-sender/rust-receiver at it — this +# scenario doesn't use those two services, only relay-rust): +# +# docker compose -f docker-compose.nat.yml -f docker-compose.relay.yml \ +# -f docker-compose.relay-interop-b.yml \ +# up --build --abort-on-container-exit --exit-code-from node-receiver \ +# dht-node-1 dht-node-2 dht-node-3 dht-node-4 dht-node-5 dht-node-6 \ +# nat-gateway-a nat-gateway-b relay-rust node-sender node-receiver +# +# (See run-relay-interop-test.sh — deliberately does not start +# rust-sender/rust-receiver for this run, since this scenario is Node-only.) + +services: + node-sender: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer + cap_add: + - NET_ADMIN + networks: + nat-a: + ipv4_address: 10.0.1.101 + depends_on: + - nat-gateway-a + - dht-node-1 + - relay-rust + environment: + - GATEWAY_IP=10.0.1.1 + - FORCE_RELAY_PUBKEY=df521e9451e3bb401805fcc0e075da39252a1c7da1d38dc0b32e313d37e308fe + - NAT_TEST_TOPIC=6e6f64656e6f64656e6f64656e6f64656e6f64656e6f64656e6f64656e6f6465 + - MESH_SETTLE_SECONDS=15 + command: ["bash", "/app/scripts/run-node-peer.sh", "sender"] + + node-receiver: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.node-peer + cap_add: + - NET_ADMIN + networks: + nat-b: + ipv4_address: 10.0.2.101 + depends_on: + - nat-gateway-b + - dht-node-1 + - relay-rust + - node-sender + environment: + - GATEWAY_IP=10.0.2.1 + - FORCE_RELAY_PUBKEY=df521e9451e3bb401805fcc0e075da39252a1c7da1d38dc0b32e313d37e308fe + - NAT_TEST_TOPIC=6e6f64656e6f64656e6f64656e6f64656e6f64656e6f64656e6f64656e6f6465 + - RECEIVER_SETTLE_SECONDS=40 + command: ["bash", "/app/scripts/run-node-peer.sh", "receiver"] diff --git a/tests/docker/docker-compose.relay.yml b/tests/docker/docker-compose.relay.yml new file mode 100644 index 0000000..042898d --- /dev/null +++ b/tests/docker/docker-compose.relay.yml @@ -0,0 +1,60 @@ +# Relay-proving overlay for the M6 dual-NAT holepunch rig. +# +# Adds a dedicated `relay-rust` blind-relay server to the existing 6-node +# public DHT mesh (see docker-compose.nat.yml) and forces both peers' +# `cp send`/`cp recv` traffic through it via PEEROXIDE_FORCE_RELAY, proving +# the Rust blind-relay server implementation (peeroxide-dht's +# BlindRelayServer + relay_service::run_relay_server) actually bridges two +# real, independently-NATed peers' encrypted traffic end-to-end. +# +# This is a separate compose file (not baked into docker-compose.nat.yml) +# so the already-verified plain-holepunch NAT test is never at risk of +# being disturbed by relay-specific changes. Run together with the base +# file: +# +# docker compose -f docker-compose.nat.yml -f docker-compose.relay.yml \ +# up --build --abort-on-container-exit --exit-code-from rust-receiver +# +# (See run-relay-test.sh, which wires this up with the right env vars.) + +services: + relay-rust: + build: + context: ../../ + dockerfile: tests/docker/Dockerfile.rust-peer + networks: + public: + ipv4_address: 172.30.0.40 + depends_on: + - dht-node-1 + # Fixed --key-seed so the relay's public key (and thus the + # PEEROXIDE_FORCE_RELAY value run-relay-test.sh exports to the peers) + # is deterministic across runs without needing to parse container logs. + command: ["/usr/local/bin/peeroxide", "--no-public", + "--bootstrap", "172.30.0.11:49737", + "--bootstrap", "172.30.0.12:49737", + "--bootstrap", "172.30.0.13:49737", + "--bootstrap", "172.30.0.14:49737", + "--bootstrap", "172.30.0.15:49737", + "--bootstrap", "172.30.0.16:49737", + "-vv", + "relay", "--host", "0.0.0.0", "--port", "49737", + "--key-seed", "5252454c4159525245524c4159525245524c4159525245524c4159525245524c"] + + # Both peers additionally receive FORCE_RELAY_* env vars (consumed by + # run-peer.sh, which sets PEEROXIDE_FORCE_RELAY before invoking cp) so + # the transfer can only succeed by actually routing through relay-rust, + # not via a direct holepunch. + rust-sender: + depends_on: + - relay-rust + environment: + - FORCE_RELAY_PUBKEY=df521e9451e3bb401805fcc0e075da39252a1c7da1d38dc0b32e313d37e308fe + - FORCE_RELAY_ADDR=172.30.0.40:49737 + + rust-receiver: + depends_on: + - relay-rust + environment: + - FORCE_RELAY_PUBKEY=df521e9451e3bb401805fcc0e075da39252a1c7da1d38dc0b32e313d37e308fe + - FORCE_RELAY_ADDR=172.30.0.40:49737 diff --git a/tests/docker/run-nat-test.sh b/tests/docker/run-nat-test.sh index e09f8d1..fbd3681 100755 --- a/tests/docker/run-nat-test.sh +++ b/tests/docker/run-nat-test.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash # M6 Docker NAT simulation test. -# Builds containers, sets up NAT'd networks, and verifies that -# Rust and Node.js peers can establish connections through simulated NAT. +# +# Builds containers and verifies that two real `peeroxide` processes, +# each behind its own independent simulated NAT (nat-a / nat-b, joined +# only via a shared public bootstrap node), can holepunch and complete a +# `cp send` / `cp recv` file transfer end-to-end. This is the only rig in +# the repo that exercises the holepunch/relay code path against two +# genuinely separate NATs; the `--ignored` live tests in +# peeroxide-cli/tests/live_commands.rs run both peers on one host/NAT and +# cannot prove this. # # Prerequisites: Docker with compose plugin, Linux containers # Usage: bash run-nat-test.sh @@ -26,6 +33,10 @@ echo "Building containers..." docker compose -f "$COMPOSE_FILE" build echo "Starting NAT simulation..." -docker compose -f "$COMPOSE_FILE" up --abort-on-container-exit --timeout 60 +# --exit-code-from propagates the receiver's real exit code (0 on a +# verified transfer, non-zero on holepunch/verification failure) instead +# of always returning 0 once `up` itself returns, as the previous version +# of this script did. +docker compose -f "$COMPOSE_FILE" up --abort-on-container-exit --exit-code-from rust-receiver echo "=== M6 Docker NAT Test PASSED ===" diff --git a/tests/docker/run-relay-interop-test.sh b/tests/docker/run-relay-interop-test.sh new file mode 100755 index 0000000..e8ec75a --- /dev/null +++ b/tests/docker/run-relay-interop-test.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Node.js relay-interop proving test: two scenarios, each layering a +# scenario-specific overlay on top of the base M6 NAT rig +# (docker-compose.nat.yml), run sequentially so a failure in one doesn't +# mask the other's result. +# +# Scenario A: real Rust `peeroxide cp` peers bridged by the Node.js +# reference blind-relay server (relay-node) — proves our Rust +# blind-relay *client* interoperates with Node's real server. +# Scenario B: real Node.js Hyperswarm peers (relay-cp-send.js / +# relay-cp-recv.js) bridged by our Rust blind-relay server +# (relay-rust) — proves our Rust blind-relay *server* +# interoperates with Node's real Hyperswarm client. +# +# Does not disturb docker-compose.nat.yml, docker-compose.relay.yml, or +# their existing test scripts. +# +# Prerequisites: Docker with compose plugin, Linux containers +# Usage: bash run-relay-interop-test.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BASE_FILE="$SCRIPT_DIR/docker-compose.nat.yml" +RELAY_FILE="$SCRIPT_DIR/docker-compose.relay.yml" +INTEROP_A="$SCRIPT_DIR/docker-compose.relay-interop-a.yml" +INTEROP_B="$SCRIPT_DIR/docker-compose.relay-interop-b.yml" + +command -v docker >/dev/null || { echo "ERROR: docker not found"; exit 1; } +docker compose version >/dev/null 2>&1 || { echo "ERROR: docker compose not found"; exit 1; } + +cleanup() { + docker compose -f "$BASE_FILE" -f "$RELAY_FILE" -f "$INTEROP_A" -f "$INTEROP_B" \ + down --remove-orphans 2>/dev/null || true +} +trap cleanup EXIT + +echo "=== Scenario A: Rust peers via Node relay (relay-node) ===" +COMPOSE_A=(-f "$BASE_FILE" -f "$INTEROP_A") +docker compose "${COMPOSE_A[@]}" build +docker compose "${COMPOSE_A[@]}" up --abort-on-container-exit --exit-code-from rust-receiver +echo "=== Scenario A PASSED ===" + +docker compose "${COMPOSE_A[@]}" down --remove-orphans + +echo +echo "=== Scenario B: Node peers via Rust relay (relay-rust) ===" +COMPOSE_B=(-f "$BASE_FILE" -f "$RELAY_FILE" -f "$INTEROP_B") +docker compose "${COMPOSE_B[@]}" build +docker compose "${COMPOSE_B[@]}" up --abort-on-container-exit --exit-code-from node-receiver \ + dht-node-1 dht-node-2 dht-node-3 dht-node-4 dht-node-5 dht-node-6 \ + nat-gateway-a nat-gateway-b relay-rust node-sender node-receiver +echo "=== Scenario B PASSED ===" + +echo +echo "=== Node Relay Interop Test PASSED (both directions verified) ===" diff --git a/tests/docker/run-relay-test.sh b/tests/docker/run-relay-test.sh new file mode 100755 index 0000000..8323081 --- /dev/null +++ b/tests/docker/run-relay-test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Docker relay-proving test: extends the M6 dual-NAT rig with a dedicated +# Rust blind-relay server (relay-rust, see docker-compose.relay.yml) and +# forces both peers' `cp send`/`cp recv` traffic through it via +# PEEROXIDE_FORCE_RELAY, verifying peeroxide-dht's BlindRelayServer +# actually bridges two independently-NATed peers end-to-end (not just a +# unit-test harness) and that the relay-through wire protocol +# (relay_through / relay_addresses in the noise handshake, blind-relay +# pair/unpair over Protomux) works against a real dual-NAT network +# topology. +# +# This does not disturb docker-compose.nat.yml or run-nat-test.sh — it +# layers docker-compose.relay.yml on top via `-f ... -f ...` so the plain +# holepunch test keeps passing/failing independently of relay changes. +# +# Prerequisites: Docker with compose plugin, Linux containers +# Usage: bash run-relay-test.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== M6 Docker Relay Test ===" + +command -v docker >/dev/null || { echo "ERROR: docker not found"; exit 1; } +docker compose version >/dev/null 2>&1 || { echo "ERROR: docker compose not found"; exit 1; } + +BASE_FILE="$SCRIPT_DIR/docker-compose.nat.yml" +RELAY_FILE="$SCRIPT_DIR/docker-compose.relay.yml" +COMPOSE_ARGS=(-f "$BASE_FILE" -f "$RELAY_FILE") + +cleanup() { + echo "Cleaning up containers..." + docker compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true +} +trap cleanup EXIT + +echo "Building containers..." +docker compose "${COMPOSE_ARGS[@]}" build + +echo "Starting relay simulation..." +docker compose "${COMPOSE_ARGS[@]}" up --abort-on-container-exit --exit-code-from rust-receiver + +echo "=== M6 Docker Relay Test PASSED ===" diff --git a/tests/docker/scripts/run-node-peer.sh b/tests/docker/scripts/run-node-peer.sh new file mode 100644 index 0000000..ef42ba6 --- /dev/null +++ b/tests/docker/scripts/run-node-peer.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Node.js counterpart to run-peer.sh, for the blind-relay interop scenario +# proving Node clients can be bridged by the Rust relay implementation +# (relay-rust) — see docker-compose.relay.yml / run-relay-interop-test.sh. +# +# Roles: sender (behind nat-a, relay-cp-send.js) / receiver (behind +# nat-b, relay-cp-recv.js). Mirrors run-peer.sh's NAT-gateway routing and +# mesh-settle timing; forces every connection through the relay via +# FORCE_RELAY_PUBKEY (required here, not optional — this scenario only +# exists to test the relay path). +# +# Usage: run-node-peer.sh +set -euo pipefail + +ROLE="${1:?Usage: run-node-peer.sh }" +BOOTSTRAP_HOST="${BOOTSTRAP_HOST:-172.30.0.11}" +BOOTSTRAP_PORT="${BOOTSTRAP_PORT:-49737}" +TOPIC="${NAT_TEST_TOPIC:-6e6174746573746e6174746573746e6174746573746e6174746573746e6174}" +PAYLOAD_PREFIX="node-relay-interop payload" + +ip route replace default via "${GATEWAY_IP:-10.0.1.1}" + +if [ -z "${FORCE_RELAY_PUBKEY:-}" ]; then + echo "FAIL: FORCE_RELAY_PUBKEY is required for run-node-peer.sh (this rig only tests the relay path)" + exit 1 +fi + +case "$ROLE" in + sender) + printf '%s %s' "$PAYLOAD_PREFIX" "$(date -u +%Y%m%dT%H%M%S)" > /tmp/nat-payload.txt + + echo "node-peer/sender: letting DHT mesh settle..." + sleep "${MESH_SETTLE_SECONDS:-6}" + + echo "node-peer/sender: starting relay-cp-send.js (topic=${TOPIC})" + exec node /app/relay-cp-send.js /tmp/nat-payload.txt "$TOPIC" \ + --bootstrap "${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}" \ + --relay-through "${FORCE_RELAY_PUBKEY}" + ;; + receiver) + echo "node-peer/receiver: letting DHT mesh + sender settle..." + sleep "${RECEIVER_SETTLE_SECONDS:-10}" + + echo "node-peer/receiver: starting relay-cp-recv.js (topic=${TOPIC})" + node /app/relay-cp-recv.js "$TOPIC" /tmp/nat-received.txt \ + --bootstrap "${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}" \ + --relay-through "${FORCE_RELAY_PUBKEY}" \ + --timeout-ms 90000 + + echo "node-peer/receiver: verifying received file..." + if [ ! -s /tmp/nat-received.txt ]; then + echo "FAIL: /tmp/nat-received.txt missing or empty" + exit 1 + fi + + received="$(cat /tmp/nat-received.txt)" + echo "node-peer/receiver: received: ${received}" + case "$received" in + "${PAYLOAD_PREFIX}"*) + echo "=== Node Relay Interop Test PASSED (Node clients bridged by Rust relay) ===" + ;; + *) + echo "FAIL: unexpected payload content: ${received}" + exit 1 + ;; + esac + ;; + *) + echo "Unknown role: $ROLE (expected sender|receiver)" + exit 1 + ;; +esac diff --git a/tests/docker/scripts/run-peer.sh b/tests/docker/scripts/run-peer.sh index 1ce2c60..41af0b3 100755 --- a/tests/docker/scripts/run-peer.sh +++ b/tests/docker/scripts/run-peer.sh @@ -1,25 +1,104 @@ #!/usr/bin/env bash +# Runs one role of the M6 dual-NAT holepunch test. +# +# Roles: +# sender - behind nat-a. Runs `peeroxide cp send`, announcing a fixed +# topic on the shared bootstrap and waiting for the receiver +# to connect and pull the file (server side of the swarm). +# receiver - behind nat-b. Runs `peeroxide cp recv`, looking up the same +# topic and connecting to the sender (client side of the +# swarm), then checks that a real file arrived. +# +# Both roles force the real network path (PEEROXIDE_LOCAL_CONNECTION=false) +# and disable the public bootstrap network (--no-public) so this test can +# only succeed via a genuine holepunch/relay through the two independent +# simulated NATs, never via a same-host/LAN shortcut or the real internet. +# +# Usage: run-peer.sh set -euo pipefail -ROLE="${1:?Usage: run-peer.sh }" -BOOTSTRAP_HOST="${BOOTSTRAP_HOST:-172.30.0.10}" +ROLE="${1:?Usage: run-peer.sh }" +BOOTSTRAP_HOST="${BOOTSTRAP_HOST:-172.30.0.11}" BOOTSTRAP_PORT="${BOOTSTRAP_PORT:-49737}" +# Fixed 64-char hex topic shared by both peers for this test run. +TOPIC="${NAT_TEST_TOPIC:-6e6174746573746e6174746573746e6174746573746e6174746573746e6174}" +PAYLOAD_PREFIX="nat-holepunch-rig payload" -ip route add default via "${GATEWAY_IP:-10.0.1.1}" 2>/dev/null || true +# Docker already installs its own default route via the network's bridge +# gateway (e.g. 10.0.1.254) before this script runs. `ip route add default` +# would fail silently against that ("File exists") and leave traffic going +# out via Docker's bridge gateway instead of our simulated NAT gateway - +# which has no forwarding/masquerade rules and silently drops everything, +# so DHT bootstrap requests would appear to "work" (the initial request +# happens to succeed against the bootstrap's single well-known port from +# some other path) while all subsequent replies are lost. Use `replace` so +# our NAT gateway becomes the only default route. +ip route replace default via "${GATEWAY_IP:-10.0.1.1}" + +export PEEROXIDE_LOCAL_CONNECTION=false + +# Optional: force cp traffic through a blind-relay server (see +# docker-compose.relay.yml / run-relay-test.sh). Unset by default, so the +# plain NAT-holepunch test (docker-compose.nat.yml alone) is unaffected. +if [ -n "${FORCE_RELAY_PUBKEY:-}" ] && [ -n "${FORCE_RELAY_ADDR:-}" ]; then + export PEEROXIDE_FORCE_RELAY="${FORCE_RELAY_PUBKEY}@${FORCE_RELAY_ADDR}" + echo "peer: forcing relay through ${FORCE_RELAY_ADDR} (pubkey ${FORCE_RELAY_PUBKEY:0:16}...)" +fi case "$ROLE" in - rust) - echo "Starting Rust peer (bootstrap=${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT})" + sender) + echo "peer/sender: waiting for bootstrap ${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}..." + printf '%s %s' "$PAYLOAD_PREFIX" "$(date -u +%Y%m%dT%H%M%S)" > /tmp/nat-payload.txt + + # `depends_on` only guarantees the dht-node-* containers have *started*, + # not that the 6-node public DHT mesh has finished cross-populating its + # routing tables via their own bootstrap queries. A brief settle delay + # here avoids racing the sender's announce against a still-converging + # mesh (observed as artificially low closest_nodes counts). + echo "peer/sender: letting DHT mesh settle..." + sleep "${MESH_SETTLE_SECONDS:-6}" + + echo "peer/sender: starting cp send (topic=${TOPIC})" exec /usr/local/bin/peeroxide \ + --no-public \ --bootstrap "${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}" \ - --mode server + -vv \ + cp send /tmp/nat-payload.txt "$TOPIC" ;; - node) - echo "Starting Node.js peer (bootstrap=${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT})" - exec node hyperdht-peer.js + receiver) + echo "peer/receiver: waiting for bootstrap ${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}..." + # Give the DHT mesh and the sender a moment to settle/announce before + # we start looking it up (sender itself delays MESH_SETTLE_SECONDS + # before announcing, so wait a bit longer than that here). + sleep "${RECEIVER_SETTLE_SECONDS:-10}" + + echo "peer/receiver: starting cp recv (topic=${TOPIC})" + /usr/local/bin/peeroxide \ + --no-public \ + --bootstrap "${BOOTSTRAP_HOST}:${BOOTSTRAP_PORT}" \ + -vv \ + cp recv "$TOPIC" /tmp/nat-received.txt --yes --force --timeout 90 + + echo "peer/receiver: verifying received file..." + if [ ! -s /tmp/nat-received.txt ]; then + echo "FAIL: /tmp/nat-received.txt missing or empty" + exit 1 + fi + + received="$(cat /tmp/nat-received.txt)" + echo "peer/receiver: received: ${received}" + case "$received" in + "${PAYLOAD_PREFIX}"*) + echo "=== M6 Docker NAT Test PASSED (holepunch + transfer verified) ===" + ;; + *) + echo "FAIL: unexpected payload content: ${received}" + exit 1 + ;; + esac ;; *) - echo "Unknown role: $ROLE" + echo "Unknown role: $ROLE (expected sender|receiver)" exit 1 ;; esac diff --git a/tests/node/blind-relay-server.js b/tests/node/blind-relay-server.js index c455084..a185440 100644 --- a/tests/node/blind-relay-server.js +++ b/tests/node/blind-relay-server.js @@ -1,16 +1,62 @@ 'use strict' +// Blind-relay server — Node.js reference-implementation relay for the +// Docker interop rig (tests/docker/docker-compose.relay.yml, service +// `relay-node`), and also usable standalone against the public network. +// +// Usage: +// node blind-relay-server.js [--port N] [--host H] +// [--bootstrap host:port ...] [--key-seed <64-char-hex>] +// +// All arguments are optional and backward compatible with the original +// no-arg invocation (public bootstrap network, random ephemeral +// listen port, random keypair). Pass --bootstrap (repeatable) to join a +// private/isolated mesh instead (e.g. the Docker rig's `public` network), +// and --key-seed for a deterministic public key across runs (so compose +// files can hardcode the relay's pubkey without parsing container logs). + const DHT = require('hyperdht') const relay = require('blind-relay') const b4a = require('b4a') +function parseArgs (argv) { + const opts = { port: 0, host: undefined, bootstrap: [], keySeed: undefined } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--port') { + opts.port = Number(argv[++i]) + } else if (arg === '--host') { + opts.host = argv[++i] + } else if (arg === '--bootstrap') { + const [host, portStr] = argv[++i].split(':') + opts.bootstrap.push({ host, port: Number(portStr) }) + } else if (arg === '--key-seed') { + opts.keySeed = argv[++i] + } + } + return opts +} + async function main () { - const dht = new DHT() + const opts = parseArgs(process.argv.slice(2)) + const usingPrivateMesh = opts.bootstrap.length > 0 + + const dhtOpts = { port: opts.port } + if (opts.host) dhtOpts.host = opts.host + if (usingPrivateMesh) { + // A private/isolated mesh needs an explicit, non-public DHT node — + // mirrors dht-node.js's settings for the same rig. + dhtOpts.ephemeral = false + dhtOpts.firewalled = false + dhtOpts.bootstrap = opts.bootstrap + } + + const dht = new DHT(dhtOpts) await dht.fullyBootstrapped() const relayServer = new relay.Server({ - createStream (opts) { - return dht.rawStreams.add(opts) + createStream (streamOpts) { + return dht.rawStreams.add(streamOpts) } }) @@ -18,7 +64,9 @@ async function main () { relayServer.accept(socket, { id: socket.remotePublicKey }) }) - const keyPair = DHT.keyPair() + const keyPair = opts.keySeed + ? DHT.keyPair(b4a.from(opts.keySeed, 'hex')) + : DHT.keyPair() await server.listen(keyPair) const addr = dht.address() @@ -30,20 +78,22 @@ async function main () { port: addr.port }) + '\n') - process.stdin.resume() - process.stdin.on('end', async () => { - await relayServer.close() - await server.close() - await dht.destroy() + // Shut down on SIGTERM/SIGINT only (matches dht-node.js's convention for + // this rig's long-running containers). The original stdin-'end'-based + // shutdown hook is removed: under Docker (no `stdin_open`/`-i`), stdin + // is already closed when the process starts, so `process.stdin.resume()` + // would fire an immediate 'end' event and exit the process right after + // printing the ready line — before it ever accepts a connection. + const shutdown = async () => { + try { + await relayServer.close() + await server.close() + await dht.destroy() + } catch (_) {} process.exit(0) - }) - - process.on('SIGTERM', async () => { - await relayServer.close() - await server.close() - await dht.destroy() - process.exit(0) - }) + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) } main().catch((err) => { diff --git a/tests/node/dht-node.js b/tests/node/dht-node.js new file mode 100644 index 0000000..7771b4d --- /dev/null +++ b/tests/node/dht-node.js @@ -0,0 +1,80 @@ +/** + * Standalone single HyperDHT node for the Docker dual-NAT rig + * (tests/docker/). Each instance of this script is one real DHT node in + * one container, given its own static IP on the `public` Docker network + * (see docker-compose.nat.yml) - unlike an earlier version of this rig + * which ran several DHT node identities in a single Node.js process (all + * sharing one container/IP), this properly gives each node a genuinely + * distinct, externally-routable address. + * + * That matters because HyperDHT nodes learn their own advertised address + * from how *other* peers perceive the source of their requests (a + * STUN-like mechanism - see dht-rpc's NatSampler). Running multiple node + * identities in one process meant they bootstrapped against each other + * over loopback, so they'd converge on "127.0.0.1" as their own address - + * accurate for loopback peers, but useless to containers reaching in from + * a different Docker network. With one node per container, each node's + * peers genuinely see it arrive from its real container IP, so the + * built-in address discovery converges correctly with no manual + * workaround needed. + * + * This rig's small `public` DHT network is made up of several containers + * each running one instance of this script (or, for a couple of nodes, + * the real `peeroxide node` Rust binary instead - see + * docker-compose.nat.yml) chained together via --bootstrap, so the + * network is a genuine mix of Node.js and Rust HyperDHT implementations + * talking the same wire protocol. + * + * A handful of nodes is a hard requirement, not just nice-to-have: + * `HyperDhtHandle::announce`'s `closest_nodes` (peeroxide-dht/src/hyperdht.rs) + * comes directly from the iterative closest-node query's replies, and + * empirically that query returns zero replies against networks of only + * 1-3 total nodes - `announce`/`lookup` then never resolve any peer at + * all. This rig runs 6 total DHT nodes across the mesh to stay well + * clear of that floor. + * + * It never exits on its own; the container is torn down by + * `docker compose down` once the peer containers finish. + * + * Usage: node dht-node.js [bootstrap-host:port ...] + */ + +'use strict' + +const DHT = require('hyperdht') + +async function main () { + const port = Number(process.argv[2] || process.env.DHT_PORT || 49737) + const bootstrap = process.argv.slice(3).map((hp) => { + const [host, portStr] = hp.split(':') + return { host, port: Number(portStr) } + }) + + const node = new DHT({ + ephemeral: false, + firewalled: false, + host: '0.0.0.0', + port, + bootstrap + }) + await node.fullyBootstrapped() + + process.stdout.write(JSON.stringify({ + ready: true, + port: node.address().port, + bootstrap + }) + '\n') + + const shutdown = async () => { + try { await node.destroy() } catch (_) {} + process.exit(0) + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) +} + +main().catch((err) => { + process.stderr.write((err && err.stack) || String(err)) + process.stderr.write('\n') + process.exit(1) +}) diff --git a/tests/node/relay-cp-recv.js b/tests/node/relay-cp-recv.js new file mode 100644 index 0000000..3ad20d9 --- /dev/null +++ b/tests/node/relay-cp-recv.js @@ -0,0 +1,112 @@ +'use strict' + +// relay-cp-recv.js — minimal Node.js Hyperswarm "client" counterpart to +// `peeroxide cp recv`, for blind-relay interop testing only (not a +// general-purpose tool). Joins a topic, connects to the announced peer, +// and writes everything it reads from the socket to a destination file. +// +// Usage: +// node relay-cp-recv.js +// [--bootstrap host:port ...] [--relay-through ] +// [--timeout-ms N] +// +// See relay-cp-send.js for --relay-through semantics. + +const fs = require('fs') +const Hyperswarm = require('hyperswarm') +const DHT = require('hyperdht') +const b4a = require('b4a') + +function parseArgs (argv) { + const opts = { bootstrap: [], relayThrough: undefined, timeoutMs: 60000, positional: [] } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--bootstrap') { + const [host, portStr] = argv[++i].split(':') + opts.bootstrap.push({ host, port: Number(portStr) }) + } else if (arg === '--relay-through') { + opts.relayThrough = argv[++i] + } else if (arg === '--timeout-ms') { + opts.timeoutMs = Number(argv[++i]) + } else { + opts.positional.push(arg) + } + } + return opts +} + +async function main () { + const opts = parseArgs(process.argv.slice(2)) + const [topicHex, destPath] = opts.positional + if (!topicHex || !destPath) { + console.error('usage: node relay-cp-recv.js [--bootstrap host:port ...] [--relay-through ] [--timeout-ms N]') + process.exit(1) + } + + const topic = b4a.from(topicHex, 'hex') + + const dhtOpts = {} + if (opts.bootstrap.length > 0) { + dhtOpts.ephemeral = true + dhtOpts.bootstrap = opts.bootstrap + } + const dht = new DHT(dhtOpts) + await dht.fullyBootstrapped() + + const swarmOpts = { dht } + if (opts.relayThrough) { + const relayPk = b4a.from(opts.relayThrough, 'hex') + swarmOpts.relayThrough = () => relayPk + } + + const swarm = new Hyperswarm(swarmOpts) + + const received = await new Promise((resolve, reject) => { + let settled = false + const finish = (fn, arg) => { + if (settled) return + settled = true + clearTimeout(timer) + clearInterval(retryTimer) + fn(arg) + } + + const timer = setTimeout( + () => finish(reject, new Error(`timed out after ${opts.timeoutMs}ms waiting for data`)), + opts.timeoutMs + ) + + swarm.on('connection', (socket, info) => { + console.log(`connected: ${b4a.toString(info.publicKey, 'hex')} (initiator=${info.client})`) + const chunks = [] + socket.on('data', (chunk) => chunks.push(chunk)) + socket.on('end', () => finish(resolve, b4a.concat(chunks))) + socket.on('error', (err) => finish(reject, err)) + }) + + // Hyperswarm's own PeerDiscovery only auto-retries a failed/empty + // lookup after a ~10-minute refresh interval (see + // hyperswarm/lib/peer-discovery.js) — far too slow for this test. + // Destroying and rejoining the topic on a short interval forces a + // fresh `dht.lookup()` each time, mirroring `peeroxide cp recv`'s own + // "retrying lookup..." loop, until a connection lands or we time out. + let discovery = swarm.join(topic, { server: false, client: true }) + const retryTimer = setInterval(() => { + if (settled) return + console.log('relay-cp-recv: retrying lookup...') + discovery.destroy().catch(() => {}) + discovery = swarm.join(topic, { server: false, client: true }) + }, 5000) + }) + + fs.writeFileSync(destPath, received) + console.log(`relay-cp-recv: wrote ${received.length} bytes to ${destPath}`) + + await swarm.destroy() + process.exit(0) +} + +main().catch((err) => { + console.error(err.stack || err) + process.exit(1) +}) diff --git a/tests/node/relay-cp-send.js b/tests/node/relay-cp-send.js new file mode 100644 index 0000000..b282e80 --- /dev/null +++ b/tests/node/relay-cp-send.js @@ -0,0 +1,89 @@ +'use strict' + +// relay-cp-send.js — minimal Node.js Hyperswarm "server" counterpart to +// `peeroxide cp send`, for blind-relay interop testing only (not a +// general-purpose tool). Announces a topic, and on the first incoming +// connection writes a file's bytes to the socket. +// +// Usage: +// node relay-cp-send.js +// [--bootstrap host:port ...] [--relay-through ] +// +// --relay-through forces every connection through the given blind-relay +// pubkey via Hyperswarm's `relayThrough` option (a function that always +// returns the pubkey, ignoring the `force` flag — the Node-side +// equivalent of peeroxide-cli's unconditional `PEEROXIDE_FORCE_RELAY`), +// so this only succeeds if the relay genuinely bridges the connection. + +const fs = require('fs') +const Hyperswarm = require('hyperswarm') +const DHT = require('hyperdht') +const b4a = require('b4a') + +function parseArgs (argv) { + const opts = { bootstrap: [], relayThrough: undefined, positional: [] } + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--bootstrap') { + const [host, portStr] = argv[++i].split(':') + opts.bootstrap.push({ host, port: Number(portStr) }) + } else if (arg === '--relay-through') { + opts.relayThrough = argv[++i] + } else { + opts.positional.push(arg) + } + } + return opts +} + +async function main () { + const opts = parseArgs(process.argv.slice(2)) + const [filePath, topicHex] = opts.positional + if (!filePath || !topicHex) { + console.error('usage: node relay-cp-send.js [--bootstrap host:port ...] [--relay-through ]') + process.exit(1) + } + + const payload = fs.readFileSync(filePath) + const topic = b4a.from(topicHex, 'hex') + + const dhtOpts = {} + if (opts.bootstrap.length > 0) { + dhtOpts.ephemeral = true + dhtOpts.bootstrap = opts.bootstrap + } + const dht = new DHT(dhtOpts) + await dht.fullyBootstrapped() + + const swarmOpts = { dht } + if (opts.relayThrough) { + const relayPk = b4a.from(opts.relayThrough, 'hex') + // Always return the relay pubkey regardless of `force`/NAT quality — + // the interop test wants to *prove* the relay path, not rely on + // whatever NAT the Docker rig happens to simulate. + swarmOpts.relayThrough = () => relayPk + } + + const swarm = new Hyperswarm(swarmOpts) + + swarm.on('connection', (socket, info) => { + console.log(`connected: ${b4a.toString(info.publicKey, 'hex')} (initiator=${info.client})`) + socket.end(payload) + socket.on('error', (err) => console.error('conn error:', err.message)) + }) + + const discovery = swarm.join(topic, { server: true, client: false }) + await discovery.flushed() + console.log(`relay-cp-send: topic ${topicHex} announced (${payload.length} bytes)`) + console.log(`relay-cp-send: dht table size=${dht.table ? dht.table.toArray().length : 'n/a'}`) + + process.on('SIGTERM', async () => { + await swarm.destroy() + process.exit(0) + }) +} + +main().catch((err) => { + console.error(err.stack || err) + process.exit(1) +})