diff --git a/docs/AGENTS.md b/docs/AGENTS.md index c2f7d91..04a252b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -15,6 +15,7 @@ docs/ ├── lookup/ — lookup command documentation ├── announce/ — announce command documentation (echo protocol defined here) ├── ping/ — ping command documentation (cross-refs echo protocol) + ├── node/ — node command documentation ├── cp/ — cp command documentation ├── dd/ — dd (Dead Drop) command documentation (v1 + v2 protocols) ├── chat/ — chat subsystem (user guide, TUI, wire format, protocol, reference) @@ -39,6 +40,7 @@ Output goes to `docs/book/` (gitignored). ## Conventions - All Mermaid diagrams use ` ```mermaid ``` ` fences — rendered by `mdbook-mermaid`. +- Command chapters live under `src//` with an `overview.md` entry in `SUMMARY.md`; follow the same pattern for new commands such as `relay/`. - Cross-references use relative `[text](../path/to/file.md)` links (mdBook requirement). - Human output examples go on **stderr**; structured JSON output goes on **stdout**. - The Echo Protocol is defined exactly once in `src/announce/echo-protocol.md`. All other chapters that reference it must link there rather than re-documenting it. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 9a3825c..c22fb45 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -15,6 +15,9 @@ # Commands +- [node](./node/overview.md) +- [relay](./relay/overview.md) + - [Architecture](./relay/architecture.md) - [lookup](./lookup/overview.md) - [Output Formats](./lookup/output-formats.md) - [announce](./announce/overview.md) diff --git a/docs/src/appendices/tracing.md b/docs/src/appendices/tracing.md index 157a3ba..55038c6 100644 --- a/docs/src/appendices/tracing.md +++ b/docs/src/appendices/tracing.md @@ -91,12 +91,21 @@ The 8 natural subsystems and the targets that feed them: | 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)_ | +| relay | `peeroxide::cmd::relay`, `peeroxide::cmd::node`, `peeroxide_dht::relay_service`, `peeroxide_dht::blind_relay` | _(none currently; module-path logs only)_ | | 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)_ | +Relay is intentionally module-path only today. `peeroxide::cmd::relay` +and `peeroxide::cmd::node` emit the operator-facing startup / shutdown / +periodic relay stats at `info`; `peeroxide_dht::relay_service` emits +self-announce success/failure, handshake/finalization, capacity +rejections, and bridge failures at `debug`/`warn`; and +`peeroxide_dht::blind_relay` emits pair request/response activity, +pairing matches, idle-session / expired-pairing sweeps, and malformed +frame drops at `debug`/`trace`. + 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). diff --git a/docs/src/introduction.md b/docs/src/introduction.md index d5fa1fa..6795dc3 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -18,16 +18,17 @@ The binary is named `peeroxide`. ## Core Tools -The toolkit consists of eight primary commands: +The toolkit consists of nine primary commands: - **[init](./init/overview.md)**: Generate configuration files and install man pages. +- **[node](./node/overview.md)**: Run a long-running DHT bootstrap / coordination node, optionally with a courtesy blind-relay. +- **[relay](./relay/overview.md)**: Run a standalone blind-relay server that bridges two NATed/firewalled peers who can't connect directly. - **[lookup](./lookup/overview.md)**: Query the DHT to find peers announcing a specific topic. - **[announce](./announce/overview.md)**: Announce your presence on a topic so others can discover you. - **[ping](./ping/overview.md)**: Diagnose reachability through bootstrap checks, NAT classification, or targeted peer pings. - **[cp](./cp/overview.md)**: Transfer files directly between peers over an encrypted swarm connection. - **[dd (Dead Drop)](./dd/overview.md)**: Perform anonymous store-and-forward messaging via the DHT. The `dd` command supports both v1 and v2 protocols, with v2 auto-selected for new put operations. - **[chat](./chat/overview.md)**: Join topic-based interactive chat rooms. -- **node**: Run a long-running DHT bootstrap / coordination node. ## Quick Start diff --git a/docs/src/node/overview.md b/docs/src/node/overview.md new file mode 100644 index 0000000..7eca58e --- /dev/null +++ b/docs/src/node/overview.md @@ -0,0 +1,128 @@ +# Node Overview + +The `node` command runs a long-lived DHT routing node. It joins the HyperDHT-compatible network, maintains a routing table, answers DHT queries, and stores the short-lived records that power `lookup`, `announce`, and higher-level commands built on top of them. + +For background on how routing works, see [DHT and Routing](../concepts/dht-and-routing.md) and [DHT Primitives](../concepts/dht-primitives.md). + +## Why run a node? + +Running `peeroxide node` is useful when you want to: + +- contribute stable routing capacity to the network, +- self-host one or more bootstrap addresses for your own deployment, +- build a private or test mesh with explicitly chosen bootstrap peers, +- keep DHT infrastructure running alongside your own `announce`, `cp`, `dd`, or `chat` usage. + +A bootstrap node is not a separate protocol role. It is just a node with a stable address that other peers use as an entry point. + +## Usage + +```bash +peeroxide node [FLAGS] +``` + +## Common examples + +Run a node with the default network settings: + +```bash +peeroxide node +``` + +Run a public-facing node on the standard HyperDHT port: + +```bash +peeroxide node --public --host 0.0.0.0 --port 49737 +``` + +Run a node in a private mesh using only explicit bootstrap peers: + +```bash +peeroxide node \ + --no-public \ + --bootstrap 10.0.0.11:49737 \ + --bootstrap 10.0.0.12:49737 \ + --host 0.0.0.0 \ + --port 49737 +``` + +Run a routing node that also offers courtesy blind-relay service: + +```bash +peeroxide node --public --relay -v +``` + +## Bootstrap and network selection + +`node` uses the same bootstrap-resolution rules as the other DHT commands. The inherited global flags are documented in [init](../init/overview.md#global-cli-flags). + +In practice: + +- `--bootstrap ` supplies explicit bootstrap addresses. Repeat it for multiple peers. +- `--public` adds the default public HyperDHT bootstrap nodes. +- `--no-public` removes the default public bootstrap nodes. +- If you provide neither custom bootstraps nor `--no-public`, peeroxide auto-fills the public bootstrap set. + +That means `peeroxide node --no-public` with no `--bootstrap` starts in isolated mode: it listens for inbound peers, but it has no built-in way to discover the wider network. + +## Flags + +### Node-specific flags + +| Flag | Description | +|---|---| +| `--port ` | UDP bind port. Default: `49737`. | +| `--host ` | UDP bind address. Default: `0.0.0.0`. | +| `--stats-interval ` | Interval for periodic stats logging. Default: `60`. Must be greater than `0`. | +| `--max-records ` | Maximum number of announcement records the node stores. | +| `--max-lru-size ` | Maximum number of mutable/immutable cache entries. | +| `--max-per-key ` | Maximum peer announcements stored for a single topic. | +| `--max-record-age ` | TTL for announcement records. | +| `--max-lru-age ` | TTL for mutable/immutable cache entries. | + +These storage and TTL knobs affect how much DHT state your node retains while serving the network. + +### Relay-related flags + +| Flag | Description | +|---|---| +| `--relay` | Also run a courtesy blind-relay server inside this node process. | +| `--relay-key-seed <64-hex>` | Deterministic 32-byte seed for the relay identity keypair. Without it, a fresh relay keypair is generated on each run. | +| `--relay-max-sessions ` | Maximum concurrently accepted relay sessions. Default: `10000`. | +| `--relay-max-pairings-per-session ` | Maximum pending + active pairings per relay session. Default: `256`. | +| `--relay-pairing-timeout ` | Drop an unmatched relay pairing after this many seconds. Default: `300`. | +| `--relay-idle-session-timeout ` | Close a relay session with no pair/unpair activity for this many seconds. Default: `600`. | + +### Inherited global flags + +`node` also accepts the shared top-level flags: + +- `--config ` +- `--no-default-config` +- `--public` +- `--no-public` +- `--bootstrap ` +- `-v` / `-vv` + +## Relay mode + +When you add `--relay`, the process still behaves as a normal DHT node. It simply adds a blind-relay service alongside its routing duties. + +This is useful when you want one always-on process to do both jobs: participate in routing and offer a courtesy relay endpoint for peers that cannot connect directly. For a dedicated relay-only process, see the [relay command](../relay/overview.md). + +If relay mode is enabled, startup prints the relay public key to stdout before the bound `host:port` line. + +## Operational notes + +- **Long-running process**: `node` is meant to stay up. It exits cleanly on Ctrl-C or SIGTERM. +- **Output streams**: the bound address is printed to stdout. Tracing and operational logs go to stderr. +- **Verbosity**: `-v` enables info-level lifecycle logs such as bootstrap completion and relay counters. `-vv` additionally enables periodic routing-table and cache statistics. +- **Private meshes**: for test or lab networks, combine `--no-public` with explicit `--bootstrap` peers. The repo's Docker NAT test uses a multi-node private mesh rather than a single bootstrap address so routing information converges faster. +- **Multiple nodes**: if you are building your own bootstrap set, run more than one stable node when possible. A small cluster is more robust than a single entry point. + +## See Also + +- [DHT and Routing](../concepts/dht-and-routing.md) +- [DHT Primitives](../concepts/dht-primitives.md) +- [Ping Overview](../ping/overview.md) +- [Announce Overview](../announce/overview.md) diff --git a/docs/src/relay/architecture.md b/docs/src/relay/architecture.md new file mode 100644 index 0000000..263e1b6 --- /dev/null +++ b/docs/src/relay/architecture.md @@ -0,0 +1,145 @@ +# Relay Architecture + +This chapter describes how peeroxide's blind-relay implementation works internally. + +At a high level, the relay has two separate jobs: + +1. accept an authenticated control connection from each peer, +2. once both sides present the same relay token, create two raw UDX streams and bridge them. + +The relay never terminates the application protocol spoken by the two real peers. + +## Control plane vs. data plane + +The control plane is a normal HyperDHT connection to the relay node. Each side completes the relay's own Noise/SecretStream session, then opens a Protomux channel named `"blind-relay"`. + +The data plane is different. After the relay matches a token, it creates two UDX streams and wires them together with `UdxStream::relay_to` in both directions. Those forwarded packets belong to the real peer-to-peer session, not to the relay's control channel. + +```mermaid +sequenceDiagram + participant A as Peer A + participant R as Relay + participant B as Peer B + + A->>R: HyperDHT + Noise control connection + B->>R: HyperDHT + Noise control connection + A->>R: Protomux "blind-relay" pair(token, stream_id) + B->>R: Protomux "blind-relay" pair(token, stream_id) + R-->>A: pair matched, relay stream id + R-->>B: pair matched, relay stream id + R->>R: create two UDX streams + R->>R: relay_to in both directions + A->>R: encrypted UDX packets + R->>B: forwarded encrypted packets +``` + +## The pairing model + +The protocol engine lives in `peeroxide-dht/src/blind_relay.rs`. + +- `BlindRelayServer` owns the shared pairing tables and limits for the whole relay instance. +- `BlindRelaySession` represents one accepted control connection. +- `BlindRelayClient` is the client-side helper used by a peer connecting through a relay. + +Each `pair` message carries: + +- a 32-byte token, +- an `is_initiator` bit, +- the sender's local UDX stream id. + +The relay keeps up to two pending links per token: one initiator slot and one responder slot. When both are present, the token is considered matched and the relay emits a `MatchedPairing` record to the transport layer. + +That transport layer lives in `peeroxide-dht/src/relay_service.rs`, which creates the actual streams, bridges them, and notifies both sessions of the relay-assigned stream ids to use. + +`unpair` removes a pending token or tears down an already-active bridged pairing. + +## Why the relay is “blind” + +The important boundary is this: + +- the relay's **control connections** are encrypted so each side can authenticate to the relay and exchange pair/unpair messages safely, +- the **relayed application payload** is already end-to-end encrypted between the two real peers. + +So the relay is not providing payload confidentiality. It is forwarding ciphertext that was produced for the real peer-to-peer session. + +In peeroxide's transport wiring, that forwarding happens at the UDX packet level through `relay_to`. The relay never unwraps the application's SecretStream frames and never learns the plaintext. + +## Server-side swarm behavior + +On the client side, blind relay is integrated into `peeroxide::Swarm`. + +When a swarm is configured with `SwarmConfig::relay_through`, the server side changes its handshake reply: + +- it mints a fresh relay token, +- it includes `relay_through` metadata naming the relay public key, +- it optionally includes `relay_addresses` when `SwarmConfig::relay_address` is known, +- it skips the normal holepunch path for that connection. + +Later, once the server receives the inbound handshake, it calls `create_server_relay_connection` to: + +1. connect to the relay over HyperDHT, +2. open a `BlindRelayClient` on the control channel, +3. pair with the token, +4. open the relayed UDX data stream, +5. wrap that data stream with the already-negotiated peer-to-peer SecretStream session. + +That last step is why the relay does not become the endpoint of the application encryption. + +## Session limits and timeouts + +Peeroxide adds configurable operational limits around the Node-compatible relay protocol: + +| Setting | Default | Effect | +|---|---:|---| +| `max_sessions` | `10000` | Maximum concurrent accepted relay sessions. | +| `max_pairings_per_session` | `256` | Caps how many pending + active tokens one session can hold. | +| `pairing_timeout` | `300s` | Drops a token if the matching side never arrives. | +| `idle_session_timeout` | `600s` | Closes a session with no pair/unpair activity. | + +These live in `BlindRelayServerConfig`. + +Node's reference `blind-relay` package does **not** have equivalents for those limits or timeouts. Peeroxide adds them as hardening measures with generous defaults so a default deployment still behaves much like the unthrottled reference implementation. + +## Teardown behavior + +Once a pairing is active, peeroxide tracks the bridged stream pair by token. + +Two teardown paths matter: + +1. **Unpair on an active token**: the relay stops forwarding and drops the bridged streams. +2. **Session close**: if either control session closes, every active pairing owned by that session is torn down. + +This teardown behavior has direct precedent in Node's reference implementation: Node's `blind-relay` destroys active streams in its `_onunpair` and `_onclose` paths. + +The idle-session sweep is different. That is a peeroxide-only addition with no Node precedent. + +## Maintenance loops + +`relay_service::run_relay_server` runs two background maintenance loops in addition to handshake acceptance: + +- a periodic self-announce refresh loop for the relay's own identity, +- a periodic sweep loop that expires stale pending pairings and closes idle sessions. + +The sweep loop is what enforces `pairing_timeout` and `idle_session_timeout` in practice. + +## Self-announcement and discovery + +One subtle but important implementation detail is that the relay must do more than call `register_server` locally. + +`register_server` only teaches the local DHT router to handle inbound `PEER_HANDSHAKE` requests for `hash(public_key)` if they somehow arrive. It does **not** tell the rest of the network that this relay exists. + +Peeroxide therefore self-announces the relay's own identity target, `hash(public_key)`, at startup and on a roughly 10-minute refresh loop. This mirrors Node's `Server.listen()` behavior. + +That matters for interoperability. A Node.js `hyperdht` client connecting by relay public key does discovery through a lookup-style `findPeer` query for an announced record on `hash(public_key)`. Without the self-announce, the relay could be perfectly healthy locally but still undiscoverable remotely, producing `PEER_NOT_FOUND`. + +## Why `relay_service` is separate from `Swarm` + +A relay is not a topic participant. It does not join discovery topics, manage peer sets, or maintain swarm retry state. + +For that reason, peeroxide keeps the relay transport wiring in `peeroxide-dht::relay_service` as a small accept-and-bridge service built directly on HyperDHT and UDX, while `peeroxide::Swarm` consumes the relay from the client side. + +## See Also + +- [Relay Overview](overview.md) +- [DHT and Routing](../concepts/dht-and-routing.md) +- [Security Model](../appendices/security-model.md) diff --git a/docs/src/relay/overview.md b/docs/src/relay/overview.md new file mode 100644 index 0000000..6087eaa --- /dev/null +++ b/docs/src/relay/overview.md @@ -0,0 +1,148 @@ +# Relay Overview + +A blind relay helps two peers connect when a direct UDX path is not possible. In the normal case, peeroxide prefers a direct connection or DHT-assisted holepunching. A relay is the fallback when NAT or firewall behavior prevents that direct path from coming up. + +For background on peer discovery and routing, see [DHT and Routing](../concepts/dht-and-routing.md) and [Topics and Discovery](../concepts/topics-and-discovery.md). + +## What “blind” means + +The relay coordinates the connection, but it does not terminate the application session. The two real peers still authenticate each other and speak over their own end-to-end encrypted transport. The relay only forwards packets between them. + +That makes a blind relay different from an application proxy: it helps with reachability, not application-layer trust. + +## Two ways to run a relay + +### Dedicated relay process + +Use `peeroxide relay` when you want a process whose only job is to serve blind-relay requests. + +```bash +peeroxide relay --public --host 0.0.0.0 --port 49737 +``` + +This command prints two useful startup lines on stdout: + +```text +relay public key: <64-hex> +0.0.0.0:49737 +``` + +The public key is the relay's identity. Clients need that public key plus a reachable UDP socket address for the relay. + +### Courtesy relay on a routing node + +Use [`peeroxide node`](../node/overview.md) with `--relay` when you want one long-running process to do both jobs: participate in DHT routing and offer relay service. + +```bash +peeroxide node --public --relay +``` + +This is convenient for private meshes, bootstrap nodes, or small deployments where one operator wants to contribute both routing capacity and relay capacity. + +## Common flags + +`peeroxide relay --help` currently exposes these relay-specific flags: + +| Flag | Meaning | +|---|---| +| `--port ` | UDP bind port. Default: `49737`. | +| `--host ` | UDP bind address. Default: `0.0.0.0`. | +| `--key-seed ` | Deterministic 32-byte relay identity seed in hex. | +| `--stats-interval ` | Periodic relay/routing stats log interval. Default: `60`. | +| `--max-sessions ` | Maximum concurrent relay sessions. Default: `10000`. | +| `--max-pairings-per-session ` | Maximum pending + active pairings per session. Default: `256`. | +| `--pairing-timeout ` | Drop an unmatched pairing after this many seconds. Default: `300`. | +| `--idle-session-timeout ` | Close a session with no pair/unpair activity for this many seconds. Default: `600`. | + +The courtesy relay flags on `peeroxide node --relay` are the same knobs with `relay-` prefixes: +`--relay-key-seed`, `--relay-max-sessions`, `--relay-max-pairings-per-session`, `--relay-pairing-timeout`, and `--relay-idle-session-timeout`. + +Like the other DHT-facing commands, both `relay` and `node --relay` also accept the shared bootstrap-selection flags documented in [init](../init/overview.md#global-cli-flags): `--public`, `--no-public`, `--bootstrap`, `--config`, and `--no-default-config`. + +## Choosing between `relay` and `node --relay` + +Use `peeroxide relay` when: + +- you want a dedicated relay-only service, +- you do not want the process storing general DHT records, +- you want to scale relays separately from bootstrap or routing nodes. + +Use `peeroxide node --relay` when: + +- you already run a stable node, +- you want to offer relay service as a courtesy, +- a single always-on process is simpler operationally. + +In either mode, the relay should usually run at a stable, reachable UDP address. The relay helps other peers traverse NATs; it is not designed to depend on its own holepunching sequence. + +## How clients opt in + +Blind relay is a `Swarm` feature. At the library level, a server-side swarm can advertise a relay by setting `SwarmConfig::relay_through` to the relay's public key and, optionally, `SwarmConfig::relay_address` to a known socket address. + +```rust +use peeroxide::SwarmConfig; + +let mut cfg = SwarmConfig::with_public_bootstrap(); +cfg.relay_through = Some(relay_public_key); +cfg.relay_address = Some("198.51.100.10:49737".parse()?); +``` + +When that is set, inbound swarm clients are told to connect through the relay instead of trying the normal direct or holepunched path. + +### CLI example: forcing relay for `cp` + +In the CLI today, the practical opt-in surface is the `PEEROXIDE_FORCE_RELAY` environment variable used by swarm-backed commands such as `cp`. + +```bash +export PEEROXIDE_FORCE_RELAY="@198.51.100.10:49737" +peeroxide cp send ./photo.jpg my-topic +peeroxide cp recv my-topic ./downloads/ +``` + +The value format is: + +```text +@ +``` + +A few details matter: + +- The public key must be 64 hex characters. +- The address must be the relay's reachable UDP address, not necessarily the bind address it printed locally. +- The setting is most important on the side acting as the swarm server, such as `cp send`, because that side advertises `relay_through` in its handshake reply. +- Scripts may still export it on both sides for symmetry and operator clarity. + +### What about `dd`? + +`dd` does not use blind relay. It stores and retrieves data through DHT records rather than opening a long-lived swarm connection, so `PEEROXIDE_FORCE_RELAY` has no effect there. + +## Practical workflow + +A common setup looks like this: + +1. Start a relay on a stable public address. +2. Note its printed relay public key. +3. Publish or otherwise share that public key and UDP address with the peers that should use it. +4. Configure the sender/server side to advertise that relay. +5. Let the receiving side connect normally; the handshake will direct it through the relay. + +## Security notes + +A blind relay improves reachability, not confidentiality. The application stream remains protected by the same end-to-end Noise + SecretStream security model described in [Security Model](../appendices/security-model.md). + +The relay can observe metadata such as: + +- which clients opened relay control sessions, +- how many pairings were attempted, +- the relay-visible source addresses of those sessions, +- traffic volume and stream lifetime. + +It does **not** need to decrypt relayed application payloads. + +## See Also + +- [Architecture](architecture.md) +- [peeroxide node](../node/overview.md) +- [cp Overview](../cp/overview.md) +- [DHT and Routing](../concepts/dht-and-routing.md) +- [Security Model](../appendices/security-model.md) diff --git a/libudx/src/native/header.rs b/libudx/src/native/header.rs index f4e7bec..20f7efb 100644 --- a/libudx/src/native/header.rs +++ b/libudx/src/native/header.rs @@ -1,44 +1,106 @@ +//! Raw UDX packet-header wire format encoding and decoding. + +/// Size in bytes of the fixed UDX packet header, before any SACK bytes, +/// MTU-probe padding, or payload. pub const HEADER_SIZE: usize = 20; +/// Sentinel byte at offset 0 that identifies a packet as UDX on the wire. pub const MAGIC: u8 = 0xFF; +/// Supported UDX header version byte at offset 1. pub const VERSION: u8 = 1; +/// Packet carries stream payload bytes after the header and any `data_offset` +/// extension bytes. pub const FLAG_DATA: u8 = 0x01; +/// Packet marks the sender's write-side end-of-stream (FIN). +/// +/// A packet may combine this with [`FLAG_DATA`] so the last payload chunk and +/// stream end marker travel in one frame. pub const FLAG_END: u8 = 0x02; +/// Packet includes selective-acknowledgement range data immediately after the +/// fixed 20-byte header. +/// +/// When this bit is set, [`Header::data_offset`] is the number of SACK bytes to +/// skip before the payload begins. pub const FLAG_SACK: u8 = 0x04; +/// Packet is an unreliable message/datagram frame rather than reliable stream +/// data. pub const FLAG_MESSAGE: u8 = 0x08; +/// Packet requests immediate stream teardown by the remote peer. pub const FLAG_DESTROY: u8 = 0x10; +/// Packet is a heartbeat/keepalive frame. pub const FLAG_HEARTBEAT: u8 = 0x20; +/// Errors returned while parsing the fixed UDX header or its SACK extension +/// bytes. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum HeaderError { + /// The packet buffer did not contain the 20-byte fixed header. #[error("packet too short: {0} bytes (minimum {HEADER_SIZE})")] TooShort(usize), + /// Byte 0 was not the expected [`MAGIC`] value. #[error("bad magic byte: 0x{0:02X} (expected 0xFF)")] BadMagic(u8), + /// Byte 1 was not the supported [`VERSION`] value. #[error("unsupported version: {0} (expected {VERSION})")] BadVersion(u8), + /// SACK extension bytes were present but not aligned to 8-byte range + /// entries. #[error("invalid SACK data: length {0} is not a multiple of 8")] InvalidSack(usize), } +/// Raw fixed-width UDX packet header as it appears on the wire. +/// +/// The serialized layout is: +/// +/// - byte 0: [`MAGIC`] +/// - byte 1: [`VERSION`] +/// - byte 2: `type_flags` +/// - byte 3: `data_offset` +/// - bytes 4..8: `remote_id` (little-endian) +/// - bytes 8..12: `recv_window` (little-endian) +/// - bytes 12..16: `seq` (little-endian) +/// - bytes 16..20: `ack` (little-endian) +/// +/// Any bytes indicated by `data_offset` live immediately after this fixed +/// header and before the payload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Header { + /// Bitfield of packet meaning flags such as [`FLAG_DATA`] or [`FLAG_END`]. pub type_flags: u8, + /// Number of bytes between the fixed header and the payload. + /// + /// This is used for SACK bytes on ACK packets and for zero padding on + /// MTU-probe packets. pub data_offset: u8, + /// Receiver-local stream identifier that the peer uses to demultiplex this + /// packet. pub remote_id: u32, + /// Sender's advertised receive window in bytes. pub recv_window: u32, + /// Packet sequence number for reliable stream delivery. pub seq: u32, + /// Cumulative acknowledgement number: all sequence numbers below this value + /// have been received in order. pub ack: u32, } impl Header { + /// Serializes the fixed 20-byte UDX header into a new array. + /// + /// Multi-byte fields are written little-endian in the layout documented on + /// [`Header`]. pub fn encode(&self) -> [u8; HEADER_SIZE] { let mut buf = [0u8; HEADER_SIZE]; self.encode_into(&mut buf); buf } + /// Serializes the fixed 20-byte UDX header into the first + /// [`HEADER_SIZE`] bytes of `buf`. + /// + /// The caller must provide a buffer at least [`HEADER_SIZE`] bytes long. pub fn encode_into(&self, buf: &mut [u8]) { buf[0] = MAGIC; buf[1] = VERSION; @@ -50,6 +112,10 @@ impl Header { buf[16..20].copy_from_slice(&self.ack.to_le_bytes()); } + /// Parses the fixed UDX wire header from the start of `buf`. + /// + /// The buffer may contain trailing bytes for SACK data, MTU-probe padding, + /// or payload; only the first [`HEADER_SIZE`] bytes are decoded. pub fn decode(buf: &[u8]) -> Result { if buf.len() < HEADER_SIZE { return Err(HeaderError::TooShort(buf.len())); @@ -70,21 +136,38 @@ impl Header { }) } + /// Returns the byte index where payload begins in a full packet buffer. + /// + /// This is `HEADER_SIZE + data_offset`, so it skips any SACK bytes or + /// MTU-probe padding carried between the fixed header and payload. pub fn payload_offset(&self) -> usize { HEADER_SIZE + self.data_offset as usize } + /// Returns `true` when `type_flags` shares any bits with `flag`. pub fn has_flag(&self, flag: u8) -> bool { self.type_flags & flag != 0 } } +/// One selective-acknowledgement range encoded after a header with +/// [`FLAG_SACK`] set. +/// +/// Ranges are half-open: `start` is included and `end` is excluded, so a range +/// acknowledges sequence numbers `start..end`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SackRange { + /// First acknowledged sequence number in the range, inclusive. pub start: u32, + /// One past the last acknowledged sequence number in the range, exclusive. pub end: u32, } +/// Encodes `ranges` as contiguous little-endian `(start, end)` pairs. +/// +/// Each range occupies 8 bytes: `start` as LE `u32`, followed by `end` as LE +/// `u32`. The caller must provide at least `ranges.len() * 8` bytes in `buf`. +/// The returned value is the number of bytes written. pub fn encode_sack(ranges: &[SackRange], buf: &mut [u8]) -> usize { let needed = ranges.len() * 8; for (i, range) in ranges.iter().enumerate() { @@ -95,6 +178,11 @@ pub fn encode_sack(ranges: &[SackRange], buf: &mut [u8]) -> usize { needed } +/// Decodes SACK bytes stored after the fixed UDX header. +/// +/// The input must be an exact sequence of 8-byte little-endian `(start, end)` +/// pairs, where each pair describes a half-open acknowledged sequence range +/// `start..end`. pub fn decode_sack(buf: &[u8]) -> Result, HeaderError> { if buf.len() % 8 != 0 { return Err(HeaderError::InvalidSack(buf.len())); diff --git a/peeroxide-cli/CHANGELOG.md b/peeroxide-cli/CHANGELOG.md index 62a9189..0ea9acb 100644 --- a/peeroxide-cli/CHANGELOG.md +++ b/peeroxide-cli/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `--idle-session-timeout`/`--relay-idle-session-timeout` help text incorrectly claimed idle-session sweeping was "not yet enforced" — stale since idle-session-timeout enforcement shipped in `peeroxide-dht` 1.7.0. Corrected the help text to describe actual current behavior. + ## [0.3.0](https://github.com/Rightbracket/peeroxide/compare/peeroxide-cli-v0.2.1...peeroxide-cli-v0.3.0) - 2026-07-12 ### Other diff --git a/peeroxide-cli/README.md b/peeroxide-cli/README.md index a020769..57e708b 100644 --- a/peeroxide-cli/README.md +++ b/peeroxide-cli/README.md @@ -60,9 +60,10 @@ peeroxide --public ping | `node` | Run a long-running DHT coordination (bootstrap) node | | `lookup` | Query the DHT for peers announcing a topic | | `announce` | Announce presence on a topic | +| `relay` | Run a standalone blind-relay server | | `ping` | Diagnose reachability; bootstrap check, NAT classification, or targeted ping | | `cp` | Copy files between peers over the swarm | -| `dd` | Dead Drop: anonymous store-and-forward via the DHT (v1 + v2 protocols) | +| `dd` | Dead Drop: anonymous store-and-forward via the DHT (v2 by default; use `--v1` for the legacy protocol) | | `chat` | End-to-end-encrypted P2P chat: channels, DMs, inbox, and TUI | Run `peeroxide --help` for detailed usage of each command. @@ -81,7 +82,7 @@ If `~/.local/share/man` is not in your `MANPATH`, add it: export MANPATH="$HOME/.local/share/man:$MANPATH" ``` -This produces 9 pages: +This produces 10 pages: ``` peeroxide(1) — main command and global options @@ -89,6 +90,7 @@ peeroxide-init(1) — config initialization and man page installation peeroxide-node(1) — bootstrap node operation peeroxide-lookup(1) — DHT topic lookup peeroxide-announce(1) — DHT topic announcement +peeroxide-relay(1) — standalone blind-relay server peeroxide-ping(1) — connectivity diagnostics peeroxide-cp(1) — file transfer (send + recv) peeroxide-dd(1) — dead drop messaging (put + get, v1 + v2) diff --git a/peeroxide-cli/src/cmd/chat/display.rs b/peeroxide-cli/src/cmd/chat/display.rs index d8c6276..c3393cb 100644 --- a/peeroxide-cli/src/cmd/chat/display.rs +++ b/peeroxide-cli/src/cmd/chat/display.rs @@ -84,8 +84,8 @@ impl DisplayState { } /// Render `msg` and print directly to stdout/stderr. Convenience wrapper - /// around [`render_to`]; preserved for callers that don't yet route - /// through a `ChatUi`. New callers should prefer [`render_to`] so the + /// around [`Self::render_to`]; preserved for callers that don't yet route + /// through a `ChatUi`. New callers should prefer [`Self::render_to`] so the /// output can be directed appropriately (e.g. into the TUI scroll region). pub fn render(&mut self, msg: &DisplayMessage) { let out = self.render_to(msg); diff --git a/peeroxide-cli/src/cmd/chat/tui/mod.rs b/peeroxide-cli/src/cmd/chat/tui/mod.rs index e6f2550..c158488 100644 --- a/peeroxide-cli/src/cmd/chat/tui/mod.rs +++ b/peeroxide-cli/src/cmd/chat/tui/mod.rs @@ -73,7 +73,7 @@ mod notice_global { //! Process-wide notice sink for code paths that can't easily take a //! `NoticeSink` parameter (probe traces deep inside helpers, etc.). //! - //! Set once at session start by `join::run` (via [`install_global`]) and + //! Set once at session start by `join::run` (via [`super::install_global_notice_sink`]) and //! never replaced. Concurrent calls from spawned tasks are safe — the //! underlying `UnboundedSender` is `Clone + Send + Sync`. diff --git a/peeroxide-cli/src/cmd/deaddrop/v2/keys.rs b/peeroxide-cli/src/cmd/deaddrop/v2/keys.rs index 1d02273..f3d4a72 100644 --- a/peeroxide-cli/src/cmd/deaddrop/v2/keys.rs +++ b/peeroxide-cli/src/cmd/deaddrop/v2/keys.rs @@ -3,9 +3,9 @@ //! Spec: see *Key Derivation* section of `DEADDROP_V2.md (and `docs/src/dd/`)`. //! //! root_keypair = KeyPair::from_seed(root_seed) -//! index_keypair[i] = KeyPair::from_seed(blake2b(root_seed || b"idx" || i_le)) +//! `index_keypair[i]` = KeyPair::from_seed(blake2b(root_seed || b"idx" || i_le)) //! where i is u32 little-endian -//! salt = root_seed[0] +//! salt = `root_seed[0]` #![allow(dead_code)] diff --git a/peeroxide-cli/src/cmd/node.rs b/peeroxide-cli/src/cmd/node.rs index dc9bcd7..9cc8324 100644 --- a/peeroxide-cli/src/cmd/node.rs +++ b/peeroxide-cli/src/cmd/node.rs @@ -67,8 +67,8 @@ pub struct NodeArgs { #[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) + /// Close a relay session with no pair/unpair activity for this many + /// seconds (only used with --relay; default: 600) #[arg(long)] relay_idle_session_timeout: Option, } diff --git a/peeroxide-cli/src/cmd/relay.rs b/peeroxide-cli/src/cmd/relay.rs index 3ed78ec..ced3b1c 100644 --- a/peeroxide-cli/src/cmd/relay.rs +++ b/peeroxide-cli/src/cmd/relay.rs @@ -41,8 +41,8 @@ pub struct RelayArgs { #[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) + /// Close a session with no pair/unpair activity for this many seconds + /// (default: 600) #[arg(long)] idle_session_timeout: Option, } diff --git a/peeroxide-dht/src/blind_relay.rs b/peeroxide-dht/src/blind_relay.rs index 6fcbfc2..107d908 100644 --- a/peeroxide-dht/src/blind_relay.rs +++ b/peeroxide-dht/src/blind_relay.rs @@ -1,6 +1,30 @@ -//! blind-relay protocol messages — wire-compatible with Node.js `blind-relay@1.4.0`. +//! blind-relay protocol messages and the shared pairing engine — +//! wire-compatible with Node.js `blind-relay@1.4.0`. //! //! The blind-relay protocol uses Protomux with protocol name `"blind-relay"`. +//! A [`crate::blind_relay::BlindRelayServer`] owns the shared pairing tables +//! and limits for a relay instance, while each accepted control connection gets +//! its own [`crate::blind_relay::BlindRelaySession`]. Sessions send `pair` and +//! `unpair` messages keyed by a 32-byte token; once opposite sides of the same +//! token arrive, the server yields a [`crate::blind_relay::MatchedPairing`] to +//! the caller. The caller then creates the two raw data-plane streams and +//! bridges them blindly. This module never decrypts or inspects the relayed +//! application payloads; it only matches tokens and coordinates lifecycle. +//! +//! Peeroxide adds one hardening behavior that Node's reference `blind-relay` +//! does not have: [`crate::blind_relay::BlindRelayServer::sweep_idle_sessions`] +//! closes sessions that have seen no `pair`/`unpair` activity for +//! [`crate::blind_relay::BlindRelayServerConfig::idle_session_timeout`]. This +//! is in addition to [`crate::blind_relay::BlindRelayServer::sweep_expired_pairings`], +//! which drops unmatched pending tokens after `pairing_timeout`. +//! +//! Stream teardown follows Node's lifecycle closely once a pairing is active: +//! unpairing an active token tears its bridged data-plane streams down, and +//! closing either session does the same for every active pairing owned by that +//! session. In peeroxide this happens via +//! [`crate::blind_relay::BlindRelayServer::unpair`] and +//! [`crate::blind_relay::BlindRelayServer::release_session`], which signal the +//! transport layer to drop the bridged streams. #![allow(dead_code)] @@ -265,15 +289,16 @@ impl BlindRelayClient { // // 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. +// effectively unbounded and timeout-free. Peeroxide adds these as hardening +// measures with deliberately generous defaults, so normal deployments should +// not hit them in practice; see the field docs below for the exact behavior. /// 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. +/// module-level notes above. Peeroxide's defaults are intentionally generous, +/// but they are still real limits/timeouts rather than a literally unbounded +/// replica of Node's behavior. #[derive(Debug, Clone)] #[non_exhaustive] pub struct BlindRelayServerConfig { diff --git a/peeroxide-dht/src/hyperdht.rs b/peeroxide-dht/src/hyperdht.rs index 7ce73f5..99b85be 100644 --- a/peeroxide-dht/src/hyperdht.rs +++ b/peeroxide-dht/src/hyperdht.rs @@ -1165,7 +1165,7 @@ impl HyperDhtHandle { .await } - /// Like [`connect_with_nodes`] but accepts a [`ConnectOpts`] options bag. + /// Like [`Self::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). diff --git a/peeroxide-dht/src/io.rs b/peeroxide-dht/src/io.rs index 88556c5..d8f784c 100644 --- a/peeroxide-dht/src/io.rs +++ b/peeroxide-dht/src/io.rs @@ -1,7 +1,8 @@ //! IO layer for the DHT-RPC protocol. //! //! Faithful Rust port of the Node.js dht-rpc IO layer. -//! The [`Io`] struct is driven by the caller from a `tokio::select!` loop. +//! The [`crate::io::Io`] struct is driven by the caller from a +//! `tokio::select!` loop. #![allow(missing_docs)] use std::collections::VecDeque; @@ -826,9 +827,9 @@ impl Io { /// 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`. + /// of any inbound request; unlike the internal `send_reply_deferred` + /// helper, the socket kind is derived from current firewall state rather + /// than passed in via a `ReplyContext`. pub fn reply_to( &mut self, tid: u16, diff --git a/peeroxide-dht/src/lib.rs b/peeroxide-dht/src/lib.rs index 5fa2fae..217e7f2 100644 --- a/peeroxide-dht/src/lib.rs +++ b/peeroxide-dht/src/lib.rs @@ -20,7 +20,7 @@ //! | 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` (internal), [`holepuncher`] | hyperdht/lib/holepuncher.js | -//! | Relay | [`blind_relay`], [`protomux`] | [blind-relay](https://github.com/holepunchto/blind-relay) | +//! | Relay | [`blind_relay`], [`relay_service`], [`protomux`] | [blind-relay](https://github.com/holepunchto/blind-relay) | //! //! # Typical usage //! diff --git a/peeroxide-dht/src/relay_service.rs b/peeroxide-dht/src/relay_service.rs index 53ec458..9d1cc19 100644 --- a/peeroxide-dht/src/relay_service.rs +++ b/peeroxide-dht/src/relay_service.rs @@ -1,14 +1,28 @@ //! Standalone entry point that runs a blind-relay server on top of a -//! [`HyperDhtHandle`]. +//! [`crate::hyperdht::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). +//! [`crate::hyperdht::ServerEvent`]), finalizes each into an encrypted +//! connection, opens a `"blind-relay"` Protomux channel on it, and drives a +//! [`crate::blind_relay::BlindRelaySession`] against a shared +//! [`crate::blind_relay::BlindRelayServer`]. When a pairing matches, it +//! creates the two raw UDX data-plane streams and bridges them with +//! [`libudx::UdxStream::relay_to`] (packet-level, blind — peeroxide never +//! decrypts relayed application data). +//! +//! `run_relay_server` also self-announces `hash(public_key)` through +//! [`crate::hyperdht::HyperDhtHandle::announce`] at startup and then on a +//! roughly 10-minute refresh loop, mirroring Node's `Server.listen()` +//! announcer. That announce record is what Node.js `hyperdht` clients discover +//! during `dht.connect()`/`findPeer`; `register_server` alone only makes the +//! local node *answer* handshakes once they arrive. +//! +//! The service additionally runs periodic maintenance around the shared +//! pairing table: it sweeps expired pending pairings, closes sessions idle past +//! `idle_session_timeout`, and tears down bridged data-plane streams when an +//! active token is unpaired or either session closes. See +//! [`crate::blind_relay`] for the protocol-level pairing/session lifecycle. //! //! Deliberately independent of `peeroxide::Swarm`: a relay has no topics, //! no peer discovery, and no retry bookkeeping, so this reimplements only diff --git a/peeroxide/src/lib.rs b/peeroxide/src/lib.rs index ec8ba25..13571ff 100644 --- a/peeroxide/src/lib.rs +++ b/peeroxide/src/lib.rs @@ -8,7 +8,9 @@ //! `peeroxide` is the high-level entry point for the Peeroxide stack. //! It discovers peers by topic, establishes Noise-encrypted connections, //! and manages the full connection lifecycle (deduplication, retry with -//! backoff, priority queuing). Wire-compatible with the Node.js +//! backoff, priority queuing). When configured, a swarm can also route +//! server-side connections through a blind-relay server instead of using +//! the direct or hole-punched path. Wire-compatible with the Node.js //! Hyperswarm network. //! //! # Quick start diff --git a/peeroxide/src/swarm.rs b/peeroxide/src/swarm.rs index 0a2dde0..439e8d6 100644 --- a/peeroxide/src/swarm.rs +++ b/peeroxide/src/swarm.rs @@ -410,6 +410,15 @@ struct ConnectAttemptResult { /// Create and start a Hyperswarm instance. /// +/// The spawned swarm discovers peers by topic and establishes encrypted +/// UDX connections for each [`SwarmConnection`]. By default, server-side +/// handshakes advertise the direct or hole-punched path selected by the +/// DHT/NAT logic. If [`SwarmConfig::relay_through`] is set, the server side +/// instead instructs clients to pair through that blind-relay node and then +/// opens its side of the relayed connection directly to the configured relay +/// address (when [`SwarmConfig::relay_address`] is provided) or via DHT +/// discovery of the relay node. +/// /// Returns a background task handle, a control handle, and a receiver /// that yields each new [`SwarmConnection`]. pub async fn spawn(