Skip to content

fix: advertise dialable addresses to DHT and harden NAT traversal#71

Closed
mickvandijke wants to merge 1 commit into
rc-2026.4.1from
split/nat-hardening
Closed

fix: advertise dialable addresses to DHT and harden NAT traversal#71
mickvandijke wants to merge 1 commit into
rc-2026.4.1from
split/nat-hardening

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Apr 6, 2026

Copy link
Copy Markdown
Collaborator

Splits the NAT-traversal and dialable-address work out of #70 so it can
be reviewed and merged independently from the two 1000-node perf fixes
in the original bundle.

Summary

  • DhtNetworkManager::local_dht_node() is now an async fn that sources
    its self-entry from TransportHandle::observed_external_addresses()
    (QUIC OBSERVED_ADDRESS frames) instead of the wildcard
    NodeConfig::listen_addrs() derivation. This fixes the sporadic-failure
    pattern reported by saorsa-node-lmdb where DHT-based peer discovery
    returned zero contactable addresses for any node.
  • Event-driven drain of relay-established and peer-address-update events
    via tokio::select! over new TransportHandle::recv_* accessors
    instead of the previous 1 s polling loop.
  • Identity exchange timeout bumped from 5 s → 15 s (matches the bootstrap
    budget) so cross-region and congested cellular links stop timing out
    during the ML-DSA-65 handshake.
  • New ObservedAddressCache: in-memory, frequency/recency-aware fallback
    for when every live connection has dropped. Keyed by
    (local_bind, observed) so multi-homed hosts don't cross-pollute
    observations from different interfaces. Reset on process restart.
  • Bridge task split + bounded forwarder channels — the DHT bridge no
    longer blocks the peer-address-update branch on
    find_closest_nodes_network, and the forwarder mpsc channels are now
    bounded with a per-event drop counter.
  • New dial_addresses() helper: loops through every dialable address
    until one succeeds. Replaces 5 single-address dial sites so a stale NAT
    binding on entry style: Fix formatting issues to resolve CI failures #1 no longer kills the dial when entry Comparison between saorsa and libp2p or iroh #2 would have
    worked.
  • Drive-by clippy::needless_update fix in IPDiversityConfig test code.

Regression tests

tests/dht_self_advertisement.rs adds six #[tokio::test] cases that
pin the published self-entry behaviour — three on the loopback bind path,
three on the wildcard / OBSERVED_ADDRESS path (including the primary
regression test for the OBSERVED_ADDRESS flow and the cache fallback
after connection drop). All six FAIL on rc-2026.4.1 and PASS here.

ObservedAddressCache ships with 9 unit tests covering empty, single,
repeated, frequency, recency tiebreak, NAT-rebinding fallback, long-quiet
fallback, eviction, and re-observation-on-full.

Verification

  • cargo build --lib — clean
  • cargo fmt --check — clean
  • cargo clippy --lib -- -D warnings -D clippy::unwrap_used -D clippy::expect_used — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo test --lib — 282 passed, 0 failed
  • cargo test --test dht_self_advertisement — 6/6 passed
  • cargo test --test two_node_messaging — 7/7 passed
  • cargo test --test node_lifecycle — 17/17 passed

Split from #70

This PR and its sibling replace the original #70, which bundled this
NAT-traversal hardening with two 1000-node scale fixes in the DHT touch
path and the inbound message dispatcher. Byte-for-byte the union of this
PR and the sibling equals the content of #70 (verified by merging the two
split branches and diffing against the original HEAD).

The sibling PR covers the 1000-node perf work.

🤖 Generated with Claude Code

Greptile Summary

This PR fixes the root cause of DHT-based peer discovery returning zero contactable addresses by sourcing local_dht_node() from QUIC OBSERVED_ADDRESS frames and runtime listen addresses rather than the static NodeConfig::listen_addrs() wildcard derivation. Supporting changes include: ObservedAddressCache (frequency+recency fallback surviving connection drops), bounded mpsc channels replacing unbounded queues, event-driven DHT bridge replacing a 1 s poll loop, dial_addresses() trying every advertised address instead of the first, and the identity-exchange timeout raised to 15 s to absorb cross-region ML-DSA-65 handshake cost.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style/doc concerns with no correctness impact

No P0/P1 defects found. The core algorithms (OBSERVED_ADDRESS sourcing, ObservedAddressCache frequency+recency selection, dial_addresses fallback loop, bounded mpsc with drop counters) are logically correct. The single P2 finding (doc vs. implementation gap in the cache-merge deduplication in observed_external_addresses()) has no behavioral impact because dial_addresses retries all candidates. The PR ships 6 integration tests + 9 cache unit tests that all pass, and all four CI suites (clippy, fmt, lib tests, integration tests) are confirmed green.

src/transport_handle.rs lines 448–459 (cache-merge doc/behavior gap)

Important Files Changed

Filename Overview
src/dht/security_tests.rs Drive-by clippy::needless_update fix — removes redundant ..IPDiversityConfig::default() struct update syntax
src/dht_network_manager.rs local_dht_node() made async to source addresses from live transport state; new dial_addresses() helper tries every advertised address; IDENTITY_EXCHANGE_TIMEOUT raised to 15s to match bootstrap budget
src/network.rs DHT bridge refactored from 1s polling loop to event-driven tokio::select!; relay propagation detached into spawned task to prevent peer_address_update starvation
src/transport.rs Adds pub(crate) mod observed_address_cache declaration
src/transport/observed_address_cache.rs New bounded (16-entry) in-memory cache of QUIC OBSERVED_ADDRESS frames with frequency+recency-aware selection and per-local-bind partitioning; 9 unit tests covering eviction, recency tiebreak, multi-homed isolation, and NAT-rebinding fallback
src/transport/saorsa_transport_adapter.rs Forwarder channels changed from unbounded to bounded(256) with atomic drop counter and coalesced log warnings; ExternalAddressDiscovered events now populate ObservedAddressCache; dual-stack send errors now preserve full cause chain
src/transport_handle.rs New observed_external_addresses() and recv_relay_established()/recv_peer_address_update() accessors; ObservedAddressCache integrated as live-dead fallback; minor doc/implementation gap in cache merge logic (P2)
tests/dht_self_advertisement.rs Six integration regression tests: 3 loopback-bind (dialable address, port match, no port-zero) and 3 wildcard/OBSERVED_ADDRESS (empty-until-observed, address populated after peer connect, cache fallback after drop)

Sequence Diagram

sequenceDiagram
    participant T as saorsa-transport
    participant C as ObservedAddressCache
    participant TH as TransportHandle
    participant DHT as DhtNetworkManager
    participant Peer as Remote Peer

    T->>C: ExternalAddressDiscovered(addr)<br/>via forwarder task
    Note over C: records (local_bind, observed)<br/>frequency + recency

    T->>TH: RelayEstablished(relay_addr)<br/>bounded mpsc [256]
    TH-->>DHT: recv_relay_established()
    DHT->>DHT: tokio::spawn self-lookup task
    DHT->>Peer: PublishAddress(relay_addr) to K closest

    T->>TH: PeerAddressUpdated(peer, adv)<br/>bounded mpsc [256]
    TH-->>DHT: recv_peer_address_update()
    DHT->>DHT: touch_node_typed(relay address)

    DHT->>TH: local_dht_node() [async]
    TH->>T: get_observed_external_addresses()
    T-->>TH: live observations (per stack)
    TH->>C: most_frequent_recent_per_local_bind()
    C-->>TH: cached fallback (deduped)
    TH-->>DHT: merged address list

    DHT->>Peer: FIND_NODE response with self-entry
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/transport_handle.rs
Line: 448-459

Comment:
**Cache merged unconditionally alongside live observations**

The doc comment says the cache is used "for each `(local_bind, observed)` partition that has **no live observation yet**", implying it only fills the gap when a bind has no live data. The implementation deduplicates by address value only — not by local bind — so if a bind currently has a live observation but the cache also holds a stale address for that same bind (e.g. after a NAT rebinding), both addresses are published. Peers reaching `dial_addresses` will try the stale one and fail, then succeed on the fresh one, so there is no correctness impact. However, the doc and the code should be aligned: if the per-bind fallback intent is correct, the merge loop could skip cache entries whose local bind already has a live entry in `out`.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: advertise dialable addresses to DHT..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Splits the NAT-traversal and dialable-address work from the original
PR #70 bundle. Root cause and fix below; other correctness fixes
bundled.

## Root cause

`DhtNetworkManager::local_dht_node()` built its self-entry from the
static `NodeConfig::listen_addrs()` derivation, which returns wildcard
IPs (`0.0.0.0` / `::`) and the configured port (`0` for ephemeral
binds). Receivers' `dialable_addresses()` filter then rejected the
entry, so DHT-based peer discovery returned **zero contactable
addresses** for any node. Connections only succeeded via side channels
(static bootstrap, in-memory cache, relay fallback) — exactly the
sporadic-failure pattern reported by `saorsa-node-lmdb`.

## Fix

`local_dht_node()` is now an `async fn` that sources its self-entry
addresses from `TransportHandle::observed_external_addresses()` — the
post-NAT addresses observed via QUIC `OBSERVED_ADDRESS` frames, one
per local interface that has an observation on a multi-homed host.
An intermediate iteration added a `UdpSocket::connect`-then-
`getsockname` helper (`primary_local_ip`) to substitute wildcard IPs,
but it was dropped in a follow-up because it ran blocking syscalls
inside an async context and returned RFC1918 addresses behind home
NAT; the OBSERVED_ADDRESS-only flow supersedes it and needs no
interface probing.

If no source produces an address, the published self-entry has an
empty `addresses` vec. This is the right answer at the publish layer:
better to admit "I don't know how to be reached yet" than to lie
with a bind-side wildcard. The empty window closes naturally once the
first peer connects and OBSERVED_ADDRESS flows.

## Other NAT fixes bundled

- **Event-driven relay/address updates** — relay-established and
  peer-address-update events drain via `tokio::select!` over
  `TransportHandle::recv_relay_established()` /
  `recv_peer_address_update()` instead of the previous 1-second
  polling loop, eliminating the race window where outbound DHT queries
  advertised no relay address for up to a second after relay setup.

- **Identity exchange timeout bumped to 15 s** — `send_dht_request`
  computes its identity-exchange timeout as
  `min(request_timeout, IDENTITY_EXCHANGE_TIMEOUT)`; with the constant
  at 5 s the effective budget was always 5 s, too tight for
  cross-region or congested cellular links where the two-RTT exchange
  plus ML-DSA-65 verification can blow past with retransmits. Bumped
  to 15 s to match the bootstrap budget.

- **Observed-address cache fallback** — saorsa-transport exposes the
  externally-observed address only via active connections, so a node
  whose every connection has just dropped has no answer for
  `observed_external_address()`. This introduces
  `ObservedAddressCache`: an in-memory, frequency- and recency-aware
  cache populated by `P2pEvent::ExternalAddressDiscovered` events.
  When the live source is empty, the cache returns the
  most-frequently-observed address among recent entries, breaking
  ties by recency. NAT-rebind handling via a two-pass selection
  (recent window wins over stale counts). Keyed by
  `(local_bind, observed)` so multi-homed hosts don't cross-pollute
  observations from different interfaces. The cache is reset on
  process restart — a fresh node re-discovers its current address
  from live connections rather than trusting stale state.

- **Bridge task split + bounded forwarder channels** — the DHT bridge
  previously ran `find_closest_nodes_network` inline inside the select
  loop on the relay branch, blocking the peer-address-update branch
  for the duration of the iterative lookup; the underlying forwarder
  mpsc channels were `unbounded_channel` so the queue could grow
  without limit during the stall. Detached the lookup + publish into
  its own spawned task per relay event so the select loop keeps
  polling, and replaced both forwarder channels with bounded
  `channel(ADDRESS_EVENT_CHANNEL_CAPACITY)` plus a per-event drop
  counter.

- **`dial_addresses()` helper** — loops through every dialable
  address until one succeeds. Replaces single-address dial sites in
  `bootstrap_from_peers`, `spawn_bucket_refresh_task`,
  `trigger_self_lookup`, `find_closest_nodes_network` parallel-query
  block, and `send_dht_request` fallback so a stale NAT binding on
  entry #1 no longer kills the dial when entry #2 would have worked.

## Regression tests

`tests/dht_self_advertisement.rs` adds six `#[tokio::test]` cases that
pin the published self-entry behaviour:

- **Loopback bind path** (single-node, `local: true`):
  - `local_mode_publishes_dialable_loopback_address`
  - `local_mode_published_self_entry_matches_runtime_listen_addrs`
  - `local_mode_never_publishes_port_zero`

- **Wildcard / OBSERVED_ADDRESS path** (`local: false`):
  - `wildcard_bind_with_no_peers_publishes_empty_self_entry` — pins
    the "don't lie when you don't know" contract.
  - `wildcard_bind_publishes_observed_address_after_peer_connection` —
    primary regression test for the OBSERVED_ADDRESS flow.
  - `observed_address_cache_serves_fallback_after_connection_drop` —
    pins the cache fallback behaviour after all live connections drop.

All six FAIL on `rc-2026.4.1` with concrete evidence (e.g.
`[Quic(0.0.0.0:0)]` published as the only address) and PASS here.
`ObservedAddressCache` ships with 9 unit tests covering empty, single,
repeated, frequency, recency tiebreak, NAT-rebinding fallback,
long-quiet fallback, eviction, and re-observation-on-full.

## chore: drop needless struct update in IPDiversityConfig test

Both fields of `IPDiversityConfig` are explicitly set, so the trailing
`..IPDiversityConfig::default()` triggers `clippy::needless_update`
under `cargo clippy --all-targets`. Test code only; no functional
change.

## Verification

- `cargo build --lib`: clean
- `cargo fmt --check`: clean
- `cargo clippy --lib -- -D warnings -D clippy::unwrap_used -D clippy::expect_used`: clean
- `cargo clippy --all-targets -- -D warnings`: clean
- `cargo test --lib`: 282 passed, 0 failed
- `cargo test --test dht_self_advertisement`: 6/6 passed
- `cargo test --test two_node_messaging`: 7/7 passed
- `cargo test --test node_lifecycle`: 17/17 passed

This is one of two PRs that split the original PR #70, which bundled
this NAT-traversal hardening with two 1000-node scale fixes in the
DHT touch path and the inbound message dispatcher. The other PR
covers the 1000-node perf work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread src/transport_handle.rs
Comment on lines +448 to +459
pub fn observed_external_addresses(&self) -> Vec<SocketAddr> {
let mut out: Vec<SocketAddr> = self.dual_node.get_observed_external_addresses();
let cached = self
.observed_address_cache
.lock()
.most_frequent_recent_per_local_bind();
for addr in cached {
if !out.contains(&addr) {
out.push(addr);
}
}
out

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Cache merged unconditionally alongside live observations

The doc comment says the cache is used "for each (local_bind, observed) partition that has no live observation yet", implying it only fills the gap when a bind has no live data. The implementation deduplicates by address value only — not by local bind — so if a bind currently has a live observation but the cache also holds a stale address for that same bind (e.g. after a NAT rebinding), both addresses are published. Peers reaching dial_addresses will try the stale one and fail, then succeed on the fresh one, so there is no correctness impact. However, the doc and the code should be aligned: if the per-bind fallback intent is correct, the merge loop could skip cache entries whose local bind already has a live entry in out.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/transport_handle.rs
Line: 448-459

Comment:
**Cache merged unconditionally alongside live observations**

The doc comment says the cache is used "for each `(local_bind, observed)` partition that has **no live observation yet**", implying it only fills the gap when a bind has no live data. The implementation deduplicates by address value only — not by local bind — so if a bind currently has a live observation but the cache also holds a stale address for that same bind (e.g. after a NAT rebinding), both addresses are published. Peers reaching `dial_addresses` will try the stale one and fail, then succeed on the fresh one, so there is no correctness impact. However, the doc and the code should be aligned: if the per-bind fallback intent is correct, the merge loop could skip cache entries whose local bind already has a live entry in `out`.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant