Skip to content

Commit 5b1ea56

Browse files
authored
Merge pull request #136 from mickvandijke/feat/relay-canary-gate-rc-2026.6.2
feat(reachability): gate relay publication with canary quorum
2 parents 043391c + ab9da8f commit 5b1ea56

18 files changed

Lines changed: 2941 additions & 483 deletions

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ once_cell = "1.21"
6464
dashmap = "6"
6565

6666
# Networking
67-
saorsa-transport = "0.35.3"
67+
saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", branch = "fix/pr136-provisional-relay" }
6868

6969
# Core-specific dependencies
7070
dirs = "6.0"

docs/ROUTING_TABLE_DESIGN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,10 @@ The routing table MUST emit events on membership changes to allow consumers to r
396396
|---|---|
397397
| `PeerAdded(PeerId)` | New peer inserted into routing table |
398398
| `PeerRemoved(PeerId)` | Peer evicted, blocked, or departed |
399-
| `KClosestPeersChanged { old, new }` | Composition of the `K_BUCKET_SIZE`-closest peers to self changed |
399+
| `KClosestPeersChanged { old, new, added, removed }` | Composition of the `K_BUCKET_SIZE`-closest peers to self changed |
400400
| `BootstrapComplete { num_peers }` | Bootstrap process finished (routing table stabilized or timeout reached) |
401401

402-
`KClosestPeersChanged` is emitted when a routing table admission attempt causes the set of `K_BUCKET_SIZE` nearest peers to self to differ from the pre-admission set. The routing table snapshots the K-closest set before each admission attempt and compares after; the event carries both the old and new sets. This fires at most once per admission attempt — the entire admission (including sub-mutations like swaps and stale evictions) is treated as one logical operation.
402+
`KClosestPeersChanged` is emitted when a routing table admission attempt causes the set of `K_BUCKET_SIZE` nearest peers to self to differ from the pre-admission set. The routing table snapshots the K-closest set before each admission attempt and compares after; the network event carries the old and new sets plus their precomputed `added` and `removed` differences. This fires at most once per admission attempt — the entire admission (including sub-mutations like swaps and stale evictions) is treated as one logical operation.
403403

404404
`BootstrapComplete` is emitted once per bootstrap cycle — both at initial startup and on each auto re-bootstrap (Section 10.3). It fires when the bootstrap lookups for that cycle complete — specifically, after the self-lookup and bucket refresh operations (Section 11) have all terminated. The event carries the total number of peers in the routing table at the time of emission. Consumers (e.g., replication, application-layer services) SHOULD wait for this event before initiating operations that depend on a populated routing table.
405405

docs/adr/ADR-014-proactive-relay-first-nat-traversal.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
Proposed
5+
Superseded by [ADR-016](./ADR-016-canary-gated-proactive-relays.md)
66

77
## Context
88

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# ADR-016: Canary-Gated Proactive Relays
2+
3+
## Status
4+
5+
Accepted
6+
7+
Supersedes [ADR-014](./ADR-014-proactive-relay-first-nat-traversal.md).
8+
9+
## Context
10+
11+
ADR-014 described an earlier relay-first design. The implementation evolved in
12+
several important ways:
13+
14+
- a relay allocation must not be published merely because the requesting node
15+
can establish it;
16+
- the canary protocol is necessarily a bounded public dial service, so its
17+
abuse controls must not depend on a requester-supplied proof that the
18+
requester can mint for itself;
19+
- a canary dial must not reuse or disconnect a live application connection;
20+
- relay state changes and network teardown must not block one another behind a
21+
lifecycle mutex; and
22+
- close-group churn is a replication concern, not evidence that a healthy
23+
relay should be replaced.
24+
25+
This ADR records the implemented model and replaces the contradictory
26+
thresholds, capacity limits, and maintenance behavior in ADR-014.
27+
28+
## Decision
29+
30+
### Relay acquisition and publication
31+
32+
Every non-client node may walk suitable routing-table peers and prepare one
33+
proactive MASQUE allocation. Preparation creates a dedicated relay control
34+
connection and a separate Quinn endpoint, but the allocation remains
35+
provisional and absent from the node's published address set.
36+
37+
The target asks three randomized, non-close witnesses to probe the provisional
38+
address using the unreleased `relay-canary-v1` request/response protocol. The
39+
request contains the target peer ID, public relay socket, and an hourly witness
40+
eligibility epoch. Its ordinary signed transport envelope must authenticate as
41+
the same target peer ID, so a node can request a probe only for its own
42+
identity.
43+
44+
Witness eligibility is deterministic and independent of the requested
45+
address. A domain-separated BLAKE3 hash of the target peer ID, witness peer ID,
46+
and eligibility epoch must have its first two bits clear. This assigns roughly
47+
one quarter of witnesses to a target for an hour and prevents a requester from
48+
recruiting the whole routing table for one identity. A witness accepts the
49+
current or immediately previous epoch to tolerate an hour boundary; requesters
50+
use the current epoch and filter candidates before selecting three randomized,
51+
non-close witnesses.
52+
53+
After validating the request, an eligible witness opens a fresh one-shot
54+
authenticated QUIC connection which never enters ordinary peer, address, or
55+
dial-deduplication maps. The witness closes only that owned probe connection.
56+
The wire response is deliberately coarse: success, failure, or rate limited.
57+
Detailed dial and identity failures remain local debug information rather than
58+
turning the protocol into a richer port-scanning oracle.
59+
60+
Admission requires three positive witness results. One explicit
61+
canary-capable failure rejects the provisional allocation.
62+
63+
During the mixed-version rollout, a request that was successfully sent to a
64+
selected witness but receives no canary-protocol response before the response
65+
deadline counts as an assumed positive result. This preserves the pre-canary
66+
behavior until that witness upgrades. This compatibility rule is deliberately
67+
limited to the response stage: failure to connect to a selected witness and an
68+
explicit rate-limit response remain ineligible; neither is promoted to
69+
success. An assumed result is logged separately from a confirmed probe.
70+
71+
The implementation still requires three selectable non-close witnesses and
72+
intentionally has no sparse-network threshold or replacement sampling.
73+
74+
Canary work has its own four-permit concurrency semaphore and hourly limits.
75+
Before starting a dial, each witness consumes all of these budgets:
76+
77+
- at most 4 probes per authenticated target peer ID;
78+
- at most 20 probes per transport source IPv4 address or IPv6 `/64` prefix;
79+
- at most 4 probes per destination socket;
80+
- at most 20 probes per destination IP address; and
81+
- at most 60 probes in total on that witness.
82+
83+
The limits are intentionally redundant. Ephemeral identities cannot bypass the
84+
source-network or witness-wide limits, while rotating destination ports cannot
85+
bypass the destination-IP limit. The source IP is taken from the authenticated
86+
transport connection, never from request data. Validation and budgets happen
87+
before any canary-triggered network acquisition. Canary work does not consume
88+
the general DHT handler budget and does not retry a failed cold dial. Replayed
89+
requests consume the same hourly budgets as new requests.
90+
91+
### Established-relay maintenance
92+
93+
The node polls local tunnel health every five seconds and repeats independent
94+
third-party canary verification every two hours, with deterministic initial
95+
jitter spread across a full interval. The slower external cadence is
96+
intentional: admission already proved reachability, tunnel loss is detected by
97+
the cheap local health path, and every canary round creates three witness
98+
requests plus three fresh PQC relay handshakes. The two-hour interval avoids
99+
continuous fleet-wide dial pressure and remains comfortably inside the hourly
100+
witness budgets.
101+
102+
Maintenance accepts two positive witness results, including temporary
103+
assumed-positive legacy results, and rejects on two explicit canary-capable
104+
failures. An inconclusive maintenance round retains the relay and waits for the
105+
ordinary two-hour interval; immediately retrying unavailable witnesses would
106+
amplify a partial outage. A rejected round withdraws the relay immediately; it
107+
is not confirmed by a second round.
108+
109+
Tunnel death, explicit canary rejection, or an explicit trust/quality decision
110+
may replace a relay. A healthy established relay remains in place when the
111+
K-closest set changes. Close-group changes only publish the current
112+
authoritative address set to peers newly entering the replication set.
113+
114+
### Publication and teardown ordering
115+
116+
On relay loss, local published-relay state is cleared first. DHT withdrawal and
117+
transport teardown then run concurrently, so neither waits for the other.
118+
Relay allocation resources are owned by a small lifecycle actor. The actor
119+
serializes short state transitions; relay acquisition and teardown awaits run
120+
outside it. Generation numbers prevent a late acquisition or canary verdict
121+
from acting on a superseding allocation. Every owned allocation carries a
122+
synchronous cleanup guard: if a lifecycle reply or graceful teardown future is
123+
cancelled, dropping the owner closes the endpoint, aborts the tunnel tasks, and
124+
removes the matching relay session.
125+
126+
Candidate `ADD_ADDRESS` advertisements are allowed while an allocation is
127+
absent or provisional and suppressed only after the relay reaches the
128+
`Published` state. Relay publication itself is owned by the authenticated,
129+
sequenced DHT address-set path. Saorsa-core therefore does not forward or drain
130+
transport `PeerAddressUpdated` events.
131+
132+
### Capacity and address-family ownership
133+
134+
Public relay servers accept at most four active relay clients. A prepared
135+
allocation must preserve the address family of the selected relay path. A
136+
mismatch is aborted through the same transport-stack owner that created it and
137+
is returned as an error; later publication and teardown never redispatch an
138+
allocation to a different stack.
139+
140+
### Packaging
141+
142+
Dependency versioning and release packaging are managed separately by the
143+
release process and are not decided here.
144+
145+
## Consequences
146+
147+
- Published relay addresses have independent external reachability evidence
148+
when selected witnesses support canaries; during mixed-version rollout an
149+
unsupported selected witness temporarily contributes assumed-positive
150+
compatibility credit.
151+
- Canary traffic cannot tear down shared application/DHT connections.
152+
- A malicious node can ask eligible witnesses to attempt a connection to an
153+
unrelated public address, but the authenticated-self rule, deterministic
154+
witness assignment, hourly peer/source/destination/global limits, and
155+
isolated concurrency budget strictly bound that service. Canary work cannot
156+
exhaust the general handler pool.
157+
- Healthy relay sessions avoid churn when routing-table responsibility moves.
158+
- DHT withdrawal begins without waiting for local transport shutdown.
159+
- Mixed-version witnesses do not block admission merely because they lack the
160+
canary protocol; their missing protocol response is temporarily counted as
161+
positive.
162+
- Routing tables with fewer than three selectable non-close witnesses can
163+
still produce inconclusive admission.
164+
- Canary requests carry no allocation receipt. This removes untrusted
165+
self-signed proof material and several kilobytes of redundant ML-DSA key and
166+
signature data from every request.

docs/adr/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ An Architecture Decision Record (ADR) is a document that captures an important a
4949
|-----|-------|--------|---------|
5050
| [ADR-013](./ADR-013-no-offline-delivery-v1.md) | No Offline Message Delivery (v1) | Accepted | 1-hour TTL limit without extended offline delivery (future reconsideration) |
5151

52+
### Reachability
53+
54+
| ADR | Title | Status | Summary |
55+
|-----|-------|--------|---------|
56+
| [ADR-014](./ADR-014-proactive-relay-first-nat-traversal.md) | Proactive Relay-First NAT Traversal | Superseded | Initial proactive-relay design replaced by canary-gated publication |
57+
| [ADR-016](./ADR-016-canary-gated-proactive-relays.md) | Canary-Gated Proactive Relays | Accepted | Signed allocation receipts, isolated witnesses, stable relay lifecycle, and sequenced publication |
58+
5259
### Operations
5360

5461
| ADR | Title | Status | Summary |

src/adaptive/dht.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,21 @@ impl AdaptiveDHT {
276276
.peer_addresses_for_dial_typed(peer_id)
277277
.await
278278
}
279+
280+
/// Ensure the shared DHT dial coordinator has an authenticated channel.
281+
///
282+
/// Keeping application reconnects on the same path as iterative lookups
283+
/// means both callers share address-failure suppression and never create
284+
/// independent retry loops against a known-bad relay.
285+
pub(crate) async fn ensure_peer_channel(
286+
&self,
287+
peer_id: &PeerId,
288+
candidates: &[(MultiAddr, AddressType)],
289+
) -> Result<()> {
290+
self.dht_manager
291+
.ensure_peer_channel(peer_id, candidates)
292+
.await
293+
}
279294
}
280295

281296
#[cfg(test)]

0 commit comments

Comments
 (0)