fix(qconnect-lan): harden discovery compatibility
Advertise every eligible IPv4 interface and tolerate future handoff fields while retaining canonical-field validation. cx
This commit is contained in:
5 files changed
+49
-20
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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<F>(&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<DaemonEvent>) -> bool {
|
||||
let mut next_reconcile = Instant::now() + RECONCILE_INTERVAL;
|
||||
@@ -540,23 +541,23 @@ fn service_info(
|
||||
}
|
||||
|
||||
fn lan_ipv4_addresses() -> Result<Vec<IpAddr>, 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::<Vec<_>>();
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in new issue
Block a user