feat(peer): harden reflexive discovery (cross-family reject, stun host, mirror-advertise control) - #569
Conversation
Four items: cross-family STUN reject, standing-fallback doc fix, stun.<relay-host> preference, control.config.setMirrorAdvertiseUrls. See dispatch for full spec.
Four items in one PR (root Cargo.toml bump dirties every sibling PR in this repo, so batch rather than split): 1. Reject a cross-family STUN answer and fall through the discovery ladder. The measured dig-relay defect (fixed server-side in 0.19.7) answered an IPv4 caller with the load balancer's own IPv6 address -- a well-formed Binding transaction naming something other than the querying socket. `StunPlan::discover_reflexive` now discards a cross-family answer and keeps walking, exactly as a non-answering server falls through. 2. Correct the "INTERIM" framing on the public-STUN fallback tier: it is a standing, deliberate last resort (a single STUN source, even the relay, is a single point of trust), not scaffolding to delete once relay STUN works. dig_ecosystem#3198 stays open (agreement between two sources). 3. Prefer a dedicated `stun.<relay-host>` DNS name over the bare relay host, derived from whatever relay endpoint the operator configured (never hardcoded -- dig-relay is GPL-2.0 and self-hosted). `stun_server_from_relay` delegates to `stun_servers_from_relay` (already true before this change), so the two derivations can never disagree about which host to try first. 4. Serve `control.config.setMirrorAdvertiseUrls` (dig-node-control-interface 0.33.0) plus the additive `ConfigResult.mirror_advertise` field. The override is persisted to config.json and consulted by `advertised_urls_from_env`'s new precedence (env > persisted > none), so `requires_restart: true` is a genuine promise: nothing can rewrite a running process's environment, but the operator half of the mirror task is captured once at bring-up and a restart re-reads it. Also: bump dig-ip 0.1.2->0.1.3 and dig-nat 0.21.0->0.21.1 (touched crates' dig-*/chia-* deps to latest, CLAUDE.md #2.4b). SPEC.md #19.2 updated for items 1-3. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The CLI-parity gate (cli_covers_every_node_control_method, #426) failed on control.config.setMirrorAdvertiseUrls -- every control.* method the node resolves needs a dign verb or the CLI silently falls behind the WS surface the extension drives. Adds ControlAction::ConfigSetMirrorAdvertiseUrls, its wire_params/method/pretty-print arms, and the clap subcommand (zero arguments clears the override, matching the wire's None-clears contract). Co-Authored-By: Claude <noreply@anthropic.com>
no_help_text_exposes_an_internal_ticket_number caught it: the new config set-mirror-advertise-urls subcommand's doc comment (rendered verbatim by clap) named dig-node#570. Reworded both the clap-rendered doc and its clap-agnostic ControlAction sibling. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…54.84 dig-node-control-interface 0.32 published control.wallet.resetCoinDb (ControlMethod::WalletResetCoinDb), so the conformance gate's the_unpublished_list_still_describes_real_drift test correctly flags the now-stale exemption. The published method's own requires_master_token() returns false, so removing the local exemption changes nothing about the tier the DIG App relies on for this call -- the paired tier is now delegated to the published contract instead of carved out locally by name, which is the exact end state the exemption list documents itself as working toward. Also bumps the workspace version to 0.254.84: main had caught up to 0.254.83 while this branch sat unbumped, failing the version-increment gate. Co-Authored-By: Claude <noreply@anthropic.com>
|
IN PROGRESS — not the verdict. Auditing head e966e35. net.rs reviewed (STUN cross-family + stun. derivation):
Still to verify: control.rs (setMirrorAdvertiseUrls validation, Some(vec![])/None/Some(non-empty) semantics, requires_restart honesty), the breaking-change marker, and the fmt/string-continuation trap. |
|
IN PROGRESS -- not the verdict. Continuing audit of head e966e35. control.rs / advertise.rs / control_cli.rs / entrypoint.rs reviewed:
Next: composing final verdict. |
SECURITY VERDICT: PASSHead audited: requires_restart -- explicit answer for the dig-app lane
Per-item findings
Also-items
One non-gating defense-in-depth note
What I verified by reading vs runningEverything above was verified by reading the diff, the full surrounding source at the PR head, and the referenced No LIVE vulnerability found. PASS. |
|
I removed the What happenedThe security gate reported the The lane's own reasoning, which I did not have in front of me, was better than mine. It re-verified the marker against the SPEC diff and kept it deliberately, on two real changed defaults:
CLAUDE.md §2.4 defines major as "removed/renamed API, a wire/format/schema break, or a changed default". Both of those are changed defaults. The lane applied the contract correctly; the gate applied a narrower test (wire/API only) and I acted on the narrower one. Why the version number could not have carried it anyway — and why that makes the marker MORE important, not lessdig-node cannot express a breaking change in its version. Per #522, So under the current scheme every release is a patch bump, and the version number is structurally incapable of signalling a breaking change. That makes the Conventional-Commit Correcting my own stale note while I am here: I have been repeating that "0.255.x is a one-way door" as a live constraint. It was true before #522 and is not the operative concern now — the counter moved to patch, which has 65535 slots. The live constraint is the opposite one: do not bump the minor, because that consumes the last slot and re-breaks packaging. What I am NOT doingNot rewriting What must happen insteadThe stable release notes for This is recorded on the epic's release step too, so it is not lost between here and the release. |
Four items in the reflexive-discovery family, batched into one PR/version bump per
CLAUDE.md's PR-batching rule (every root Cargo.toml bump dirties every sibling PR).
(
crates/dig-node-core/src/seams/dig_peer/net.rs).stun.<relay-host>, falling back to the bare relay host(
prefer_dedicated_stun_host, same file).control.config.setMirrorAdvertiseUrls(dig-node-control-interface 0.33.0adopted;
crates/dig-node-service/src/control.rs,control_cli.rs, a newdign config set-mirror-advertise-urlsverb).requires_restart— verifiedtrueis the honest answer, and WHYNot because of the env var alone (the persisted form works fine live for
control.config.get) but because of a real structural fact:server.rs's mirrorlifecycle task captures
configured_operator_urls()in a local binding once,before its pass loop starts (
server.rs:2776, comment: "read ONCE for the life ofthe task ... re-reading it per pass would buy nothing"). No in-process call can
rewrite that captured value; only a restart re-enters the function. The write IS
genuinely persisted to
config.json(set_mirror_advertise_urls_override/read_mirror_advertise_urls_override_from,control.rs:921-955) and picked up byadvertised_urls_from_env's env-else-persisted precedence on the NEXT start, so therequires_restart: truepromise is real, not a stand-in for unimplemented work —same class of honest-
trueasconfig.setUpstream's existing answer.Breaking-change (
!) justification — re-verified, not just carried overTwo real behaviour changes justify it, both "changed defaults" under this repo's own
SemVer convention (§2.4):
that previously got a (possibly wrong) non-null address from a cross-family answer
can now get
NoPublicAddress/fall through instead. A prior test assertingfirst-answer-wins had to be updated for exactly this reason — see below.
to a three-tier plan (operator override -> relay's
stun.<host>/bare host ->public Google/Cloudflare fallback), which can change which address a node ends up
advertising.
Fixed since the last push (this session)
0.254.83, equal tomain(main hadcaught up). Bumped root
Cargo.tomlto0.254.84,cargo update -w --offlineto sync
Cargo.lock(onlydig-node-servicemoved, as expected — it is the onecrate on
version.workspace = true).a commit message (both commits only carried a non-fatal
footer-leading-blankwarning). Retitled to 98 chars, keeping the
!(justified above).control_contract_conformance::the_unpublished_list_still_ describes_real_driftfailed:control.wallet.resetCoinDb is published now -- remove it from KNOWN_UNPUBLISHED. Verified against the real crate source(
dig-node-control-interface0.32+ declaresControlMethod::WalletResetCoinDb)that this is genuine catch-up drift from the 0.33 bump, not something this PR's
own logic caused. Removed the entry (
control.rs:337) and rewrote the stale doccomment. Confirmed safe by tracing every reference to
resetCoinDb(15 hits) andthe tier-delegation chain:
requires_master_token_givennow delegates toControlMethod::WalletResetCoinDb.requires_master_token(), which the cratereturns
falsefor — the same PAIRED tier the exemption used to grant by name,now delegated to the contract instead of carved out locally (exactly the end
state the exemption's own doc comment said to reach). No count-based assertion
on the list's length exists anywhere (grepped).
Blast radius checked
Single-array-literal edit (
KNOWN_UNPUBLISHED_CONTROL_METHODS) plus its doc comment.Traced all 15 references to
control.wallet.resetCoinDbacrosscontrol.rs,control_cli.rs,entrypoint.rsby hand (no fresh gitnexus index built for thisworktree -- would have contended with the running test compile for CPU; grep-based
trace was exhaustive for a change this narrow). No other file references
KNOWN_UNPUBLISHED_CONTROL_METHODS's contents or length outsidecontrol_contract_conformance.rsandcontrol.rs's own test module, both read infull.
Verified with the exact CI commands (all local, all after the fixes above)
cargo fmt --all -- --check-- clean, exit 0.cargo clippy --workspace --all-targets --all-features --locked -- -D warnings--clean, exit 0 (1m30s).
cargo test --workspace --all-targets --all-features --locked-- exit 0,every
test result:line across every binary readsokwith 0 failed(~3132 passed total, matching CI's ~3142 baseline; this run used
cargo testrather than nextest so the per-binary grouping differs slightly, but the count
is real and every binary's own summary was checked, not just the final exit
code). Confirmed by name:
test the_unpublished_list_still_describes_real_drift ... ok, alongside its 3 sibling conformance tests in the same binary, allok.(First attempt at full workspace parallelism OOM'd this box mid-link --
rustc-LLVM ERROR: out of memory/ undersized pagefile, zero tests executed,exit 101 -- an environment artifact of this machine's 32-core default
parallelism against many heavy debug-info links at once, not a code defect.
Retried with
--jobs 4; clean.)Not in scope (per the brief)
one's.
setMirrorAdvertiseUrls-- feat(mirror): derive the advertised mirror URL from this node's reflexive peer address #562 deliberately lets an operator's LAN address staypublishable while a derived one must be global unicast; unchanged here.
closes=NONE-- the parent is a super-repo issue this submodule PR structurallycannot close; closed by hand once merged.