feat(w7 p6): opt-in n0 DNS discovery for Discoverable presence

Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.

- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
  -> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
  publish only when Discoverable; DirectOnly (the explicit no-server posture)
  gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
  address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
  n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
  Toggling publish off drops the publisher (republish task ends; TTL-30s record
  expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
  installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
  discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
  stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
  persists it. Re-selecting Discoverable restarts the clock.

Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.

266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 16:24:46 -04:00
co-authored by Claude Opus 4.8
parent 3b02c2be0a
commit 22f0eed94d
6 changed files with 244 additions and 38 deletions
+25 -3
View File
@@ -193,9 +193,31 @@ Rejoin is best-effort (works only while the room is still live + reachable throu
stored ticket — reliability is P6/the member-ticket floor, not this list). stored ticket — reliability is P6/the member-ticket floor, not this list).
**Screenshot-verified** (seeded config → 3 recents render with correct ages + fallback). **Screenshot-verified** (seeded config → 3 recents render with correct ages + fallback).
### P6 — Opt-in discovery — Small ### P6 — Opt-in discovery — ✅ DONE 2026-06-16
Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup Wires the *Discoverable* presence state to n0 DNS publish (default off, 30-min
path for finding a discoverable friend whose saved address went stale. time-boxed) plus an always-on lookup path. **Decisions (user, 2026-06-16):** 30-min
auto-revert (not sticky); resolver always on in relay-capable modes (the stationary
"looker" is usually in Normal, so lookups must work there).
- **Pure seam (`src/discovery.rs`, +3 tests):** `lookup_plan(network_mode, want_publish)
→ LookupPlan { resolver, publisher }` — relay modes always resolve + publish only when
Discoverable; **`DirectOnly` gets neither** (the explicit no-server posture overrides
the toggle). `DISCOVERY_TIMEBOX = 30 min`.
- **iroh edge (`apply_discovery` in `core/mod.rs`):** at runtime, on the bound endpoint,
`clear()` + reinstall the address-lookup services — memory-lookup always, n0 `PkarrResolver`
+ `DnsAddressLookup` when `resolver`, `PkarrPublisher` when `publisher`. **No endpoint
rebuild** — toggling publish off drops the publisher (its republish task ends; the
TTL-30s record expires). `build_net_stack` now binds uniformly with `Minimal` + per-mode
relay and calls `apply_discovery` (the old per-mode `presets::N0` build is gone), seeded
by the startup presence mode.
- **Toggle + time-box (core loop):** `SetPresenceMode` re-applies discovery and arms/cancels
a `discovery_deadline`; a `tokio::select!` branch fires at the deadline → revert to Normal +
stop publishing + `UiEvent::PresenceModeReverted` so the GUI mirrors/persists it (status:
"Discoverable timed out — back to Normal"). Re-selecting Discoverable restarts the clock.
- **Verified:** 266 lib tests green, clippy clean (`--all-targets`); runtime smoke-tested —
app binds + runs the new `Minimal`+`apply_discovery` path with no error/panic, both Normal
and Discoverable startup postures (the `PkarrPublisher` build path). ⚠️ **The actual cross-
network publish→lookup (a friend whose saved addr went stale resolving via n0 DNS) and the
live 30-min auto-revert need a 2-machine field test (P7).**
### P7 — Security review + 2-machine field test — SmallMedium ### P7 — Security review + 2-machine field test — SmallMedium
Surface: the friends-only listener (confirm non-friends are truly dropped pre-any Surface: the friends-only listener (confirm non-friends are truly dropped pre-any
+9
View File
@@ -735,6 +735,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
UiEvent::FriendPresence { id, presence } => { UiEvent::FriendPresence { id, presence } => {
state.friend_presence.insert(id, presence); state.friend_presence.insert(id, presence);
} }
UiEvent::PresenceModeReverted { mode } => {
// The Discoverable time-box elapsed; core dropped us back to
// `mode` (Normal) and stopped publishing. Mirror + persist so the
// presence picker reflects it, and tell the user why it changed.
state.config.presence_mode = mode;
state.config.save();
state.status_message =
"Discoverable timed out — back to Normal".to_string();
}
UiEvent::Error(err) => { UiEvent::Error(err) => {
state.status_message = format!("Error: {}", err); state.status_message = format!("Error: {}", err);
} }
+6
View File
@@ -116,5 +116,11 @@ pub enum UiEvent {
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline. /// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence }, FriendPresence { id: EndpointId, presence: FriendPresence },
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
/// GUI must mirror + persist this so its presence picker stops showing
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
/// without having issued the command itself.
PresenceModeReverted { mode: PresenceMode },
Error(String), Error(String),
} }
+105 -32
View File
@@ -405,46 +405,73 @@ impl NetStack {
} }
} }
/// Install the n0 DNS address-lookup services for a discovery `plan` (W7 P6), at
/// runtime, on an already-bound endpoint. The in-memory lookup (server-free, fed by
/// tickets + gossip) is always re-added; the n0 DNS *resolver* (`PkarrResolver` +
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
///
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its
/// republish task ends when the last clone is dropped, and the already-published
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing
/// resolution. The brief clear→re-add window is a few synchronous calls; presence
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
fn apply_discovery(
endpoint: &Endpoint,
memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
plan: crate::discovery::LookupPlan,
) -> Result<(), anyhow::Error> {
use iroh::address_lookup::{
AddressLookupBuilder, dns::DnsAddressLookup,
pkarr::{PkarrPublisher, PkarrResolver},
};
let services = endpoint.address_lookup()?;
services.clear();
// Always keep the local, server-free lookup (this is what ticket/gossip dialing
// depends on — it must survive every posture, including DirectOnly).
services.add(memory_lookup.clone());
if plan.resolver {
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?);
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
}
if plan.publisher {
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?);
}
Ok(())
}
/// Build the persistent network stack for the given identity + relay/discovery /// Build the persistent network stack for the given identity + relay/discovery
/// posture. Binds the endpoint (the per-`NetworkMode` build hoisted out of the old /// posture. Binds the endpoint (the per-`NetworkMode` build hoisted out of the old
/// per-join path), spawns one gossip instance + one router accepting gossip and /// per-join path), spawns one gossip instance + one router accepting gossip and
/// audio, and kicks off `online()` in the background so app launch isn't blocked on /// audio, and kicks off `online()` in the background so app launch isn't blocked on
/// the relay handshake. /// the relay handshake. `publish` is whether the presence posture is `Discoverable`
/// at build time (W7 P6) — it seeds the initial n0 DNS publish state.
async fn build_net_stack( async fn build_net_stack(
secret_key: SecretKey, secret_key: SecretKey,
network_mode: NetworkMode, network_mode: NetworkMode,
memory_lookup: iroh::address_lookup::memory::MemoryLookup, memory_lookup: iroh::address_lookup::memory::MemoryLookup,
friends_handler: crate::presence_net::Handler, friends_handler: crate::presence_net::Handler,
publish: bool,
) -> Result<NetStack, anyhow::Error> { ) -> Result<NetStack, anyhow::Error> {
// Build the endpoint per the configured relay/discovery posture. All postures // Bind with only the relay posture baked in (`Minimal` = crypto provider only,
// keep the in-memory address lookup (fed by tickets and gossip); they differ in // relay on/off per mode); n0 DNS discovery is installed uniformly below via
// whether n0's relay and DNS presence beacon are used. `Minimal` sets only the // `apply_discovery` so the Discoverable publish toggle is independent of the
// mandatory crypto provider and deliberately omits the n0 DNS publish/resolve. // network mode and can be flipped later at runtime. The in-memory lookup is added
let endpoint = match network_mode { // here so dialing works even before the first `apply_discovery` (which re-adds it).
NetworkMode::N0Full => { let relay_mode = match network_mode {
Endpoint::builder(presets::N0) NetworkMode::DirectOnly => RelayMode::Disabled,
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => RelayMode::Default,
};
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(secret_key.clone()) .secret_key(secret_key.clone())
.relay_mode(relay_mode)
.address_lookup(memory_lookup.clone()) .address_lookup(memory_lookup.clone())
.bind() .bind()
.await .await?;
}
NetworkMode::RelayNoDiscovery => { // Install the n0 DNS services for the current (mode, publish) posture.
Endpoint::builder(presets::Minimal) apply_discovery(&endpoint, &memory_lookup, crate::discovery::lookup_plan(network_mode, publish))?;
.secret_key(secret_key.clone())
.relay_mode(RelayMode::Default)
.address_lookup(memory_lookup.clone())
.bind()
.await
}
NetworkMode::DirectOnly => {
Endpoint::builder(presets::Minimal)
.secret_key(secret_key.clone())
.relay_mode(RelayMode::Disabled)
.address_lookup(memory_lookup.clone())
.bind()
.await
}
}?;
// Bring the endpoint online in the background so launch isn't blocked on the // Bring the endpoint online in the background so launch isn't blocked on the
// relay handshake; joins/sends just work once it's up (online() is idempotent). // relay handshake; joins/sends just work once it's up (online() is idempotent).
@@ -702,7 +729,8 @@ async fn run_core_loop(
// it (subscribe a topic + bind the audio router on join, clear on leave); it's // it (subscribe a topic + bind the audio router on join, clear on leave); it's
// rebuilt only when the network mode or identity changes. Moving `memory_lookup` // rebuilt only when the network mode or identity changes. Moving `memory_lookup`
// in — all later access is via `net.memory_lookup`. // in — all later access is via `net.memory_lookup`.
let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone()).await { let initial_publish = presence_mode.lock().unwrap().publishes_to_discovery();
let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone(), initial_publish).await {
Ok(stack) => stack, Ok(stack) => stack,
Err(e) => { Err(e) => {
// Only a local socket bind can fail here (the relay handshake is // Only a local socket bind can fail here (the relay handshake is
@@ -719,6 +747,11 @@ async fn run_core_loop(
// "applies on next join" semantics while keeping the endpoint up while idle. // "applies on next join" semantics while keeping the endpoint up while idle.
let mut net_rebuild_pending = false; let mut net_rebuild_pending = false;
// When Discoverable is on, the instant it auto-reverts to Normal (W7 P6 time-box).
// `None` = not Discoverable, no pending revert. Set on SetPresenceMode(Discoverable),
// cleared on any other posture, and consumed by the select! branch below.
let mut discovery_deadline: Option<tokio::time::Instant> = None;
// Tell the GUI the loaded friends list (it renders from this, no longer owning // Tell the GUI the loaded friends list (it renders from this, no longer owning
// it). Snapshot under the lock, then release it before the async send. // it). Snapshot under the lock, then release it before the async send.
let initial_snapshot = friends.lock().unwrap().list().to_vec(); let initial_snapshot = friends.lock().unwrap().list().to_vec();
@@ -758,6 +791,27 @@ async fn run_core_loop(
} }
continue; continue;
} }
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
// so a publish beacon never stands indefinitely. The branch is disabled
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI.
_ = tokio::time::sleep_until(
discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
), if discovery_deadline.is_some() => {
discovery_deadline = None;
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal;
let plan = crate::discovery::lookup_plan(network_mode, false);
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
}
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: crate::presence::PresenceMode::Normal,
})
.await;
continue;
}
}; };
match cmd { match cmd {
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => {
@@ -782,7 +836,8 @@ async fn run_core_loop(
if net_rebuild_pending { if net_rebuild_pending {
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
net_rebuild_pending = false; net_rebuild_pending = false;
} }
@@ -1410,7 +1465,8 @@ async fn run_core_loop(
if net_rebuild_pending { if net_rebuild_pending {
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
net_rebuild_pending = false; net_rebuild_pending = false;
} }
} }
@@ -1523,7 +1579,8 @@ async fn run_core_loop(
if active_session.is_none() { if active_session.is_none() {
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else { } else {
net_rebuild_pending = true; net_rebuild_pending = true;
} }
@@ -1555,7 +1612,8 @@ async fn run_core_loop(
if active_session.is_none() { if active_session.is_none() {
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone()).await?; let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?;
} else { } else {
net_rebuild_pending = true; net_rebuild_pending = true;
} }
@@ -1592,6 +1650,21 @@ async fn run_core_loop(
CoreCommand::SetPresenceMode(mode) => { CoreCommand::SetPresenceMode(mode) => {
*presence_mode.lock().unwrap() = mode; *presence_mode.lock().unwrap() = mode;
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
// address-lookup services. The resolver stays on regardless so we can
// still look up moved friends.
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) {
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
}
// Arm (Discoverable) or cancel (any other posture) the auto-revert
// time-box. Re-selecting Discoverable restarts the clock.
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable {
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX)
} else {
None
};
} }
CoreCommand::SetRecordingMode(mode) => { CoreCommand::SetRecordingMode(mode) => {
+95
View File
@@ -0,0 +1,95 @@
//! Opt-in discovery posture (W7 P6) — the pure policy that turns a (network mode,
//! "want to be Discoverable?") pair into *which* n0 DNS address-lookup services to
//! run, plus the Discoverable time-box. The actual iroh wiring (clearing and
//! reinstalling the endpoint's address-lookup services) lives in `core`; this module
//! is the small, testable decision surface so the policy can be unit-tested without
//! standing up an endpoint.
//!
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
//! address is stale and the dial falls through to discovery.
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
//! publishes their address to n0 DNS; everyone else just looks it up.
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
//! ever touches n0 there, regardless of the Discoverable toggle.
use crate::config::NetworkMode;
use std::time::Duration;
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
/// a publish-to-n0 beacon, so it auto-expires rather than standing indefinitely; the
/// user can re-toggle to restart the clock. (Open decision #3 settled: 30-minute
/// time-box, not sticky-until-off.)
pub const DISCOVERY_TIMEBOX: Duration = Duration::from_secs(30 * 60);
/// Which n0 DNS address-lookup services to install for a posture. The in-memory
/// lookup (fed by tickets + gossip, fully server-free) is always present and is
/// deliberately *not* represented here — these flags are only about n0 DNS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LookupPlan {
/// Resolve a moved friend's address by their (stable) node id via n0 DNS. A
/// passive query — publishes nothing — and only fires when a saved address is
/// stale enough that the dial falls through to discovery.
pub resolver: bool,
/// Publish *our* current address to n0 DNS so friends can find us after we moved
/// networks. The one privacy-costly bit; on only while `Discoverable`.
pub publisher: bool,
}
/// The n0 DNS services wanted for a `(network_mode, want_publish)` pair. Pure.
///
/// `want_publish` is `true` exactly when the presence posture is `Discoverable`
/// (see [`crate::presence::PresenceMode::publishes_to_discovery`]).
pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan {
match network_mode {
// The explicit serverless posture: no n0 contact at all, even to resolve.
// A Discoverable toggle here is intentionally inert.
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
// Relay-capable: always resolve (so a stationary friend can find a mover);
// publish only when the user opted into Discoverable.
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
LookupPlan { resolver: true, publisher: want_publish }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relay_modes_always_resolve_and_publish_only_when_wanted() {
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
assert_eq!(
lookup_plan(mode, false),
LookupPlan { resolver: true, publisher: false },
"{mode:?}: resolve always on, no publish when not Discoverable"
);
assert_eq!(
lookup_plan(mode, true),
LookupPlan { resolver: true, publisher: true },
"{mode:?}: Discoverable adds publish on top of resolve"
);
}
}
#[test]
fn direct_only_never_touches_n0_even_when_discoverable() {
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, false),
LookupPlan { resolver: false, publisher: false }
);
// The serverless posture overrides the Discoverable request entirely.
assert_eq!(
lookup_plan(NetworkMode::DirectOnly, true),
LookupPlan { resolver: false, publisher: false }
);
}
#[test]
fn timebox_is_thirty_minutes() {
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
}
}
+1
View File
@@ -15,6 +15,7 @@ pub mod screenshare;
pub mod sanitize; pub mod sanitize;
pub mod avatar; pub mod avatar;
pub mod recents; pub mod recents;
pub mod discovery;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::OnceLock;