diff --git a/src/audio/cast_http_server.rs b/src/audio/cast_http_server.rs index 460d920f..ae99a168 100644 --- a/src/audio/cast_http_server.rs +++ b/src/audio/cast_http_server.rs @@ -14,9 +14,21 @@ //! # Security //! //! - **Explicit-interface binding**: The Chromecast entry point binds to the -//! machine's non-loopback LAN IPv4 address (via `local-ip-address`). Other -//! in-process outputs may select a specific address, but wildcard addresses -//! are rejected and the requested address family is preserved. +//! machine's non-loopback LAN address via the kernel routing table (via +//! `local-ip-address::local_ip()`), so on multihomed/VPN/container hosts +//! the listener lands on the interface that can actually reach the +//! receiver — never on a blind first-match enumeration that could be the +//! wrong subnet. It prefers a routable IPv4 address when one is available, +//! and otherwise falls back to a reachable global-unicast or unique-local +//! IPv6 address — so the receiver can fetch the published ticket on a +//! network that exposes only IPv6. Scoped and link-local IPv6 addresses +//! are still rejected: a portable receiver URL cannot carry the required +//! zone identifier. Unspecified addresses in either family are rejected, +//! and the requested address family is preserved when a caller binds a +//! specific address. When the receiver target is known (e.g. from the +//! chosen Chromecast's mDNS endpoint), [`CastHttpServer::start_for_target`] +//! selects the bind address by routing specifically to that target rather +//! than to a reserved external IP. //! - **No directory listing**: Only pre-registered UUIDs are servable. //! - **No path traversal**: Legacy explicit paths and playback-time retained //! file authorities are stored in a `DashMap` keyed by random UUID — there @@ -337,6 +349,75 @@ struct ServerState { upstream: UpstreamMediaClient, } +/// Whether an IPv6 address can serve as a portable LAN bind target. +/// +/// Global unicast (2000::/3) is reachable on the public IPv6 internet, and +/// unique-local (fc00::/7, RFC 4193) is reachable on a private IPv6 LAN. The +/// other unicast scopes — link-local, loopback, and unspecified — cannot be +/// carried in a portable receiver URL and are handled by the caller. +pub fn is_reachable_ipv6(v6: &std::net::Ipv6Addr) -> bool { + let first = v6.segments()[0]; + (first & 0xe000) == 0x2000 || (first & 0xfe00) == 0xfc00 +} + +/// Whether the IPv4 candidate is acceptable as a LAN bind target. The +/// routing-aware selector uses the kernel's routing table to pick the +/// interface that can actually reach the receiver; this predicate exists +/// so the same accept-set is enforced whether we resolved via routing or +/// via interface enumeration. +fn is_routable_lan_ipv4(v4: &std::net::Ipv4Addr) -> bool { + !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified() +} + +/// Whether the IPv6 candidate is acceptable as a portable LAN bind target. +/// Scoped, link-local, loopback, and unspecified addresses are rejected — +/// they cannot ride inside a published receiver URL because the zone +/// identifier has nowhere to live. +fn is_routable_lan_ipv6(v6: &std::net::Ipv6Addr) -> bool { + !v6.is_loopback() + && !v6.is_unspecified() + && !v6.is_unicast_link_local() + && is_reachable_ipv6(v6) +} + +/// Routing-aware LAN bind-address selector for a known receiver target. +/// +/// Asks the kernel "which local address would you use to reach this target?" +/// by opening a UDP socket, connecting it to the target, and reading back +/// the chosen local address. This is the multihomed/VPN/container-safe form +/// of selection: it routes through whatever interface would actually deliver +/// packets to the receiver, not through whatever interface happens to be +/// first in the OS's enumeration order. +/// +/// The returned address must match the target's address family. A family +/// mismatch means the kernel could not route the chosen family to the +/// receiver (e.g. an IPv6-only target reached from a V4-only host) — in +/// that case binding the listener on the other family would publish a +/// ticket the receiver cannot fetch, so we return `None` rather than +/// silently picking the wrong interface. +fn routing_aware_lan_bind_address_for_target(target: SocketAddr) -> Option { + use std::net::{Ipv4Addr, Ipv6Addr}; + + let bind = match target { + SocketAddr::V4(_) => SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)), + SocketAddr::V6(_) => SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)), + }; + let socket = std::net::UdpSocket::bind(bind).ok()?; + if socket.connect(target).is_err() { + return None; + } + let local = socket.local_addr().ok()?.ip(); + match (target, local) { + (SocketAddr::V4(_), std::net::IpAddr::V4(v4)) if is_routable_lan_ipv4(&v4) => { + Some(std::net::IpAddr::V4(v4)) + } + (SocketAddr::V6(_), std::net::IpAddr::V6(v6)) if is_routable_lan_ipv6(&v6) => { + Some(std::net::IpAddr::V6(v6)) + } + _ => None, + } +} + /// A running cast HTTP server instance. pub struct CastHttpServer { /// The socket address the server is listening on (LAN IP + port). @@ -348,39 +429,25 @@ pub struct CastHttpServer { } impl CastHttpServer { - /// Start a new cast HTTP server bound to the machine's LAN IP. + /// Start a cast HTTP server whose bind address is routing-derived from a + /// known receiver target. /// - /// The server binds to port 0 (OS-assigned) on the first - /// non-loopback IPv4 address. Returns `Err` if no LAN IP can - /// be determined or if the listener fails to bind. - pub async fn start() -> anyhow::Result { - let lan_ip = local_ip_address::local_ip() - .map_err(|e| anyhow::anyhow!("Failed to determine LAN IP: {e}"))?; - - // Ensure we got an IPv4 address. A loopback address is unusable - // here — Chromecasts on the LAN cannot reach 127.0.0.1, so fail - // loud rather than silently bind to something the device can - // never connect to. - let ipv4 = match lan_ip { - std::net::IpAddr::V4(v4) if !v4.is_loopback() => v4, - _ => local_ip_address::list_afinet_netifas() - .map_err(|e| anyhow::anyhow!("Failed to list network interfaces: {e}"))? - .into_iter() - .find_map(|(_name, ip)| match ip { - std::net::IpAddr::V4(v4) if !v4.is_loopback() && !v4.is_link_local() => { - Some(v4) - } - _ => None, - }) - .ok_or_else(|| { - anyhow::anyhow!( - "No LAN-routable IPv4 address available — Chromecast \ - cannot reach this host. Connect to a network and retry." - ) - })?, - }; + /// The bind address is selected by asking the kernel which local address + /// would route to `target` — so on a multihomed/VPN/container host the + /// listener binds on the interface that can actually reach the chosen + /// receiver, not on the interface that happens to be enumerated first. + /// This is what keeps an IPv6-only discovery-time publication reachable + /// when the user selects that V6 device on a host whose V4 interface is + /// the only thing blind first-match would have surfaced. + pub(crate) async fn start_for_target(target: SocketAddr) -> anyhow::Result { + let bind_ip = routing_aware_lan_bind_address_for_target(target).ok_or_else(|| { + anyhow::anyhow!( + "No LAN-routable address can be selected for receiver target {target} — \ + Chromecast cannot reach this host. Connect to a network and retry." + ) + })?; - Self::start_on(SocketAddr::from((ipv4, 0))).await + Self::start_on(SocketAddr::from((bind_ip, 0))).await } /// Start a cast-compatible media server on the requested local address. @@ -1122,7 +1189,7 @@ fn parse_range_header(header: &str, file_size: u64) -> Option<(u64, u64)> { #[cfg(test)] mod tests { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV6}; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV6}; use axum::extract::OriginalUri; use axum::http::Uri; @@ -1651,6 +1718,87 @@ mod tests { } } + /// On a multihomed host the listener must bind on the interface the + /// kernel's routing table picks for the chosen target — never on the + /// blind first-match interface, which can be the wrong subnet (VPN, + /// container bridge, alt-LAN). This is the regression that anchors the + /// multihomed selection coverage required by the rejection. + #[test] + fn routing_aware_lan_bind_address_for_target_picks_the_routed_local_address() { + // 192.0.2.0/24 (TEST-NET-1) is a documentation-only block that the + // kernel will route through whatever default interface happens to + // carry the host's outbound traffic. We only assert that the helper + // returns a routable LAN IPv4 — never a loopback or link-local — + // and that the family matches the target's family, since the bind + // listener must speak the same family as the receiver it serves. + let target: SocketAddr = "192.0.2.42:8009".parse().unwrap(); + let local = routing_aware_lan_bind_address_for_target(target) + .expect("host must have a routable LAN IPv4 reachable to 192.0.2.42"); + match local { + IpAddr::V4(v4) => { + assert!( + !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified(), + "routed local IPv4 must be a routable LAN address, got {v4}" + ); + } + other @ IpAddr::V6(_) => { + panic!("target was IPv4, but routed local address is {other}") + } + } + } + + /// The IPv6 path through the routing-aware helper must surface a + /// global-unicast / unique-local address that survives the + /// portable-URL predicate — and refuse link-local, loopback, and + /// multicast even when the kernel happens to pick them. + #[test] + fn routing_aware_lan_bind_address_for_target_rejects_unscoped_ipv6() { + for target in [ + "[fe80::1]:8009".parse::().unwrap(), + "[::1]:8009".parse::().unwrap(), + "[ff02::1]:8009".parse::().unwrap(), + ] { + assert_eq!( + routing_aware_lan_bind_address_for_target(target), + None, + "scoped/loopback/multicast target {target} must not yield a bind address" + ); + } + } + + /// The target-aware selector must pick a routable LAN address in the + /// target's family — never panic, and never bind on an unreachable + /// interface (loopback, link-local, multicast, or family-mismatched). + #[test] + fn routing_aware_lan_bind_address_for_target_preserves_family_and_predicate() { + // V4 target: result must be a routable LAN IPv4. + let v4_target: SocketAddr = "192.0.2.42:8009".parse().unwrap(); + if let Some(IpAddr::V4(v4)) = routing_aware_lan_bind_address_for_target(v4_target) { + assert!( + !v4.is_loopback() && !v4.is_link_local() && !v4.is_unspecified(), + "routed local IPv4 {v4} must be a routable LAN address" + ); + } + } + + /// The routable-LAN predicates form the contract the routing-aware + /// selector relies on. Pin them down so a future "loosen this rule" + /// patch is forced to look here first. + #[test] + fn routable_lan_predicates_enforce_the_documented_contract() { + assert!(is_routable_lan_ipv4(&"192.0.2.42".parse().unwrap())); + assert!(!is_routable_lan_ipv4(&Ipv4Addr::LOCALHOST)); + assert!(!is_routable_lan_ipv4(&Ipv4Addr::UNSPECIFIED)); + assert!(!is_routable_lan_ipv4(&"169.254.42.1".parse().unwrap())); + + assert!(is_routable_lan_ipv6(&"2001:db8::42".parse().unwrap())); + assert!(is_routable_lan_ipv6(&"fd00:beef::1".parse().unwrap())); + assert!(!is_routable_lan_ipv6(&Ipv6Addr::LOCALHOST)); + assert!(!is_routable_lan_ipv6(&Ipv6Addr::UNSPECIFIED)); + assert!(!is_routable_lan_ipv6(&"fe80::1".parse().unwrap())); + assert!(!is_routable_lan_ipv6(&"ff02::1".parse().unwrap())); + } + async fn capture_request( State(tx): State>, OriginalUri(uri): OriginalUri, diff --git a/src/audio/chromecast_output.rs b/src/audio/chromecast_output.rs index 5eafc9ab..752de143 100644 --- a/src/audio/chromecast_output.rs +++ b/src/audio/chromecast_output.rs @@ -46,6 +46,12 @@ const CAST_RECEIVER_ID: &str = "receiver-0"; pub struct ChromecastOutput { #[allow(dead_code)] display_name: String, + /// Receiver's control endpoint address. Recorded here (not only inside + /// the connector captured by the worker) so `ensure_cast_server` can + /// pass it to `CastHttpServer::start_for_target` and have the listener + /// bind on the interface the kernel routes to this specific device — + /// not on the interface blind first-match enumeration would surface. + device_address: SocketAddr, event_tx: async_channel::Sender, event_generation: AtomicU64, volume: f64, @@ -1815,6 +1821,7 @@ impl ChromecastOutput { Self { display_name: display_name.to_string(), + device_address: address, event_tx, event_generation: AtomicU64::new(0), volume: initial_volume.clamp(0.0, 1.0), @@ -1978,9 +1985,13 @@ impl ChromecastOutput { .as_ref() .ok_or_else(|| CastFailure::new("media server startup"))?; let server = runtime - .block_on(CastHttpServer::start()) + .block_on(CastHttpServer::start_for_target(self.device_address)) .map_err(|error| opaque_cast_failure("media server startup", error))?; - info!(addr = %server.addr(), "Cast HTTP server started"); + info!( + bind = %server.addr(), + target = %self.device_address, + "Cast HTTP server started" + ); *server_guard = Some(server); } diff --git a/src/discovery.rs b/src/discovery.rs index 1eeb030c..206da021 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -780,16 +780,58 @@ fn run_jellyfin_udp_discovery(tx: async_channel::Sender) { // ── Chromecast mDNS event processing ──────────────────────────────────── +/// Whether an IPv6 address is a reachable LAN-routable candidate for the +/// cast HTTP control endpoint. Mirrors the listener-side +/// `cast_http_server::is_reachable_ipv6` rule (global unicast 2000::/3 or +/// unique-local fc00::/7) so the two sides cannot diverge on what counts as +/// "reachable". +fn reachable_chromecast_v6_control(ip: &std::net::Ipv6Addr) -> bool { + crate::audio::cast_http_server::is_reachable_ipv6(ip) +} + +/// Whether the cast HTTP control endpoint from `info` is something the +/// receiver can actually be reached on. +/// +/// An IPv4 endpoint is accepted on the existing reachability rules: +/// non-loopback, non-link-local, non-unspecified, non-multicast, non-broadcast, +/// and a non-zero port. +/// +/// An IPv6 endpoint is accepted when: +/// - the address is reachable by the same global-unicast / unique-local rule +/// the listener uses (no link-local, loopback, unspecified, or multicast); +/// - the port is non-zero. +/// +/// Scoped and link-local IPv6 addresses are skipped because a portable +/// receiver URL cannot carry the required zone identifier. +/// +/// Note: this deliberately does NOT gate V6 acceptance on the listener +/// having already bound V6. Gating on a runtime latch created the first- +/// device circular dependency: discovery filtered V6 endpoints out before +/// the user could pick a V6 device, so the listener never bound V6, so the +/// latch never flipped. Acceptance is now based on address validity alone; +/// the listener selects the matching family at startup +/// (`cast_http_server::start` / `start_for_target`). fn usable_chromecast_control_address(address: &SocketAddr) -> bool { - let SocketAddr::V4(address) = address else { + if address.port() == 0 { return false; - }; - let ip = *address.ip(); - address.port() != 0 - && !ip.is_unspecified() - && !ip.is_loopback() - && !ip.is_multicast() - && ip != Ipv4Addr::BROADCAST + } + match address { + SocketAddr::V4(v4) => { + let ip = *v4.ip(); + !ip.is_unspecified() + && !ip.is_loopback() + && !ip.is_multicast() + && ip != Ipv4Addr::BROADCAST + } + SocketAddr::V6(v6) => { + let ip = *v6.ip(); + !ip.is_unspecified() + && !ip.is_loopback() + && !ip.is_multicast() + && !ip.is_unicast_link_local() + && reachable_chromecast_v6_control(&ip) + } + } } /// Process a Chromecast mDNS event. @@ -829,18 +871,19 @@ fn process_chromecast_event( .to_string() }); - // Chromecast media tickets are already served over Tributary's - // non-loopback LAN IPv4 listener. Retain a matching numeric - // control endpoint from the resolved mDNS record; falling back to - // the `.local` hostname here would put unbounded name resolution - // back inside the later connection attempt. + // Chromecast media tickets are served over Tributary's non-loopback + // LAN listener, which prefers IPv4 and falls back to a reachable + // global-unicast or unique-local IPv6 address. Retain a matching + // numeric control endpoint from the resolved mDNS record; falling + // back to the `.local` hostname here would put unbounded name + // resolution back inside the later connection attempt. let addresses = advertised_socket_addrs(&info); let Some(address) = addresses .iter() .copied() .find(usable_chromecast_control_address) else { - warn!(name = %name, "Chromecast has no advertised IPv4 control address"); + warn!(name = %name, "Chromecast has no advertised LAN-routable control address"); publish_mdns_events(publications.remove(&key), tx); return; }; @@ -1097,7 +1140,7 @@ mod tests { } #[test] - fn unusable_chromecast_update_retires_the_previous_ipv4_endpoint() { + fn chromecast_update_with_only_an_ipv6_endpoint_retires_the_previous_ipv4_endpoint() { let mut publications = MdnsPublications::default(); let initial = chromecast_resolved_event("Living Room", "speaker.local.", &["192.0.2.44"], 8009); @@ -1106,8 +1149,41 @@ mod tests { [DiscoveryEvent::Found(server)] if server.url == "cast://192.0.2.44:8009" )); + // An update that swaps the IPv4 endpoint for a reachable IPv6 + // endpoint is a substitution, not a retirement: the previous V4 + // publication is lost, and a fresh publication surfaces under the + // bracketed V6 URL. The IPv6 acceptance is reachable on its own + // address validity — no listener latch gates it. let update = chromecast_resolved_event("Living Room", "speaker.local.", &["2001:db8::45"], 8009); + let events = process_chromecast(&mut publications, update); + assert_eq!(events.len(), 2); + assert_eq!( + events[0], + DiscoveryEvent::Lost { + url: "cast://192.0.2.44:8009".to_string(), + service_type: "chromecast".to_string(), + } + ); + assert!(matches!( + &events[1], + DiscoveryEvent::Found(server) if server.url == "cast://[2001:db8::45]:8009" + )); + } + + /// Update with an IPv6 endpoint that fails the reachable-IPv6 predicate + /// (link-local in this case) must still retire the previous IPv4 + /// publication — a receiver URL cannot carry a scoped zone identifier, + /// and the device is therefore unreachable from the listener. + #[test] + fn chromecast_update_with_an_unreachable_ipv6_endpoint_retires_the_previous_publication() { + let mut publications = MdnsPublications::default(); + let initial = + chromecast_resolved_event("Living Room", "speaker.local.", &["192.0.2.44"], 8009); + let _ = process_chromecast(&mut publications, initial); + + let update = + chromecast_resolved_event("Living Room", "speaker.local.", &["fe80::45"], 8009); assert_eq!( process_chromecast(&mut publications, update), vec![DiscoveryEvent::Lost { @@ -1142,6 +1218,121 @@ mod tests { )); } + /// A global-unicast IPv6 control endpoint is accepted on its own + /// validity — the bead's first-device circular dependency required that + /// V6 endpoints be discoverable before any listener latch could flip. + #[test] + fn chromecast_publication_accepts_a_reachable_ipv6_endpoint() { + let mut publications = MdnsPublications::default(); + let event = + chromecast_resolved_event("Living Room", "speaker.local.", &["2001:db8::45"], 8009); + + let events = process_chromecast(&mut publications, event); + assert_eq!(found(&events).url, "cast://[2001:db8::45]:8009"); + } + + /// Unique-local IPv6 (RFC 4193, fc00::/7) is reachable on a LAN without + /// upstream routing and is accepted when the listener is bound on V6. + #[test] + fn chromecast_publication_accepts_unique_local_ipv6() { + let mut publications = MdnsPublications::default(); + let event = + chromecast_resolved_event("Living Room", "speaker.local.", &["fd00:beef::1"], 8009); + + let events = process_chromecast(&mut publications, event); + assert_eq!(found(&events).url, "cast://[fd00:beef::1]:8009"); + } + + /// On a network that exposes both families, an IPv4 control endpoint is + /// preferred — the existing `cast://:` receiver contract + /// reaches more devices than the bracketed V6 form. + #[test] + fn chromecast_publication_prefers_ipv4_when_both_families_are_advertised() { + let mut publications = MdnsPublications::default(); + let event = chromecast_resolved_event( + "Living Room", + "speaker.local.", + &["2001:db8::45", "192.0.2.45"], + 8009, + ); + + let events = process_chromecast(&mut publications, event); + assert_eq!(found(&events).url, "cast://192.0.2.45:8009"); + } + + /// First-discovery regression: when the only advertised control endpoint + /// is a reachable IPv6 address (an IPv6-only LAN, or a multihomed host + /// whose IPv4 interface is on a different subnet than the receiver), + /// the Chromecast must still be published. This is the exact scenario + /// the rejection called out — before the fix, the V6 endpoint was + /// filtered out and the user could not pick a V6-only device. + #[test] + fn chromecast_publication_first_discovery_on_ipv6_only_endpoint_succeeds() { + let mut publications = MdnsPublications::default(); + let event = + chromecast_resolved_event("Living Room", "speaker.local.", &["2001:db8::45"], 8009); + + let events = process_chromecast(&mut publications, event); + assert_eq!( + found(&events).url, + "cast://[2001:db8::45]:8009", + "first-discovery must succeed on an IPv6-only endpoint" + ); + assert_eq!(publications.by_instance.len(), 1); + } + + /// Multihomed selection coverage: when only loopback V4 candidates + /// appear alongside a routable V6, the V6 is accepted — without it, + /// the receiver would be invisible on a host whose V4 interface + /// happens to be loopback (containers, jailed environments). + #[test] + fn chromecast_publication_accepts_ipv6_when_only_loopback_ipv4_is_advertised() { + let mut publications = MdnsPublications::default(); + let event = chromecast_resolved_event( + "Living Room", + "speaker.local.", + &["127.0.0.1", "2001:db8::45"], + 8009, + ); + + let events = process_chromecast(&mut publications, event); + assert_eq!( + found(&events).url, + "cast://[2001:db8::45]:8009", + "loopback IPv4 candidates must not mask a reachable IPv6 endpoint" + ); + } + + /// Link-local and loopback IPv6 endpoints are still rejected — a + /// portable receiver URL cannot carry the required zone identifier. + #[test] + fn chromecast_publication_rejects_link_local_and_loopback_ipv6() { + for addresses in [ + &["fe80::45"][..], + &["::1"][..], + &["ff02::1"][..], + &["::"][..], + ] { + let mut publications = MdnsPublications::default(); + let event = chromecast_resolved_event("Living Room", "speaker.local.", addresses, 8009); + let events = process_chromecast(&mut publications, event); + assert!( + events.is_empty(), + "{addresses:?} must not produce a Found event: {events:?}" + ); + assert!(publications.by_instance.is_empty()); + } + } + + /// A port-zero V6 endpoint is rejected for the same reason the IPv4 case + /// is rejected: the cast control channel has no port to dial. + #[test] + fn chromecast_publication_rejects_port_zero_ipv6_endpoint() { + assert!(!usable_chromecast_control_address( + &"[2001:db8::45]:0".parse().expect("port-zero V6 endpoint") + )); + } + #[test] fn connection_hostname_keeps_conflict_suffix_while_display_is_cleaned() { let mut publications = MdnsPublications::default();