Skip to content

Commit 030d104

Browse files
feat(dig-node): connect plumbing — GossipHandle + peers.connect + peers --json (#929)
Turn a discovered peer into a COUNTED, machine-checkable, RPC-reachable connected peer. - Retain the live dig-gossip GossipHandle on Node (set once by the peer-network bring-up) so the control surface can act on the pool. - control.peers.connect <peer_id|addr>: dial via the retained handle; deterministic success/failure; "no peer network" on the FFI path. Delegated control method + CLI-parity coverage. - control.peerStatus now emits a per-peer array peers[] of {peer_id, address, via, direction} so the A<->B mutual-peer_id proof is machine-checkable; CLI renders it IPv6-first. - Peer<->peer RPC over mTLS proven against the REAL node dispatch (read served, control rejected -32601). Deferred (release-first, dig-nat 0.3.0 blocked by dig-dht/dig-download/ dig-peer-selector pinning dig-nat ^0.2): consuming dig-gossip 0.3.0 (B1/B2), the real relay-transport 'via', and the Register.listen_addrs advertisement. via is 'direct' for all pooled peers (accurate on 0.2.1). See DEVELOPMENT_LOG + SPEC 19.8. Version: dig-node 0.39.0 -> 0.40.0 (minor: new control method + status field). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6b98bc1 commit 030d104

10 files changed

Lines changed: 352 additions & 78 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 2 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
@@ -31,7 +31,7 @@ edition = "2021"
3131
# the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a
3232
# release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet)
3333
# keep their own independent versions — only the released binary tracks the workspace version.
34-
version = "0.39.0"
34+
version = "0.40.0"
3535

3636
# Release hardening, matching digstore: keep integer-overflow checks ON in release.
3737
# The node parses untrusted serialized input and does offset/length arithmetic over

DEVELOPMENT_LOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,16 @@ CLI path (not via a native package) needs the `sc config`/`sc qc` dance.
169169
no-follow `symlink_metadata` + `FILE_ATTRIBUTE_REPARSE_POINT` (catches junctions, not just
170170
symlinks). Mirrors dig-dns's `ensure_prefix_root_owned_not_writable` (#701).
171171

172-
<!-- WU5 connect plumbing stub (#929) -->
172+
- **dig-gossip 0.3.0 needs dig-nat 0.3.0, which the crates.io peer stack blocks (WU5 #929).**
173+
dig-gossip v0.3.0's code reads `dig_nat::wire::RelayPeerInfo.addresses` — a field that exists ONLY in
174+
dig-nat 0.3.0 (its `>=0.2,<0.4` req is looser than the code). But dig-dht, dig-download, and
175+
dig-peer-selector (crates.io 0.1.2 AND their git `main`) still pin dig-nat `^0.2`, and the workspace
176+
`[patch.crates-io] dig-nat = { git }` can only redirect onto a version those consumers accept — 0.3.0
177+
does NOT satisfy `^0.2`, so the graph forks into two incompatible dig-nat instances (crates.io 0.2.0
178+
vs git 0.3.0) and fails to compile (`PeerId`/`Contact` type mismatch at the dht.rs seam). ADOPTION
179+
ORDER: republish dig-dht + dig-download + dig-peer-selector accepting dig-nat `>=0.2,<0.4` → bump
180+
dig-nat to 0.3.0 across the graph → then dig-gossip 0.3.0 is consumable (unlocking B1 dialable-fold,
181+
B2 relay-transport connected-count + `connected_pool_peers_with_via`, and the `Register.listen_addrs`
182+
advertisement). Until then the node stays on dig-gossip 0.2.1 (`connect_to` + `connected_pool_peers`
183+
exist there), the per-peer `via` is always `"direct"` (0.2.1 has no relay-transport peer kind), and
184+
`listen_addrs` is not advertised in the relay Register.

SPEC.md

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -761,8 +761,8 @@ For the current node library (§2.2) the catalogue is:
761761
`dig.getContent`), `dig.getProof`, `dig.listCapsules`.
762762
- **shell**: `rpc.discover`.
763763
- **control**: the operator `control.*` methods of §7.4, plus the node-owned control methods the
764-
shell delegates to the node (`control.peerStatus`, `control.subscribe`, `control.unsubscribe`,
765-
`control.listSubscriptions`).
764+
shell delegates to the node (`control.peerStatus`, `control.peers.connect`, `control.subscribe`,
765+
`control.unsubscribe`, `control.listSubscriptions`).
766766

767767
Param/result schemas for the `dig.*`/`cache.*` methods are owned by the digstore dig RPC and
768768
published on docs.dig.net (Protocol → the L7 read/RPC pages); this repo's OpenRPC document is a
@@ -1365,18 +1365,25 @@ reachable from a CLI verb, so a new node control method cannot ship without a CL
13651365
`peers` reaches parity with the extension's peer surface (`src/features/peers/peersApi.ts`):
13661366

13671367
- `peers [list]``control.peerStatus` — the live peer status: running flag, connected count,
1368-
relay reservation, and (when a newer node fills the optional field) the per-peer list. Peer
1368+
relay reservation, and a **per-peer array** `peers[]`, each element
1369+
`{ peer_id, address, via, direction }` where `via ∈ {"direct","relay"}` (transport; currently always
1370+
`"direct"` — the connected pool admits only directly-dialed peers, and `"relay"` is a
1371+
forward-compatible value a later dig-gossip upgrade fills) and `direction ∈ {"outbound","inbound"}`.
1372+
The array is present whenever a peer network is running and
1373+
omitted (count only) on the in-process FFI path / before bring-up. The per-peer `peer_id` is the
1374+
machine-checkable proof of a mutual A↔B connection (each side lists the other's `peer_id`). Peer
13691375
addresses are displayed **IPv6-first, IPv4 second** per the ecosystem §5.2 address-family policy.
1370-
- `peers connect <peer>``control.peers.connect`; `peers disconnect <peer>`
1371-
`control.peers.disconnect`; `peers ban <peer> --state <ban|blacklist|none>`
1372-
`control.peers.setBan`; `peers pool-config --max-connections <n>``control.peers.setPoolConfig`.
1373-
1374-
The management verbs + the extended per-peer `control.peerStatus` payload are a **known node-side
1375-
gap** (the same gap the extension documents): the node today implements only the running flag +
1376-
connected count. Until it ships the management RPCs the `list` view degrades honestly (count only)
1377-
and the management verbs surface the node's METHOD_NOT_FOUND. The CLI verbs exist now so the surface
1378-
reaches parity and lights up with NO CLI change once the node implements them (tracked cross-repo
1379-
follow-up).
1376+
- `peers connect <peer>``control.peers.connect` — dial a peer via the live gossip pool. `peer` is
1377+
EITHER a dialable socket address (`host:port`, IPv6 in brackets) dialed over the full NAT ladder, OR
1378+
a `peer_id` (64-hex) honoured only if already connected (idempotent). Returns
1379+
`{ connected: true, peer_id }`; a bare unknown `peer_id`, a malformed argument, a dial failure, or no
1380+
running peer network each return a deterministic control error. CONTROL-plane — reachable only from
1381+
the loopback admin / in-process dispatch, NEVER over the mTLS peer surface.
1382+
- `peers disconnect <peer>``control.peers.disconnect`; `peers ban <peer> --state <ban|blacklist|none>`
1383+
`control.peers.setBan`; `peers pool-config --max-connections <n>``control.peers.setPoolConfig`
1384+
remain a **known node-side gap**: until the node ships those RPCs those verbs surface the node's
1385+
METHOD_NOT_FOUND. The CLI verbs exist now so the surface reaches parity and lights up with NO CLI
1386+
change once the node implements them.
13801387

13811388
### 8.5. `open` — the OS scheme handler (#389)
13821389

@@ -2797,6 +2804,21 @@ Exercising the connected pool end-to-end is gated on the network-genesis bring-u
27972804
placeholder genesis is rejected by `GossipService::start`); these behaviors are unit-tested
27982805
independently of a live pool.
27992806

2807+
### 19.8. Relay reservation — control dial + advertised listen candidates (partial)
2808+
2809+
The node holds ONE persistent relay reservation (dig-nat `run_relay_connection`) sharing a single
2810+
`Arc<RelayStatus>` with the gossip pool. Advertising the node's real gossip listen candidates in the
2811+
RLY-001 `Register` message (`listen_addrs`, so the relay's reflexive substitution can hand another peer
2812+
a DIALABLE candidate) is a **release-first follow-up**: it requires dig-nat 0.3.0's `Register.listen_addrs`
2813+
field, and dig-nat 0.3.0 can only be adopted once dig-dht, dig-download, and dig-peer-selector are
2814+
republished accepting dig-nat `>=0.2,<0.4` (they currently pin `^0.2`, so the graph cannot unify at
2815+
dig-nat 0.3.0). Until then the reservation registers without advertised candidates.
2816+
2817+
The node retains the live `GossipHandle` for the pool so the CONTROL surface can act on it:
2818+
`control.peers.connect` dials a discovered/known peer into the connected pool, and `control.peerStatus`
2819+
enumerates the pool as the per-peer array (§8.7). The in-process FFI host runs no peer network, so it
2820+
retains no handle — connect reports "no peer network" and the peer array is omitted.
2821+
28002822
## 20. Logging — structured JSONL file + human stderr (#553)
28012823

28022824
The node adopts the shared `dig-logging` building block (`dig-logging` crate, `dig_ecosystem` #547),

crates/dig-node-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "dig-node-core"
3-
version = "0.7.0"
3+
version = "0.8.0"
44
edition = "2021"
55
license = "GPL-2.0-only"
66
description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works."

crates/dig-node-core/src/lib.rs

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@ pub struct Node {
294294
/// unaffected (no self-keep-alive cycle). NEVER set on the FFI path, so a backfill there upgrades
295295
/// to `None` and is a no-op (the browser consumer has no peer network to pull a capsule from).
296296
self_ref: OnceLock<std::sync::Weak<Node>>,
297+
/// The live [`dig_gossip::GossipHandle`] for the node's connected peer pool, retained by the
298+
/// standalone peer-network bring-up ([`peer::run_peer_network`]) so the CONTROL surface can act on
299+
/// the pool: dial a peer (`control.peers.connect`) and enumerate the connected peers per-peer
300+
/// (`control.peerStatus` → the `peers` array). Set ONCE via [`Node::set_gossip_handle`]; NEVER set
301+
/// on the in-process FFI path (the browser is a pure consumer with no pool), where the connect verb
302+
/// reports "no peer network" and the peer list is empty.
303+
gossip: OnceLock<dig_gossip::GossipHandle>,
297304
/// The outgoing-bandwidth throttle (dig_ecosystem issue #30): tracks bytes served this second
298305
/// against a configurable cap (`DIG_NODE_MAX_OUTGOING_BYTES_PER_SEC`, unlimited by default) so
299306
/// the serve path can redirect an over-budget request to a known alternate holder instead of
@@ -324,6 +331,19 @@ impl Node {
324331
let _ = self.inventory_refresher.set(refresher);
325332
}
326333

334+
/// Retain the live gossip pool handle (the standalone peer-network bring-up calls this once with
335+
/// the [`dig_gossip::GossipHandle`] it starts; the FFI path never does). Idempotent — a second
336+
/// install is ignored. Enables the control surface to dial peers + enumerate the connected pool.
337+
pub(crate) fn set_gossip_handle(&self, handle: dig_gossip::GossipHandle) {
338+
let _ = self.gossip.set(handle);
339+
}
340+
341+
/// The live gossip pool handle, if the peer network is running. `None` on the FFI path (no pool)
342+
/// and before bring-up — callers degrade honestly (empty peer list; "no peer network" on connect).
343+
pub(crate) fn gossip_handle(&self) -> Option<&dig_gossip::GossipHandle> {
344+
self.gossip.get()
345+
}
346+
327347
/// Refresh the node's DHT provider records against its current inventory, if a peer network is
328348
/// running (SPEC §14.1). A no-op on the FFI path (no hook installed) or before bring-up.
329349
pub(crate) async fn refresh_dht_inventory(&self) {
@@ -2580,8 +2600,35 @@ pub async fn handle_rpc(node: &Node, req: Value) -> Value {
25802600
if method == "control.peerStatus" {
25812601
let endpoint = peer::relay_url_from_env();
25822602
let network_id = peer::network_id_from_env();
2583-
return json!({"jsonrpc":"2.0","id":id,
2584-
"result": node.peer_status.snapshot_json(&endpoint, &network_id)});
2603+
let mut snapshot = node.peer_status.snapshot_json(&endpoint, &network_id);
2604+
// Attach the per-peer array so the A↔B mutual-connection proof is machine-checkable (each
2605+
// side lists the OTHER's peer_id), not just a count. Sourced from the live pool handle; empty
2606+
// (and omitted-as-`[]`) on the FFI path / before bring-up. See `peer::connected_peers_json`.
2607+
if let Some(handle) = node.gossip_handle() {
2608+
snapshot["peers"] = Value::Array(peer::connected_peers_json(handle));
2609+
}
2610+
return json!({"jsonrpc":"2.0","id":id, "result": snapshot});
2611+
}
2612+
// control.peers.connect — dial a peer by address (or resolve an already-connected peer_id) via the
2613+
// live gossip pool, turning a relay-DISCOVERED peer into a COUNTED, RPC-reachable connected peer
2614+
// (#929). CONTROL-plane: reachable ONLY from the loopback admin / in-process FFI dispatch, NEVER
2615+
// over the mTLS peer surface (absent from `is_peer_reachable_method`). Deterministic success /
2616+
// failure; a no-op "no peer network" on the FFI path (no pool handle retained).
2617+
if method == "control.peers.connect" {
2618+
let params = req.get("params").cloned().unwrap_or(json!({}));
2619+
let peer = params.get("peer").and_then(Value::as_str).unwrap_or("");
2620+
let Some(handle) = node.gossip_handle() else {
2621+
return control_err(
2622+
&id,
2623+
CONTROL_ERROR,
2624+
"no peer network is running on this node",
2625+
);
2626+
};
2627+
return match peer::connect_peer(handle, peer).await {
2628+
Ok(peer_id) => json!({"jsonrpc":"2.0","id":id,
2629+
"result": {"connected": true, "peer_id": peer_id}}),
2630+
Err(e) => control_err(&id, CONTROL_ERROR, &format!("connect failed: {e}")),
2631+
};
25852632
}
25862633
// control.subscribe / control.unsubscribe / control.listSubscriptions (SPEC §6) — manage the
25872634
// node's OWN persisted set of subscribed stores (the stores it actively watches + gap-fills). These
@@ -3023,6 +3070,7 @@ impl Node {
30233070
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
30243071
verification_ledger: verification_ledger::VerificationLedger::new(),
30253072
self_ref: OnceLock::new(),
3073+
gossip: OnceLock::new(),
30263074
outgoing_throttle: bandwidth::OutgoingThrottle::from_env(),
30273075
})
30283076
}
@@ -3086,6 +3134,7 @@ pub(crate) mod test_support {
30863134
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
30873135
verification_ledger: verification_ledger::VerificationLedger::new(),
30883136
self_ref: OnceLock::new(),
3137+
gossip: OnceLock::new(),
30893138
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
30903139
};
30913140
(Arc::new(node), td)
@@ -3293,6 +3342,7 @@ mod tests {
32933342
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
32943343
verification_ledger: verification_ledger::VerificationLedger::new(),
32953344
self_ref: OnceLock::new(),
3345+
gossip: OnceLock::new(),
32963346
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
32973347
};
32983348
(node, td)
@@ -3366,6 +3416,7 @@ mod tests {
33663416
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
33673417
verification_ledger: verification_ledger::VerificationLedger::new(),
33683418
self_ref: OnceLock::new(),
3419+
gossip: OnceLock::new(),
33693420
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
33703421
};
33713422

@@ -3410,6 +3461,7 @@ mod tests {
34103461
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
34113462
verification_ledger: verification_ledger::VerificationLedger::new(),
34123463
self_ref: OnceLock::new(),
3464+
gossip: OnceLock::new(),
34133465
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
34143466
});
34153467

@@ -3495,6 +3547,7 @@ mod tests {
34953547
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
34963548
verification_ledger: verification_ledger::VerificationLedger::new(),
34973549
self_ref: OnceLock::new(),
3550+
gossip: OnceLock::new(),
34983551
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
34993552
});
35003553

@@ -3562,6 +3615,7 @@ mod tests {
35623615
backfilling: std::sync::Mutex::new(std::collections::HashSet::new()),
35633616
verification_ledger: verification_ledger::VerificationLedger::new(),
35643617
self_ref: OnceLock::new(),
3618+
gossip: OnceLock::new(),
35653619
outgoing_throttle: bandwidth::OutgoingThrottle::new(0),
35663620
});
35673621

@@ -3949,6 +4003,54 @@ mod tests {
39494003
std::env::remove_var("DIG_NODE_CACHE");
39504004
}
39514005

4006+
// -- Peer connect + status control RPCs (#929) ------------------------------
4007+
4008+
/// **Proves:** `control.peers.connect` on a node with NO peer network running (the FFI path / before
4009+
/// bring-up — no retained gossip handle) returns a control error, never a panic or a false success.
4010+
/// **Catches:** a connect arm that dereferences an absent pool handle.
4011+
#[test]
4012+
fn peers_connect_without_a_pool_reports_no_peer_network() {
4013+
let rt = tokio::runtime::Builder::new_current_thread()
4014+
.enable_all()
4015+
.build()
4016+
.unwrap();
4017+
let (node, _td) = test_node(None);
4018+
let resp = rt.block_on(handle_rpc(
4019+
&node,
4020+
json!({"jsonrpc":"2.0","id":1,"method":"control.peers.connect",
4021+
"params":{"peer":"[::1]:9444"}}),
4022+
));
4023+
assert!(resp.get("result").is_none());
4024+
assert!(
4025+
resp["error"]["message"]
4026+
.as_str()
4027+
.unwrap_or("")
4028+
.contains("no peer network"),
4029+
"expected a no-peer-network control error: {resp}"
4030+
);
4031+
}
4032+
4033+
/// **Proves:** `control.peerStatus` on a node with no peer network omits the per-peer array (there
4034+
/// is no live pool to enumerate) while still returning the running/relay snapshot.
4035+
/// **Catches:** a status handler that fabricates a `peers` array without a pool handle.
4036+
#[test]
4037+
fn peer_status_without_a_pool_omits_the_per_peer_array() {
4038+
let rt = tokio::runtime::Builder::new_current_thread()
4039+
.enable_all()
4040+
.build()
4041+
.unwrap();
4042+
let (node, _td) = test_node(None);
4043+
let resp = rt.block_on(handle_rpc(
4044+
&node,
4045+
json!({"jsonrpc":"2.0","id":1,"method":"control.peerStatus"}),
4046+
));
4047+
assert!(resp["result"].is_object());
4048+
assert!(
4049+
resp["result"].get("peers").is_none(),
4050+
"no pool handle → no per-peer array: {resp}"
4051+
);
4052+
}
4053+
39524054
// -- Subscription management control RPCs (SPEC §6) -------------------------
39534055
//
39544056
// `control.subscribe` / `control.unsubscribe` / `control.listSubscriptions` manage the node's

0 commit comments

Comments
 (0)