Fix scoped link-local clobbering and the in-flight start-death race

The daemon's is_link_local_v6 guard could not see scoped fe80::…%iface
hosts, so a later link-local mDNS resolution could clobber a good
routable IPv4 host and break the session until the next re-resolution.
Strip the %zone before parsing. Also invalidate a StartCast when the
session dies (or StopCast fires) between negotiation and StartFinished,
so the dead session is torn down instead of installed and the UI is not
left stuck on Casting; add daemon actor tests for both races.

Hardening: dedupe Cast/DLNA re-resolution forwards, tolerate a
TRANSITIONING DLNA renderer after Play, fall back to if-addrs for LAN-IP
enumeration when ip(8) is missing, harden the HTTP token fallback, and
null-guard the estimated-bandwidth FFI getter.
This commit is contained in:
Breadway 2026-08-30 18:34:41 +08:00
parent 99a04c800b
commit b5ea779c06
8 changed files with 289 additions and 12 deletions

View file

@ -21,6 +21,10 @@ gstreamer-video = "0.25"
tiny_http = "0.12"
rupnp = "3.0.0"
futures-util = "0.3.33"
# Fallback LAN-IP enumeration used only when the `ip` command is unavailable
# (net.rs) -- already a transitive dep via mdns-sd, so this adds no new crate
# to the build.
if-addrs = "0.15"
breadcast-caststream-sys = { path = "../breadcast-caststream-sys" }
[dev-dependencies]

View file

@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use rupnp::Service;
use std::time::Duration;
use super::device::DlnaDevice;
use super::AV_TRANSPORT;
@ -81,8 +82,22 @@ impl DlnaSession {
{
Ok(_) => {}
Err(e) => {
if self.transport_state().await.ok().as_deref() != Some("PLAYING") {
return Err(e).context("Play failed");
// The renderer may already be auto-playing after SetURI (and
// reject a redundant Play), or may still be TRANSITIONING and
// only reach PLAYING a moment later -- either is success.
// A single immediate GetTransportInfo isn't enough for the
// latter, so give it a short bounded window before giving up
// on the Play error.
let mut attempts = 0u32;
loop {
if self.transport_state().await.ok().as_deref() == Some("PLAYING") {
break;
}
attempts += 1;
if attempts >= 5 {
return Err(e).context("Play failed");
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}

View file

@ -132,12 +132,30 @@ fn random_token() -> String {
.is_ok();
if !read_ok {
// Unreachable in practice on Linux, but better than a zero-entropy
// token if it ever happened.
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::process::id().hash(&mut hasher);
bytes[..8].copy_from_slice(&hasher.finish().to_le_bytes());
// token if it ever happened. Mix several low-cost, not-directly-
// foreseeable sources (two time reads a hair apart, pid, a process
// counter) into both hashes and write all 16 bytes, so the fallback
// never reduces to a single low-resolution clock tick or per-pid
// reuse -- still far weaker than /dev/urandom, which is why it stays
// a fallback, but not trivially guessable.
let mix = |salt: u64| {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::time::SystemTime::now().hash(&mut hasher);
std::time::SystemTime::UNIX_EPOCH
.elapsed()
.ok()
.map(|d| d.as_nanos())
.unwrap_or_default()
.hash(&mut hasher);
std::process::id().hash(&mut hasher);
salt.hash(&mut hasher);
hasher.finish()
};
let a = mix(0x9e3779b97f4a7c15);
let b = mix(0xdead_beef_dead_beef);
bytes[..8].copy_from_slice(&a.to_le_bytes());
bytes[8..].copy_from_slice(&b.to_le_bytes());
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}

View file

@ -55,7 +55,26 @@ fn pick_lan_ip(peer: Option<IpAddr>) -> Result<IpAddr> {
.context("no LAN-reachable IPv4 address found (excluding loopback/VPN/virtual interfaces) — is this machine connected to a network?")
}
/// Enumerates this machine's private, LAN-reachable IPv4 addresses as
/// `(interface, address, prefix-len)` tuples. Primary path parses `ip -o
/// addr show` -- the formatting it relies on is stable, and it bakes in the
/// `scope global up` filter (excluding link-local 169.254/16 and down
/// interfaces) for free -- falling back to a `getifaddrs`-based enumeration
/// (`if-addrs`, already in the dependency tree via `mdns-sd`) when the `ip`
/// command is unavailable, so the LAN-IP discovery doesn't silently depend on
/// iproute2 being installed.
fn lan_ipv4_candidates() -> Result<Vec<(String, Ipv4Addr, u8)>> {
match ip_command_candidates() {
Ok(candidates) if !candidates.is_empty() => return Ok(candidates),
Ok(_) => {} // `ip` ran but found no private address -- genuine, fall through
Err(e) => {
tracing::debug!(error = ?e, "`ip addr show` unavailable, falling back to getifaddrs");
}
}
getifaddrs_candidates()
}
fn ip_command_candidates() -> Result<Vec<(String, Ipv4Addr, u8)>> {
let output = std::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "scope", "global", "up"])
.output()
@ -89,6 +108,30 @@ fn lan_ipv4_candidates() -> Result<Vec<(String, Ipv4Addr, u8)>> {
Ok(candidates)
}
/// `getifaddrs`-based counterpart to [`ip_command_candidates`], mirroring the
/// `ip` path's filters (up, non-loopback, private, not link-local, not an
/// excluded interface name) so the two produce equivalent candidate sets.
fn getifaddrs_candidates() -> Result<Vec<(String, Ipv4Addr, u8)>> {
let ifaces = if_addrs::get_if_addrs().context("failed to enumerate network interfaces (getifaddrs fallback)")?;
let mut candidates: Vec<(String, Ipv4Addr, u8)> = Vec::new();
for iface in ifaces {
if EXCLUDED_PREFIXES.iter().any(|p| iface.name.starts_with(p)) {
continue;
}
if !iface.is_oper_up() {
continue;
}
let if_addrs::IfAddr::V4(v4) = iface.addr else { continue };
// Mirrors `type(-4 -o addr show scope global up)`: drop loopback,
// link-local (169.254) and non-private networks.
if v4.ip.is_loopback() || v4.ip.is_link_local() || !v4.ip.is_private() {
continue;
}
candidates.push((iface.name.clone(), v4.ip, v4.prefixlen.min(32)));
}
Ok(candidates)
}
fn same_subnet(a: Ipv4Addr, b: Ipv4Addr, prefix: u8) -> bool {
if prefix == 0 {
return true;