The advert names its address, and the client stops rolling dice on the rest #429

Merged
enricobuehler merged 1 commits from worktree-mdns-primary-addr into main 2026-08-28 19:10:18 +00:00
Owner

Follow-up from the 2026-08-28 field logs (companion to #426): the Windows client streamed over the host's ZeroTier address while both machines shared a LAN, and the LAN reachability probes had succeeded a minute earlier.

What actually happened

Neither end chose ZeroTier. The host registers its mDNS advert with exactly one address — primary_local_ip(), the routed primary — but the host OS's built-in mDNS responder also answers A queries for <host>.local. per interface, with that interface's address. A host on an overlay network whose multicast reaches the client (ZeroTier does, when both ends are joined) therefore contributes its overlay address to the resolved set. Both the desktop and Android clients then picked with HashSet::iter().next() — an arbitrary draw, re-rolled on every re-announce ("a refreshed advert WINS"). A client with no VPN never hears the extra answers, which is why this only bites multi-homed setups.

The fix

punktfunk_core::discovery — one deterministic pick, shared by both clients. Ranking, best wins:

  1. Longest common prefix with any of this machine's unicast addresses — on-link beats routed. This settles LAN-vs-overlay in both directions: on a shared LAN the host's LAN address out-prefixes its overlay address; a client that can only reach the host through the overlay has no interface for the host's LAN address to match, so the overlay address wins there.
  2. The address the host itself declared — new advisory TXT key addr (the IP the advert was registered for, rebuilt on address change like the rest of the record). Settles a multi-NIC host's tie; never overrides reachability, because a declared address we can't see on-link already lost rung 1.
  3. Longest prefix against our default-route source address — old hosts without the TXT still resolve the common ties.
  4. Numerically lowest — pure determinism, so a re-announce can't flap the card.

The prior art here is Bonjour's interface-scoped resolution ("dial the address you heard the advert on"); mdns-sd merges per-interface answers before we see them, so the ranking reconstructs the same preference from the client's own interface set.

Verification

  • punktfunk_core::discovery ranking: 4 unit tests covering the field case, the overlay-only client, the multi-NIC tie, and no-context determinism — green natively.
  • punktfunk-host: container compile + discovery:: tests green (TXT addition).
  • pf-client-core: compiles in the GTK container gate (non-vacuous).
  • punktfunk-client-android: full cargo ndk -t arm64-v8a check green.
  • fmt clean, workspace and Android.
Follow-up from the 2026-08-28 field logs (companion to #426): the Windows client streamed over the host's **ZeroTier** address while both machines shared a LAN, and the LAN reachability probes had succeeded a minute earlier. ## What actually happened Neither end chose ZeroTier. The host registers its mDNS advert with exactly **one** address — `primary_local_ip()`, the routed primary — but the host OS's built-in mDNS responder *also* answers A queries for `<host>.local.` per interface, with that interface's address. A host on an overlay network whose multicast reaches the client (ZeroTier does, when both ends are joined) therefore contributes its overlay address to the resolved set. Both the desktop and Android clients then picked with `HashSet::iter().next()` — an arbitrary draw, re-rolled on every re-announce ("a refreshed advert WINS"). A client with no VPN never hears the extra answers, which is why this only bites multi-homed setups. ## The fix `punktfunk_core::discovery` — one deterministic pick, shared by both clients. Ranking, best wins: 1. **Longest common prefix with any of this machine's unicast addresses** — on-link beats routed. This settles LAN-vs-overlay in *both* directions: on a shared LAN the host's LAN address out-prefixes its overlay address; a client that can only reach the host through the overlay has no interface for the host's LAN address to match, so the overlay address wins there. 2. **The address the host itself declared** — new advisory TXT key `addr` (the IP the advert was registered for, rebuilt on address change like the rest of the record). Settles a multi-NIC host's tie; never overrides reachability, because a declared address we can't see on-link already lost rung 1. 3. Longest prefix against our default-route source address — old hosts without the TXT still resolve the common ties. 4. Numerically lowest — pure determinism, so a re-announce can't flap the card. The prior art here is Bonjour's interface-scoped resolution ("dial the address you heard the advert on"); mdns-sd merges per-interface answers before we see them, so the ranking reconstructs the same preference from the client's own interface set. ## Verification - `punktfunk_core::discovery` ranking: 4 unit tests covering the field case, the overlay-only client, the multi-NIC tie, and no-context determinism — green natively. - `punktfunk-host`: container compile + `discovery::` tests green (TXT addition). - `pf-client-core`: compiles in the GTK container gate (non-vacuous). - `punktfunk-client-android`: full `cargo ndk -t arm64-v8a check` green. - fmt clean, workspace and Android.
enricobuehler added 1 commit 2026-08-28 18:35:02 +00:00
The advert names its address, and the client stops rolling dice on the rest
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 52s
ci / docs-drift (pull_request) Successful in 57s
ci / docs-site (pull_request) Successful in 1m13s
ci / rust-arm64 (pull_request) Successful in 2m7s
apple / swift (pull_request) Successful in 2m17s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
android / android (pull_request) Successful in 6m45s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 7m1s
ci / rust (pull_request) Successful in 8m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 8m16s
33914867d1
A field client streamed over the host's ZeroTier address while both
machines shared a LAN. Neither end chose that: the host registers
exactly one address (its routed primary), but the OS's own mDNS
responder also answers A queries for <host>.local. per interface with
that interface's address, so the resolved set a client folds is a union
polluted by every overlay network whose multicast reaches it - and both
clients picked with HashSet::iter().next(): arbitrary, and re-rolled on
every re-announce.

punktfunk_core::discovery now makes the pick deterministic, shared by
the desktop and Android clients. Ranking, best wins: longest common
prefix with any of this machine's unicast addresses (on-link beats
routed, which settles LAN-vs-overlay in BOTH directions - a client that
can only reach the host through the overlay has no interface for the
host's LAN address to match), then the address the host itself declared,
then prefix against our default-route source, then the numerically
lowest. The host declares which address its advert is FOR in a new
advisory TXT key (addr); it never overrides reachability, only settles
a multi-NIC host's tie, and old hosts without it still resolve
deterministically.
enricobuehler merged commit 78723f381e into main 2026-08-28 19:10:18 +00:00
enricobuehler deleted branch worktree-mdns-primary-addr 2026-08-28 19:10:24 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#429