Repository navigation
fix(swarm): let Node.js clients connect to a firewalled peeroxide server - #31
Conversation
`QueryReply` gains `to`: our own address as the responding node saw it,
already decoded off the wire. `HyperDhtHandle` gains `current_relays()`:
the self-announce relays, each paired with that address, mirroring Node
hyperdht's `Announcer.relays` (`{ relayAddress: msg.from, peerAddress:
msg.to }`).
A server advertises these pairs in the `holepunch.relays` of its
handshake reply, and a relay forwards a client's PEER_HOLEPUNCH to
`peer_address`, so it has to be how that relay reaches us.
`current_relay_addresses()` keeps its signature and behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Node.js Hyperswarm client could not connect to a firewalled peeroxide
server: every attempt timed out ("passive holepunch timed out" here,
HOLEPUNCH_ABORTED on the Node side). The server side of the handshake
now matches Node hyperdht `lib/server.js` on three points:
- Duplicate PEER_HANDSHAKE. A Node client sends one Noise message
through up to two relays in parallel and acts on the first reply. Every
copy now gets the same reply, cached by the Noise message for 20 s.
Before, each copy got a fresh responder, and the second replaced the
holepunch session of the first.
- Holepunch relays. `holepunch.relays[].peer_address` is now our own
address as each relay saw it (`HyperDhtHandle::current_relays()`).
Before, it held the client's address, so the relay sent the client's
PEER_HOLEPUNCH back to the client.
- Open or direct clients. A client that says it is FIREWALL_OPEN, or
that reached us without a relay, is now dialled directly, as Node does.
Before, it was given a holepunch, while it waited for that dial.
Tests: two Node-client -> Rust-server interop tests (a firewalled client
that holepunches through the relays, and a reachable client that waits
to be dialled), a Rust-only test that sends one PEER_HANDSHAKE twice,
and unit tests for the reply cache and envelope. The Node helper gains
two opt-in flags, `--remote-client` and `--open-client`; without them it
behaves as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eshork
left a comment
There was a problem hiding this comment.
Thanks for this. The diagnosis is right: I reproduced it locally. The three new tests fail against main's source and pass on this branch (and stayed green over 10 reruns). The dedup fix and the relay peer_address fix both look correct. Requesting changes for two things, plus some test gaps.
1. Open/direct clients: the server still never dials (blocking)
Node handles a FIREWALL_OPEN (or direct) client in two parts:
- the reply still includes
holepunch: { id, relays }whenever the server itself is firewalled (lib/server.js:360, which only looks atourRemoteAddr); - then the server dials the client:
hs.onsocket(sock, clientAddress.port, clientAddress.host)(lib/server.js:392-396).
This PR does neither. It drops holepunch from the reply, and create_server_connection still ignores _client_address; it only installs a passive firewall hook. With no holepunch info, the Node client takes the "relayed && !remoteHolepunchable" branch of connect.js holepunch() and dials the firewalled server itself. That works on loopback and behind full-cone NATs. Behind the more common address- or port-restricted NATs, the client's unsolicited packets get dropped. So I'd expect open clients (e.g. on a VPS) to still fail in practice, now with a stream timeout instead of HOLEPUNCH_ABORTED.
Suggested fix: for open/direct clients, connect the server stream to client_address so the server sends first (like Node's onsocket), and keep advertising { id, relays } when the server is firewalled.
2. HandshakeReplyCache has no size limit
getandinsertboth runprune(), whichretains over the whole map, so each handshake costs two full scans.- Nothing limits the number of entries.
- Insertion happens before the already-connected and
max_peerschecks.
Anyone who knows the announced public key can send distinct valid IK first messages, each one a new key. That makes the cost grow quadratically, and all of it runs inside the serial swarm actor, so a flood also stalls joins, connections and holepunches. Expired entries are also only removed when another handshake arrives. Suggest insertion-ordered expiry (e.g. a VecDeque<(Instant, key)> popped from the front) plus a hard cap on entries.
Test gaps
hyperswarm_open_node_client_connects_to_rust_serverpasses because of loopback reachability, not because the server dials. It wouldn't catch (1).hyperswarm_node_client_holepunches_to_rust_serverdoesn't assert the Rust side is actuallyFIREWALL_CONSISTENT/RANDOMwhen the handshake arrives. If NAT classification is still UNKNOWN, it takes the direct path and passes anyway.server_handshake_dedup.rssends the handshake straight to the server, so no passive holepunch is ever staged. It checks the replies are byte-identical, but not the bug that actually broke things:commit_passive_holepunchreplacing the first session. Worth adding a duplicate that arrives while a holepunch is staged, and asserting a PEER_HOLEPUNCH for the first reply's id still works.- Also missing: duplicates through two real relays (
FROM_RELAY+FROM_SECOND_RELAYwith differentpeer_address), a check that the server sends the first packet to an open client, and the cache's size cap / idle expiry.
Non-blocking: run_server still runs a fresh responder per copy (you already noted it). Worth a follow-up issue so it doesn't get lost.
|
A few corrections and clarifications to my review above:
|
Follow-up corrections to the Node-client-to-firewalled-server fix:
- Open or direct clients: the server now dials the client (its stream
connects to the client's address, so it sends the first packet and opens
its own NAT mapping), as Node's `hs.onsocket(...)` does. A firewalled
server keeps advertising `holepunch { id, relays }` to such a client, as
Node does, so an open Node client waits for that dial instead of dialling
the firewalled server itself.
- Handshake reply cache: keyed by a hash of the client's Noise message,
expired in insertion order (amortised O(1)) and by a periodic sweep,
capped at 1024 entries (oldest evicted first), and skips handshakes the
server rejects.
- Tests: the interop tests wait until the Rust server classifies itself as
firewalled and check Node's punch counters to prove which side dialled;
the open client is now ephemeral so it really goes through relays; a new
direct-client test covers the direct path; the remote client reports
FIREWALL_UNKNOWN deterministically. A new Rust-only test sends one
handshake through two real relays to a firewalled server and checks that
the staged holepunch session for the first reply survives the duplicate.
Cache unit tests cover first-reply-wins, the capacity bound and idle expiry.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…en tests Review follow-ups on the previous commit: - A relayed handshake the server would reject (already connected, at max_peers, no UDX info) no longer keeps its staged passive holepunch or a reply-cache entry; the session would only have been dropped when its punch landed. `HolepunchSetup::release()` also frees the puncher on the Noise error paths, not just the abort timer. - `--direct-client` now reports FIREWALL_UNKNOWN, so the direct-client interop test can only pass through the direct (non-relayed) path. - New tests: a server at max_peers = 0 neither caches replies nor keeps a staged holepunch; a Rust client connecting straight to a firewalled swarm server still works now that the server dials it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A relayed --direct-client attempt would otherwise take Node's same-host LAN shortcut, which leaves the punch counters at zero, so the counters could not tell a direct handshake from a relayed one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
`select_server_path` replaces the `server_firewalled` / `client_dialable` /
`attempt_holepunch` flags with an explicit `ServerPath` (Relay, Dial,
Holepunch, Listen), mirroring Node `lib/server.js _addHandshake`, and is
unit-tested as a decision table. `Dial { advertise_holepunch }` carries the
"firewalled server still offers a holepunch to a client it dials" rule.
The free-slot and UDX checks no longer need the client's key, so they now
run before a passive holepunch is staged: a full swarm no longer opens a
puncher socket only to release it. Only the already-connected check still
releases a staged session.
Tests: the max_peers test now asserts no holepunch is staged; a new test
covers the already-connected release path.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
@alaa-eddine thanks for this. Your diagnosis and the relay /
Tests
Validation (macOS)
No public API changes beyond yours. |
clippy 1.98 denies `drain(..).collect()` into the same collection type (clippy::drain_collect). Use `std::mem::take` instead; behaviour is unchanged. Three sites: libudx stream timeout path, protomux cork batch and pending remote-channel messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rust 1.99 deprecates `AtomicUsize::fetch_update` in favour of `try_update`, which does not exist at our MSRV (1.85), so CI clippy (-D warnings) fails on stable. Move the three identical saturating decrements into one `saturating_dec` helper with a scoped `#[allow(deprecated)]`; behaviour is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Summary
Node.js Hyperswarm clients could not connect to a firewalled peeroxide server; every attempt timed
out. This PR makes the server side of the handshake match Node hyperdht
lib/server.json the threepoints described below.
Problem
A Node.js Hyperswarm client cannot connect to a peeroxide 1.7.3 swarm that announces a topic from
behind a NAT. The opposite direction, Rust client to Node server, works; that is what
hyperswarm_cross_language_connectcovers.Every attempt from Node fails:
passive holepunch: replacing stale in-flight entry, thenpassive holepunch timed out.dht.connect()fails withHOLEPUNCH_ABORTED.Seen against hyperswarm 4.17.0 / hyperdht 6.30.0 (the versions
tests/nodepins) and 4.17.2 /6.34.1, both on a local testnet and on the public DHT from behind a home NAT.
How to reproduce
cargo run --example swarm_announce -p peeroxidebehind a NAT, thennode tests/node/hyperswarm-join.js <topic>. They never connect.hyperswarm_node_client_holepunches_to_rust_server, added in this PR,fails on
mainthe same way.Causes
There are three, and any one of them is enough to break the connection. Line numbers are for
hyperdht 6.30.0.
lib/connect.js:416).lib/connect.js:355,Semaphore(2)) and acts on the first reply.(
lib/server.js:466-475). It keeps the entry until 10 s after the handshake settles(
HANDSHAKE_CLEAR_WAIT).handle_server_handshakeruns a freshNoiseWrapresponder for every copy, witha new ephemeral key, stream id and holepunch id.
commit_passive_holepunchthen replaces thefirst session with the second.
peer_addressinholepunch.relays.{ relayAddress: msg.from, peerAddress: msg.to }(
lib/announcer.js:265): the relay, and our address as that relay saw it.relayAddress, and the relay forwards it topeerAddress(
lib/router.js:213-218).peer_addresswith the client's address (swarm.rs,peer_address: client_address.clone()).a round.
FIREWALL_OPEN, or reached the server without a relay, Node'sserver dials it straight away (
lib/server.js:392-396). An open Node client waits for thatdial (
lib/connect.js:236-239).passive holepunch that the client will never perform.
NATed Rust peer.
Changes
peeroxide(src/swarm.rs)HandshakeReplyCacheanswers every copy of a PEER_HANDSHAKE with the same Noise reply, keyed bythe client's Noise message. Entries last 20 s: the 10 s passive-holepunch window plus Node's 10 s
HANDSHAKE_CLEAR_WAIT.encode_server_handshake_reply, shared by the first answer and theduplicates.
holepunch.relaysnow comes fromHyperDhtHandle::current_relays(), so each relay is paired withour own address as that relay saw it.
FIREWALL_OPEN, or that reached us directly, is dialled through the existingdirect path instead of being given a holepunch.
peeroxide-dht(additive API only)QueryReply::to(newpubfield on a#[non_exhaustive]Result-role type): our address as theresponder saw it. It is filled from the
tofield already decoded off the wire.HyperDhtHandle::current_relays() -> Vec<RelayInfo>(new method on a Handle;RelayInfois theexisting wire envelope): the self-announce relays, each with that
to.RelayInfoinstead of bare relay addresses.current_relay_addresses()keeps its signature and behaviour; it maps over the same list.
API and semver
VISIBILITY_POLICY.md: the field ison a
#[non_exhaustive]Result type, the method is on a Handle, andRelayInfois a wireenvelope.
bumps the version group.
Tests
Each new test fails on
mainand passes with the fix. Each fix was also switched off on its own tocheck that the matching test catches it.
peeroxide/tests/hyperswarm_interop.rs::hyperswarm_node_client_holepunches_to_rust_serverpeeroxide/tests/hyperswarm_interop.rs::hyperswarm_open_node_client_connects_to_rust_serverpeeroxide/tests/server_handshake_dedup.rsswarm::tests::handshake_reply_cache_returns_the_first_reply_until_it_expiresswarm::tests::server_handshake_reply_follows_the_inbound_modeFROM_RELAY/FROM_SECOND_RELAY→FROM_SERVERwith the client's address;FROM_CLIENT→REPLYhyperdht::tests::current_relays_pair_each_relay_with_our_observed_addresspeer_addressis our own socket as the relay saw ittoplumbingTest helper (
tests/node/hyperswarm-server.js): two opt-in flags. Without them, the helperbehaves exactly as before.
--remote-client: the client skips the same-host LAN shortcut, as a client on another networkwould. It also starts one more DHT node, because a holepunching Node client needs 3 NAT samples
(
lib/nat.js:103).--open-client: the client's DHT node is reachable.Also checked, on the public DHT from behind a home NAT: a real Node.js app (hyperswarm 4.17.2 /
hyperdht 6.34.1) and a Rust peer built on this branch.
end to end.
Not fixed here (possible follow-ups)
addresses4and connects its stream there (lib/connect.js:242-262).not answer DHT pings.
connects to the wrong socket, and the client gets
ETIMEDOUT.works, just slower.
UDX streams are not tied to one socket), or advertise only addresses where the stream listens.
peeroxide_dht::hyperdht::run_server(not the swarm) also runs a fresh responder per copy.It is untouched here.
one has both sent and received bytes (
index.js,existingIsOutdated). peeroxide compares keysonly on outgoing connections, and drops incoming duplicates.
Checklist
cargo test --workspace --no-fail-faston Windows 11: 1065 passed, 9 failed. All 9 fail thesame way on an untouched
maincheckout on that machine, so none come from this PR:libudx:stream_lifecycle::stream_write_read_10mbandthroughput::throughput_sustained(debug-build timeouts; libudx is unchanged);
peeroxide-cli:chat_integration(2) andlocal_commands(5ddtests).Not yet run on Linux, which is what CI uses.
cargo test -p peeroxide-cli --test live_commands -- --ignored: not run.cargo clippy --workspace --all-targets -- -D warningscargo doc --workspace --no-depsMSRV 1.85
cargo check: not run locally (no 1.85 toolchain here; nothing newer is used).No public API breaking changes.
CHANGELOG updated (peeroxide, peeroxide-dht); TESTING.md updated.
No task-artifact files in the branch: the only added file is
peeroxide/tests/server_handshake_dedup.rs.🤖 Generated with Claude Code