Files
punktfunk/crates/pf-client-core/src/discovery.rs
T
enricobuehler d0fa8bd3ee fix(clients): pin mDNS discovery to IPv4 on every client
Host machines' OS mDNS responders answer AAAA for hostname.local even though the punktfunk
stack binds IPv4 sockets exclusively, so a v6-resolved address rendered a host card whose
connect always failed. Apple's Network.framework prefers IPv6 (RFC 6724) — force the resolve
connection to v4; pf-client-core (Linux/Windows/Deck shells) now picks get_addresses_v4()
instead of an arbitrary first address. Android already did exactly this, for the same reason.
Lift all three when the stack actually speaks IPv6 (per-family shard sizing is the gating
item — the IPv6 header costs 20 bytes of the MTU budget shard_payload is maximal against).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:57:41 +02:00

115 lines
5.2 KiB
Rust

//! LAN host discovery: browse the host's mDNS advert (`_punktfunk._udp`, TXT keys
//! `fp`/`pair`/`id` — see the host crate's `discovery.rs`) on a worker thread and stream
//! results to the UI. Removal events are forwarded too, so the hosts page can drop stale
//! cards and flip a saved host's online pip when its advert disappears.
use mdns_sd::{ServiceDaemon, ServiceEvent};
#[derive(Clone, Debug)]
pub struct DiscoveredHost {
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
pub key: String,
/// The mDNS service fullname — what a later `Removed` event identifies the advert by.
pub fullname: String,
pub name: String,
pub addr: String,
pub port: u16,
/// Host certificate fingerprint to pin (lowercase hex), empty if not advertised.
pub fp_hex: String,
/// Pairing requirement: `"required"` or `"optional"`.
pub pair: String,
/// The management API's port (mDNS `mgmt` TXT) — where the game library is served.
/// `None` when not advertised (older host / standalone `punktfunk1-host`); the
/// library client then falls back to the well-known default.
pub mgmt_port: Option<u16>,
/// Wake-on-LAN MAC(s) from the mDNS `mac` TXT (comma-separated `aa:bb:cc:dd:ee:ff`), which the
/// hosts page persists onto the matching saved host so it can wake it later. Empty if absent.
pub mac: Vec<String>,
}
/// One discovery update for the UI's advert map.
pub enum DiscoveryEvent {
/// A host advert appeared or refreshed (new address, pairing flipped, …).
Resolved(DiscoveredHost),
/// The advert went away (host stopped / left the network).
Removed { fullname: String },
}
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
/// dropped (the send fails) or the daemon dies.
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
let (tx, rx) = async_channel::unbounded();
std::thread::Builder::new()
.name("punktfunk-mdns".into())
.spawn(move || {
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
tracing::warn!(error = %e, "mDNS daemon failed — discovery disabled");
return;
}
};
let receiver = match daemon.browse("_punktfunk._udp.local.") {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
return;
}
};
while let Ok(event) = receiver.recv() {
let update = match event {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
// IPv4 only, on purpose (same policy as the Android client): the core
// dials `format!("{host}:{port}").parse::<SocketAddr>()` over IPv4-bound
// sockets, so a v6 pick from this unordered address set (the host's OS
// responder often answers AAAA for its hostname) would render a host card
// that fails on every click. A v6-only advert is dropped — the honest
// "not found" — until the stack actually speaks IPv6.
let Some(addr) =
info.get_addresses_v4().iter().next().map(|a| a.to_string())
else {
continue;
};
let id = val("id");
DiscoveryEvent::Resolved(DiscoveredHost {
key: if id.is_empty() {
info.get_fullname().to_string()
} else {
id
},
fullname: info.get_fullname().to_string(),
name: info
.get_fullname()
.split('.')
.next()
.unwrap_or("?")
.to_string(),
addr,
port: info.get_port(),
fp_hex: val("fp"),
pair: val("pair"),
mgmt_port: val("mgmt").parse().ok(),
mac: val("mac")
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
})
}
ServiceEvent::ServiceRemoved(_ty, fullname) => {
DiscoveryEvent::Removed { fullname }
}
_ => continue,
};
if tx.send_blocking(update).is_err() {
break; // UI gone — stop browsing
}
}
let _ = daemon.shutdown();
})
.expect("spawn mdns thread");
rx
}