diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 4031e1770..a0aece400 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -4746,6 +4746,7 @@ name = "qconnect-lan" version = "2.0.2" dependencies = [ "base64 0.23.1", + "if-addrs 0.15.0", "mdns-sd", "qbz-credentials", "qbz-qobuz", diff --git a/crates/qconnect-lan/Cargo.toml b/crates/qconnect-lan/Cargo.toml index ee45292b3..74fc19cbc 100644 --- a/crates/qconnect-lan/Cargo.toml +++ b/crates/qconnect-lan/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "Official Qobuz Connect LAN receiver wire and discovery" [dependencies] +if-addrs = "0.15" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/qconnect-lan/src/mdns.rs b/crates/qconnect-lan/src/mdns.rs index bbf0b644c..0dd6739dc 100644 --- a/crates/qconnect-lan/src/mdns.rs +++ b/crates/qconnect-lan/src/mdns.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::net::IpAddr; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::{Duration, Instant}; @@ -120,7 +120,8 @@ impl MdnsRegistration { } /// Publish only after the HTTP acceptor and workers exist. The monitor - /// supervises daemon health and reconciles route-selected IPv4 changes. + /// supervises daemon health and reconciles the complete operational IPv4 + /// interface set. pub(crate) fn publish(&mut self, on_fatal: F) -> Result<(), LanError> where F: Fn() + Send + Sync + 'static, @@ -131,7 +132,7 @@ impl MdnsRegistration { .reconcile(initial_addresses, &monitor, Some(ANNOUNCE_TIMEOUT))?; if matches!(&self.control.address_source, AddressSource::Dynamic) { // The initial acknowledgement may have shared the monitor queue - // with an interface event. Re-read route selection once before the + // with an interface event. Re-read the interface set once before the // long-lived monitor takes over; runtime publishes do not consume // monitor events while waiting for an acknowledgement. self.control @@ -367,7 +368,7 @@ where } /// Returns true for an unexpected exit. The receiver blocks between actual -/// interface events. Its bounded wake only observes shutdown; route discovery +/// interface events. Its bounded wake only observes shutdown; interface discovery /// remains a 30-second missed-event repair pass, not a fast polling loop. fn monitor_loop(control: &MdnsControl, monitor: &Receiver) -> bool { let mut next_reconcile = Instant::now() + RECONCILE_INTERVAL; @@ -540,23 +541,23 @@ fn service_info( } fn lan_ipv4_addresses() -> Result, LanError> { - // A connected UDP socket performs route selection without sending data. - // Targeting the mDNS multicast group yields the IPv4 interface that can - // actually reach LAN controllers, excluding Docker/VM bridges and - // loopback records that make strict official clients choose a dead URL. - let socket = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))) - .map_err(|_| LanError::AddressDiscovery)?; - socket - .connect(SocketAddr::from((Ipv4Addr::new(224, 0, 0, 251), 5353))) - .map_err(|_| LanError::AddressDiscovery)?; - let address = socket - .local_addr() + // The official receiver is reachable from every attached LAN. Advertising + // only the interface selected by one multicast route strands controllers + // on a second physical NIC. Point-to-point interfaces are excluded because + // they are normally VPN/tunnel links rather than mDNS LAN segments. + let addresses = if_addrs::get_if_addrs() .map_err(|_| LanError::AddressDiscovery)? - .ip(); - if !address.is_ipv4() || address.is_loopback() || address.is_unspecified() { + .into_iter() + .filter(|interface| { + interface.is_oper_up() && !interface.is_loopback() && !interface.is_p2p() + }) + .map(|interface| interface.ip()) + .collect::>(); + let addresses = normalize_addresses(addresses); + if addresses.is_empty() { return Err(LanError::NoLanAddresses); } - Ok(vec![address]) + Ok(addresses) } fn short_id(device_uuid: &str) -> String { diff --git a/crates/qconnect-lan/src/model.rs b/crates/qconnect-lan/src/model.rs index c0fcda4f8..147171edc 100644 --- a/crates/qconnect-lan/src/model.rs +++ b/crates/qconnect-lan/src/model.rs @@ -50,7 +50,6 @@ pub struct ConnectInfo { /// Raw official LAN token shape. Deliberately has no `Debug` implementation. #[derive(Deserialize)] -#[serde(deny_unknown_fields)] pub(crate) struct RawLanJwtToken { pub endpoint: String, pub exp: i64, @@ -66,7 +65,6 @@ impl Drop for RawLanJwtToken { /// Raw official handoff body. Deliberately has no `Debug` implementation. #[derive(Deserialize)] -#[serde(deny_unknown_fields)] pub(crate) struct RawHandoffRequest { pub session_id: String, pub jwt_api: RawLanJwtToken, diff --git a/crates/qconnect-lan/src/validation.rs b/crates/qconnect-lan/src/validation.rs index cd8afda51..24614026f 100644 --- a/crates/qconnect-lan/src/validation.rs +++ b/crates/qconnect-lan/src/validation.rs @@ -204,6 +204,34 @@ mod tests { assert!(become_active); } + #[test] + fn tolerates_future_fields_while_requiring_canonical_fields() { + let future = serde_json::json!({ + "session_id": "controller-session", + "jwt_api": { + "endpoint": "https://api.qobuz.test/v1", + "exp": NOW + 600, + "jwt": "api-secret", + "future_claim": "ignored" + }, + "jwt_qconnect": { + "endpoint": "wss://qws.qobuz.test/ws", + "exp": NOW + 600, + "jwt": "qws-secret", + "future_claim": { "version": 2 } + }, + "become_active": true, + "future_capability": ["one", "two"] + }); + + let candidate = + parse_and_validate(&serde_json::to_vec(&future).unwrap(), &policy(), NOW).unwrap(); + + assert_eq!(candidate.session_id(), "controller-session"); + assert_eq!(candidate.api_token().jwt(), "api-secret"); + assert_eq!(candidate.qconnect_token().jwt(), "qws-secret"); + } + #[test] fn rejects_aliases_expiry_and_untrusted_hosts() { let alias = serde_json::json!({