From e1e4da730d247f6e11a7b9e4b15301ed9190764d Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 00:56:31 +0200 Subject: [PATCH 1/2] fix(dlna): answer browsemetadata for every container the server lists A BrowseMetadata on 0/artists, 0/albums or one artist or album answered an empty DIDL, so a controller that reads a folder's metadata before opening or queueing it showed the folder empty. Containers carried no childCount, and a track's own metadata named the root as its parent. Object ids are now parsed once into an ObjectId that both browse flags dispatch on. Every container id answers BrowseMetadata with a single container carrying its childCount, from index-backed COUNT subqueries that the listings now carry too. An album names 0/albums as its parent (the container its id lives under) and a track names its album. --- docs/features/dlna.md | 12 + src-tauri/crates/app/src/dlna/cds.rs | 405 ++++++++++++++++++++------- 2 files changed, 315 insertions(+), 102 deletions(-) diff --git a/docs/features/dlna.md b/docs/features/dlna.md index 27a692e47..d66f11649 100644 --- a/docs/features/dlna.md +++ b/docs/features/dlna.md @@ -45,6 +45,18 @@ Object IDs are **string prefixes**, routed by [`cds.rs`](../../src-tauri/crates/ 0/track/ Single-track BrowseMetadata payload ``` +Every id that a `BrowseDirectChildren` can return also answers `BrowseMetadata` with exactly one object: controllers read a folder's metadata before opening it, or before queueing it whole, and an empty answer shows as an empty folder. Every container carries a `childCount` (index-backed `COUNT` subqueries; an album counts only its available tracks, as its listing does). The `parentID` a `BrowseMetadata` reports: + +| Object | `parentID` | Why | +| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `0` | `-1` | The root, per the spec. | +| `0/artists`, `0/albums` | `0` | | +| `0/artists/` | `0/artists` | | +| `0/albums/` | `0/albums` | Also listed under its artist, but its id lives under `0/albums`, which reaches it whatever the album's artist row says. | +| `0/track/` | `0/albums/` | Its album, the only container that lists it; `0` for a track without an album. | + +An unknown id, or one whose row is gone, still answers an empty DIDL rather than a fault. + Pagination via `StartingIndex` + `RequestedCount` → SQL `LIMIT/OFFSET`, capped at `MAX_PAGE_SIZE = 500`. `RequestedCount = 0` (the spec's "all") folds to the same cap so a misbehaving controller can't pull 50k tracks into one DIDL document. ## DIDL-Lite items diff --git a/src-tauri/crates/app/src/dlna/cds.rs b/src-tauri/crates/app/src/dlna/cds.rs index ce037553a..d89a01442 100644 --- a/src-tauri/crates/app/src/dlna/cds.rs +++ b/src-tauri/crates/app/src/dlna/cds.rs @@ -10,6 +10,17 @@ //! - `0/albums/` — Tracks for that album (items) //! - `0/track/` — Single track item (used by BrowseMetadata) //! +//! Every id above answers `BrowseMetadata` with the one object it +//! names, and every container carries a `childCount`. Controllers +//! read a folder's metadata before opening it, or before queueing it +//! whole, and treat an empty answer as an empty folder. +//! +//! An album is listed under both `0/albums` and `0/artists/`, but +//! its own metadata names `0/albums` as the parent: that is the +//! container its id lives under, and it is reachable from the root +//! whatever the album's artist row says. A track names its album, the +//! only container that lists it. +//! //! # Pagination //! //! `StartingIndex` + `RequestedCount` map directly to SQL @@ -34,6 +45,63 @@ use crate::dlna::{description::xml_escape, http::ServerCtx}; /// avoid serialising 50k tracks into one DIDL document. const MAX_PAGE_SIZE: i64 = 500; +/// Titles of the two top-level folders. Shared by the root listing and +/// by each folder's own `BrowseMetadata` so the two cannot drift. +const ARTISTS_TITLE: &str = "Artists"; +const ALBUMS_TITLE: &str = "Albums"; + +/// A ContentDirectory object id, parsed once so `BrowseDirectChildren` +/// and `BrowseMetadata` agree on which ids exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObjectId { + Root, + Artists, + Artist(i64), + Albums, + Album(i64), + Track(i64), +} + +impl ObjectId { + /// `None` for anything the server never emits. An empty id is the + /// root: some controllers send `` on first + /// contact. + fn parse(oid: &str) -> Option { + match oid { + "" | "0" => Some(Self::Root), + "0/artists" => Some(Self::Artists), + "0/albums" => Some(Self::Albums), + _ => { + let numbered = |prefix: &str| oid.strip_prefix(prefix)?.parse::().ok(); + numbered("0/artists/") + .map(Self::Artist) + .or_else(|| numbered("0/albums/").map(Self::Album)) + .or_else(|| numbered("0/track/").map(Self::Track)) + } + } + } + + /// The parent a container's own metadata names. A track's parent + /// depends on its album row instead, see [`track_parent_id`]. + fn container_parent(self) -> &'static str { + match self { + Self::Root => "-1", + Self::Artists | Self::Albums => "0", + Self::Artist(_) => "0/artists", + Self::Album(_) => "0/albums", + // Never asked: a track's metadata reads its album row. + Self::Track(_) => "0", + } + } +} + +/// The container a track is listed under: its album, or the root for a +/// track without one (no container lists it, and the root at least +/// exists). +fn track_parent_id(album_id: Option) -> String { + album_id.map_or_else(|| "0".to_string(), |id| format!("0/albums/{id}")) +} + /// SOAP control endpoint handler. Decodes the envelope, dispatches by /// action name, returns the appropriate SOAP response (or fault). pub async fn handle_control(State(ctx): State>, body: String) -> Response { @@ -158,47 +226,67 @@ async fn browse(ctx: &ServerCtx, req: &BrowseRequest) -> Result() { - return browse_artist_albums(&ctx.pool, artist_id, count, offset).await; - } - } - if oid == "0/albums" { - return browse_albums(&ctx.pool, count, offset).await; - } - if let Some(id_str) = oid.strip_prefix("0/albums/") { - if let Ok(album_id) = id_str.parse::() { - return browse_album_tracks(ctx, album_id, count, offset).await; - } + match ObjectId::parse(&req.object_id) { + Some(ObjectId::Root) => browse_root(&ctx.pool).await, + Some(ObjectId::Artists) => browse_artists(&ctx.pool, count, offset).await, + Some(ObjectId::Artist(id)) => browse_artist_albums(&ctx.pool, id, count, offset).await, + Some(ObjectId::Albums) => browse_albums(&ctx.pool, count, offset).await, + Some(ObjectId::Album(id)) => browse_album_tracks(ctx, id, count, offset).await, + // A track has no children, and an unknown id gets an empty + // DIDL rather than a fault so controllers gracefully render + // an empty folder. + Some(ObjectId::Track(_)) | None => Ok(empty_result()), } +} - // Unknown ID — return an empty DIDL rather than a fault so - // controllers gracefully render an empty folder. - Ok(BrowseResult { +fn empty_result() -> BrowseResult { + BrowseResult { didl: didl_envelope(""), number_returned: 0, total_matches: 0, - }) + } } -fn browse_root() -> BrowseResult { - let mut body = String::new(); - body.push_str(&container_xml( - "0/artists", - "0", - "Artists", - // childCount is informational; controllers query it for the - // folder badge but won't fail if it's wrong. - None, - )); - body.push_str(&container_xml("0/albums", "0", "Albums", None)); +/// Wrap a single DIDL object, or nothing when the id named no row. +fn single_result(body: Option) -> BrowseResult { + match body { + Some(body) => BrowseResult { + didl: didl_envelope(&body), + number_returned: 1, + total_matches: 1, + }, + None => empty_result(), + } +} + +/// Children of the two top-level folders, in the order +/// `(artists, albums)`. +async fn root_counts(pool: &SqlitePool) -> Result<(i64, i64), sqlx::Error> { + let artists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artist") + .fetch_one(pool) + .await?; + let albums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM album") + .fetch_one(pool) + .await?; + Ok((artists, albums)) +} + +fn artists_container(child_count: i64) -> String { + container_xml("0/artists", "0", ARTISTS_TITLE, Some(child_count)) +} + +fn albums_container(child_count: i64) -> String { + container_xml("0/albums", "0", ALBUMS_TITLE, Some(child_count)) +} + +async fn browse_root(pool: &SqlitePool) -> Result { + let (artists, albums) = root_counts(pool).await?; + Ok(root_children(artists, albums)) +} + +fn root_children(artists: i64, albums: i64) -> BrowseResult { + let mut body = artists_container(artists); + body.push_str(&albums_container(albums)); BrowseResult { didl: didl_envelope(&body), number_returned: 2, @@ -214,19 +302,26 @@ async fn browse_artists( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artist") .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = - sqlx::query_as("SELECT id, name FROM artist ORDER BY canonical_name LIMIT ? OFFSET ?") - .bind(count) - .bind(offset) - .fetch_all(pool) - .await?; + // The per-row counts ride `idx_album_artist`, so a page of 500 + // stays a page of index probes. + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT ar.id, ar.name, + (SELECT COUNT(*) FROM album al WHERE al.artist_id = ar.id) + FROM artist ar + ORDER BY ar.canonical_name + LIMIT ? OFFSET ?", + ) + .bind(count) + .bind(offset) + .fetch_all(pool) + .await?; let mut body = String::new(); - for (id, name) in &rows { + for (id, name, albums) in &rows { body.push_str(&container_xml( &format!("0/artists/{id}"), "0/artists", name, - None, + Some(*albums), )); } Ok(BrowseResult { @@ -246,9 +341,12 @@ async fn browse_artist_albums( .bind(artist_id) .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = sqlx::query_as( - "SELECT id, title FROM album WHERE artist_id = ? - ORDER BY year, canonical_title + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT al.id, al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + WHERE al.artist_id = ? + ORDER BY al.year, al.canonical_title LIMIT ? OFFSET ?", ) .bind(artist_id) @@ -258,12 +356,12 @@ async fn browse_artist_albums( .await?; let parent = format!("0/artists/{artist_id}"); let mut body = String::new(); - for (id, title) in &rows { + for (id, title, tracks) in &rows { body.push_str(&container_xml( &format!("0/albums/{id}"), &parent, title, - None, + Some(*tracks), )); } Ok(BrowseResult { @@ -281,19 +379,26 @@ async fn browse_albums( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM album") .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = - sqlx::query_as("SELECT id, title FROM album ORDER BY canonical_title LIMIT ? OFFSET ?") - .bind(count) - .bind(offset) - .fetch_all(pool) - .await?; + // Only available tracks count, because `browse_album_tracks` lists + // only those; the per-row count rides `idx_track_album_available`. + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT al.id, al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + ORDER BY al.canonical_title + LIMIT ? OFFSET ?", + ) + .bind(count) + .bind(offset) + .fetch_all(pool) + .await?; let mut body = String::new(); - for (id, title) in &rows { + for (id, title, tracks) in &rows { body.push_str(&container_xml( &format!("0/albums/{id}"), "0/albums", title, - None, + Some(*tracks), )); } Ok(BrowseResult { @@ -320,6 +425,8 @@ struct TrackRow { artwork_hash: Option, artwork_format: Option, album_title: Option, + /// Names the track's parent container in its own metadata. + album_id: Option, } async fn browse_album_tracks( @@ -340,7 +447,8 @@ async fn browse_album_tracks( t.file_path, aw.hash AS artwork_hash, aw.format AS artwork_format, - al.title AS album_title + al.title AS album_title, + t.album_id FROM track t LEFT JOIN album al ON al.id = t.album_id LEFT JOIN artwork aw ON aw.id = al.artwork_id @@ -367,55 +475,93 @@ async fn browse_album_tracks( } async fn browse_metadata(ctx: &ServerCtx, object_id: &str) -> Result { - if object_id.is_empty() || object_id == "0" { - let body = r#"WaveFlowobject.container"#.to_string(); - return Ok(BrowseResult { - didl: didl_envelope(&body), - number_returned: 1, - total_matches: 1, - }); - } - if let Some(id_str) = object_id.strip_prefix("0/track/") { - if let Ok(track_id) = id_str.parse::() { - // Column aliases must match `TrackRow`: `ar.name AS artist_name` - // (not `t.primary_artist`) and `t.bitrate AS bitrate` (the column - // is `bitrate`, not `bit_rate` — the earlier shape would fail at - // bind time on every BrowseMetadata request). - let row: Option = sqlx::query_as( - "SELECT t.id, t.title, - ar.name AS artist_name, - t.duration_ms, t.file_size, t.bitrate, t.sample_rate, t.channels, - t.file_path, - aw.hash AS artwork_hash, - aw.format AS artwork_format, - al.title AS album_title - FROM track t - LEFT JOIN album al ON al.id = t.album_id - LEFT JOIN artwork aw ON aw.id = al.artwork_id - LEFT JOIN artist ar ON ar.id = t.primary_artist - WHERE t.id = ? AND t.is_available = 1", + let body = match ObjectId::parse(object_id) { + Some(ObjectId::Track(track_id)) => track_metadata(ctx, track_id).await?, + Some(container) => container_metadata(&ctx.pool, container).await?, + None => None, + }; + Ok(single_result(body)) +} + +/// The `` an id names, or `None` when its row is gone. +async fn container_metadata( + pool: &SqlitePool, + id: ObjectId, +) -> Result, sqlx::Error> { + let parent = id.container_parent(); + Ok(match id { + ObjectId::Root => Some(format!( + r#"WaveFlowobject.container"# + )), + ObjectId::Artists => Some(artists_container(root_counts(pool).await?.0)), + ObjectId::Albums => Some(albums_container(root_counts(pool).await?.1)), + ObjectId::Artist(artist_id) => { + let row: Option<(String, i64)> = sqlx::query_as( + "SELECT ar.name, (SELECT COUNT(*) FROM album al WHERE al.artist_id = ar.id) + FROM artist ar + WHERE ar.id = ?", ) - .bind(track_id) - .fetch_optional(&ctx.pool) + .bind(artist_id) + .fetch_optional(pool) .await?; - if let Some(row) = row { - let parent = "0"; - let body = item_xml(ctx, &row, parent); - return Ok(BrowseResult { - didl: didl_envelope(&body), - number_returned: 1, - total_matches: 1, - }); - } + row.map(|(name, albums)| { + container_xml( + &format!("0/artists/{artist_id}"), + parent, + &name, + Some(albums), + ) + }) } - } - Ok(BrowseResult { - didl: didl_envelope(""), - number_returned: 0, - total_matches: 0, + ObjectId::Album(album_id) => { + let row: Option<(String, i64)> = sqlx::query_as( + "SELECT al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + WHERE al.id = ?", + ) + .bind(album_id) + .fetch_optional(pool) + .await?; + row.map(|(title, tracks)| { + container_xml( + &format!("0/albums/{album_id}"), + parent, + &title, + Some(tracks), + ) + }) + } + ObjectId::Track(_) => None, }) } +async fn track_metadata(ctx: &ServerCtx, track_id: i64) -> Result, sqlx::Error> { + // Column aliases must match `TrackRow`: `ar.name AS artist_name` + // (not `t.primary_artist`) and `t.bitrate AS bitrate` (the column + // is `bitrate`, not `bit_rate` — the earlier shape would fail at + // bind time on every BrowseMetadata request). + let row: Option = sqlx::query_as( + "SELECT t.id, t.title, + ar.name AS artist_name, + t.duration_ms, t.file_size, t.bitrate, t.sample_rate, t.channels, + t.file_path, + aw.hash AS artwork_hash, + aw.format AS artwork_format, + al.title AS album_title, + t.album_id + FROM track t + LEFT JOIN album al ON al.id = t.album_id + LEFT JOIN artwork aw ON aw.id = al.artwork_id + LEFT JOIN artist ar ON ar.id = t.primary_artist + WHERE t.id = ? AND t.is_available = 1", + ) + .bind(track_id) + .fetch_optional(&ctx.pool) + .await?; + Ok(row.map(|row| item_xml(ctx, &row, &track_parent_id(row.album_id)))) +} + /// Wrap inner content in the standard DIDL-Lite envelope. The four /// xmlns declarations are mandatory for AVTransport-side parsers. fn didl_envelope(inner: &str) -> String { @@ -621,9 +767,64 @@ mod tests { #[test] fn root_browse_lists_two_top_level_containers() { - let r = browse_root(); + let r = root_children(3, 7); assert_eq!(r.number_returned, 2); - assert!(r.didl.contains("0/artists")); - assert!(r.didl.contains("0/albums")); + assert!(r + .didl + .contains(r#"id="0/artists" parentID="0" restricted="1" childCount="3""#)); + assert!(r + .didl + .contains(r#"id="0/albums" parentID="0" restricted="1" childCount="7""#)); + } + + #[test] + fn object_id_parses_every_id_the_server_emits() { + assert_eq!(ObjectId::parse(""), Some(ObjectId::Root)); + assert_eq!(ObjectId::parse("0"), Some(ObjectId::Root)); + assert_eq!(ObjectId::parse("0/artists"), Some(ObjectId::Artists)); + assert_eq!(ObjectId::parse("0/artists/12"), Some(ObjectId::Artist(12))); + assert_eq!(ObjectId::parse("0/albums"), Some(ObjectId::Albums)); + assert_eq!(ObjectId::parse("0/albums/34"), Some(ObjectId::Album(34))); + assert_eq!(ObjectId::parse("0/track/56"), Some(ObjectId::Track(56))); + } + + #[test] + fn object_id_rejects_what_the_server_never_emits() { + assert_eq!(ObjectId::parse("0/artists/"), None); + assert_eq!(ObjectId::parse("0/albums/abc"), None); + assert_eq!(ObjectId::parse("0/track"), None); + assert_eq!(ObjectId::parse("0/genres"), None); + assert_eq!(ObjectId::parse("1"), None); + } + + #[test] + fn every_container_names_the_container_that_lists_it() { + assert_eq!(ObjectId::Root.container_parent(), "-1"); + assert_eq!(ObjectId::Artists.container_parent(), "0"); + assert_eq!(ObjectId::Albums.container_parent(), "0"); + assert_eq!(ObjectId::Artist(1).container_parent(), "0/artists"); + assert_eq!(ObjectId::Album(1).container_parent(), "0/albums"); + } + + #[test] + fn a_track_names_its_album_or_the_root() { + assert_eq!(track_parent_id(Some(9)), "0/albums/9"); + assert_eq!(track_parent_id(None), "0"); + } + + #[test] + fn single_result_counts_one_object_or_none() { + let one = single_result(Some(container_xml( + "0/albums/1", + "0/albums", + "A & B", + Some(4), + ))); + assert_eq!((one.number_returned, one.total_matches), (1, 1)); + assert!(one + .didl + .contains(r#"childCount="4">A & B"#)); + let none = single_result(None); + assert_eq!((none.number_returned, none.total_matches), (0, 0)); } } From f0ad7ea7f2ed5ca9a1eb4dfa7cc7a8c50fdc6c1a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 19 Oct 2026 01:07:09 +0200 Subject: [PATCH 2/2] fix(dlna): advertise an address each receiver can reach The servers took the first non-loopback IPv4 the OS listed, joined the SSDP group on that interface only and put it in every LOCATION, URLBase, stream and cover URL. With a VPN, WSL, Hyper-V or Docker adapter listed first, a receiver was pointed at an address it cannot reach. lan_addrs now ranks every IPv4 address: a private one on a running, physical interface first, then a non-private one, then point-to-point, down or VPN / VM / container-named adapters, link-local last. The name match only demotes. SSDP joins the group on every interface, sends each NOTIFY batch out of each interface with its own address, and answers an M-SEARCH with the address on the requester's subnet. The HTTP layer builds its URLs from the local address each request arrived on, read through ConnectInfo. With no IPv4 address at all, SSDP now reports an error instead of announcing 0.0.0.0. MPD only displays the address (clients connect to us), so it keeps calling pick_lan_ip and simply shows the better-ranked one. --- docs/features/dlna.md | 12 +- docs/features/mpd.md | 2 + src-tauri/crates/app/src/dlna/cds.rs | 51 +++++-- src-tauri/crates/app/src/dlna/http.rs | 67 ++++++++- src-tauri/crates/app/src/dlna/mod.rs | 203 ++++++++++++++++++++++++-- src-tauri/crates/app/src/dlna/ssdp.rs | 133 +++++++++++++---- 6 files changed, 407 insertions(+), 61 deletions(-) diff --git a/docs/features/dlna.md b/docs/features/dlna.md index d66f11649..52d8d2c82 100644 --- a/docs/features/dlna.md +++ b/docs/features/dlna.md @@ -79,6 +79,16 @@ The `transferMode.dlna.org: Streaming` and `contentFeatures.dlna.org` headers on The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the same `uuid:` URN across launches even when the LAN IP changes — no on-disk persistence needed. +## Which address is advertised + +A desktop often carries a VPN, WSL, Hyper-V or Docker adapter next to its real LAN, frequently listed first, and an address on one of those is one a receiver cannot reach. So nothing advertises a single address picked once: + +- **SSDP** joins the multicast group on **every** IPv4 interface (one that refuses the membership is skipped; only refusing them all fails). Each NOTIFY batch goes out of each interface with that interface's own address in `LOCATION`, and an M-SEARCH reply names the address on the requester's subnet — the preferred address when none matches. +- **HTTP** — `URLBase` in `/description.xml`, and every stream and cover URL in a Browse result, is built from the local address the request arrived on (`http::LocalAddr`, recorded through axum's `ConnectInfo`). The address a controller connected to is reachable from it by construction, whichever interface that is. +- **The preferred address** — the URL shown in Settings, and the fallback above — comes from [`lan_addrs`](../../src-tauri/crates/app/src/dlna/mod.rs), which ranks every non-loopback IPv4 address: a private address on a running, physical interface first; then a non-private one; then anything point-to-point, down, or named like a VPN / VM / container adapter (`vEthernet (…)`, `docker0`, `br-…`, `virbr0`, `tailscale0`, `wg0`, …); link-local (`169.254.x.x`) last. The name match is a heuristic, so it only demotes, never drops: a host whose only address looks virtual is still served on it. Within a rank the OS order holds. + +The interface list is read once when the server starts. After a network change (new Wi-Fi, a VPN coming up), toggle the server off and on to pick it up. There is no setting to pin an interface. + ## Range streaming `/stream/` parses the `Range:` header, replies with `206 Partial Content` + `Content-Range`, and pipes the file through `tokio_util::io::ReaderStream::with_capacity(64 KiB)`. `take(length)` caps the reader so we never overshoot the window even if the controller closes early. @@ -88,7 +98,7 @@ The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the Failures inside the worker are surfaced through `DlnaStatus.last_error` so the Settings UI can display them inline: - Bind failure (port in use, no privilege) → `"bind 0.0.0.0:1234: …"` and the server stays in the stopped state. -- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"`. +- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"` — including `"SSDP: no IPv4 network interface"` when the host has no address to advertise, rather than announcing `0.0.0.0`. ## Limitations / not implemented yet diff --git a/docs/features/mpd.md b/docs/features/mpd.md index bf78db5b1..ea46e011f 100644 --- a/docs/features/mpd.md +++ b/docs/features/mpd.md @@ -54,6 +54,8 @@ Persisted in the global `app_setting` table — the listener is process-wide, no The server binds every interface, matching [DLNA](dlna.md). Settled in #471 for two reasons. +A client connects to us, so no address is advertised on the wire; the one in `MpdStatus.bound_address` is only what Settings displays. It is the DLNA server's preferred address ([which one](dlna.md#which-address-is-advertised)): a real LAN interface ahead of a VPN, WSL, Hyper-V or Docker adapter listed before it. + **It matches what we already ship.** [`dlna/mod.rs`](../../src-tauri/crates/app/src/dlna/mod.rs) binds `0.0.0.0` and the DLNA HTTP layer has no authentication at all (UPnP has no such concept). WaveFlow therefore already exposes an opt-in, default-off, LAN-bound, zero-auth service — one that exposes strictly _more_ than MPD control does: | | DLNA | MPD | diff --git a/src-tauri/crates/app/src/dlna/cds.rs b/src-tauri/crates/app/src/dlna/cds.rs index d89a01442..ffec3c0bf 100644 --- a/src-tauri/crates/app/src/dlna/cds.rs +++ b/src-tauri/crates/app/src/dlna/cds.rs @@ -30,14 +30,17 @@ use std::sync::Arc; use axum::{ - extract::State, + extract::{ConnectInfo, State}, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use serde::Deserialize; use sqlx::SqlitePool; -use crate::dlna::{description::xml_escape, http::ServerCtx}; +use crate::dlna::{ + description::xml_escape, + http::{LocalAddr, ServerCtx}, +}; /// Maximum rows we'll ever return in a single Browse call. Even when /// the controller asks for "all" (count = 0), we cap at this value to @@ -104,14 +107,18 @@ fn track_parent_id(album_id: Option) -> String { /// SOAP control endpoint handler. Decodes the envelope, dispatches by /// action name, returns the appropriate SOAP response (or fault). -pub async fn handle_control(State(ctx): State>, body: String) -> Response { +pub async fn handle_control( + State(ctx): State>, + ConnectInfo(conn): ConnectInfo, + body: String, +) -> Response { let action = match parse_soap_action(&body) { Some(a) => a, None => return soap_fault(401, "Invalid Action"), }; match action { - SoapAction::Browse(req) => match browse(&ctx, &req).await { + SoapAction::Browse(req) => match browse(&ctx, &ctx.base_url_for(conn), &req).await { Ok(resp) => soap_response("Browse", &resp), Err(err) => { tracing::warn!(?err, "Browse failed"); @@ -212,7 +219,13 @@ struct BrowseResult { total_matches: i64, } -async fn browse(ctx: &ServerCtx, req: &BrowseRequest) -> Result { +/// `base` prefixes the stream and artwork URLs: the address this +/// request reached us on (see [`ServerCtx::base_url_for`]). +async fn browse( + ctx: &ServerCtx, + base: &str, + req: &BrowseRequest, +) -> Result { // Resolve effective limit + offset. RequestedCount = 0 means // "everything" per the spec — we cap at MAX_PAGE_SIZE. let count = if req.requested_count <= 0 { @@ -223,7 +236,7 @@ async fn browse(ctx: &ServerCtx, req: &BrowseRequest) -> Result Result browse_artists(&ctx.pool, count, offset).await, Some(ObjectId::Artist(id)) => browse_artist_albums(&ctx.pool, id, count, offset).await, Some(ObjectId::Albums) => browse_albums(&ctx.pool, count, offset).await, - Some(ObjectId::Album(id)) => browse_album_tracks(ctx, id, count, offset).await, + Some(ObjectId::Album(id)) => browse_album_tracks(ctx, base, id, count, offset).await, // A track has no children, and an unknown id gets an empty // DIDL rather than a fault so controllers gracefully render // an empty folder. @@ -431,6 +444,7 @@ struct TrackRow { async fn browse_album_tracks( ctx: &ServerCtx, + base: &str, album_id: i64, count: i64, offset: i64, @@ -465,7 +479,7 @@ async fn browse_album_tracks( let parent = format!("0/albums/{album_id}"); let mut body = String::new(); for row in &rows { - body.push_str(&item_xml(ctx, row, &parent)); + body.push_str(&item_xml(base, row, &parent)); } Ok(BrowseResult { didl: didl_envelope(&body), @@ -474,9 +488,13 @@ async fn browse_album_tracks( }) } -async fn browse_metadata(ctx: &ServerCtx, object_id: &str) -> Result { +async fn browse_metadata( + ctx: &ServerCtx, + base: &str, + object_id: &str, +) -> Result { let body = match ObjectId::parse(object_id) { - Some(ObjectId::Track(track_id)) => track_metadata(ctx, track_id).await?, + Some(ObjectId::Track(track_id)) => track_metadata(ctx, base, track_id).await?, Some(container) => container_metadata(&ctx.pool, container).await?, None => None, }; @@ -536,7 +554,11 @@ async fn container_metadata( }) } -async fn track_metadata(ctx: &ServerCtx, track_id: i64) -> Result, sqlx::Error> { +async fn track_metadata( + ctx: &ServerCtx, + base: &str, + track_id: i64, +) -> Result, sqlx::Error> { // Column aliases must match `TrackRow`: `ar.name AS artist_name` // (not `t.primary_artist`) and `t.bitrate AS bitrate` (the column // is `bitrate`, not `bit_rate` — the earlier shape would fail at @@ -559,7 +581,7 @@ async fn track_metadata(ctx: &ServerCtx, track_id: i64) -> Result .bind(track_id) .fetch_optional(&ctx.pool) .await?; - Ok(row.map(|row| item_xml(ctx, &row, &track_parent_id(row.album_id)))) + Ok(row.map(|row| item_xml(base, &row, &track_parent_id(row.album_id)))) } /// Wrap inner content in the standard DIDL-Lite envelope. The four @@ -580,9 +602,9 @@ fn container_xml(id: &str, parent: &str, title: &str, child_count: Option) ) } -fn item_xml(ctx: &ServerCtx, t: &TrackRow, parent: &str) -> String { +fn item_xml(base: &str, t: &TrackRow, parent: &str) -> String { let mime = mime_for_path(&t.file_path); - let stream_url = format!("{}/stream/{}", ctx.base_url, t.id); + let stream_url = format!("{base}/stream/{}", t.id); let duration = format_duration_hms(t.duration_ms); let size_attr = format!(r#" size="{}""#, t.file_size); let bitrate_attr = t @@ -620,7 +642,6 @@ fn item_xml(ctx: &ServerCtx, t: &TrackRow, parent: &str) -> String { let ext = t.artwork_format.as_deref().unwrap_or("jpg"); format!( r#"{base}/art/{h}.{ext}"#, - base = ctx.base_url, ) }) .unwrap_or_default(); diff --git a/src-tauri/crates/app/src/dlna/http.rs b/src-tauri/crates/app/src/dlna/http.rs index 16b5b5bc6..f82b3dfff 100644 --- a/src-tauri/crates/app/src/dlna/http.rs +++ b/src-tauri/crates/app/src/dlna/http.rs @@ -11,19 +11,22 @@ //! shared metadata_artwork dir) //! GET /healthz — diag (always 200 "ok") +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use axum::{ body::Body, - extract::{Path, State}, + extract::{connect_info::Connected, ConnectInfo, Path, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, + serve::IncomingStream, Router, }; use sqlx::SqlitePool; use tokio::io::{AsyncSeekExt, SeekFrom}; +use tokio::net::TcpListener; use tokio_util::io::ReaderStream; use crate::dlna::{cds, description}; @@ -34,12 +37,47 @@ use crate::dlna::{cds, description}; #[derive(Clone)] pub struct ServerCtx { pub server_name: String, + /// The preferred address, used only when a connection's own local + /// address is unknown (see [`ServerCtx::base_url_for`]). pub base_url: String, pub pool: SqlitePool, pub profile_artwork_dir: PathBuf, pub metadata_artwork_dir: PathBuf, } +impl ServerCtx { + /// The base of every URL handed to the peer behind `conn`: the + /// address that peer reached us on. On a host with several + /// interfaces the preferred one may sit on a network the peer + /// cannot route to, while the one it connected to is reachable by + /// construction. + pub fn base_url_for(&self, conn: LocalAddr) -> String { + base_url(conn.0, &self.base_url) + } +} + +/// The local end of an accepted connection, recorded by the server +/// (`into_make_service_with_connect_info`) for [`ServerCtx::base_url_for`]. +#[derive(Debug, Clone, Copy)] +pub struct LocalAddr(pub Option); + +impl Connected> for LocalAddr { + fn connect_info(stream: IncomingStream<'_, TcpListener>) -> Self { + Self(stream.io().local_addr().ok()) + } +} + +fn base_url(local: Option, fallback: &str) -> String { + match local { + Some(addr) if !addr.ip().is_unspecified() => { + // A dual-stack socket reports an IPv4 peer as `::ffff:a.b.c.d`. + let addr = SocketAddr::new(addr.ip().to_canonical(), addr.port()); + format!("http://{addr}") + } + _ => fallback.to_string(), + } +} + pub fn router(ctx: ServerCtx) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) @@ -84,8 +122,11 @@ pub fn router(ctx: ServerCtx) -> Router { .with_state(Arc::new(ctx)) } -async fn serve_description(State(ctx): State>) -> Response { - let body = description::device_descriptor(&ctx.server_name, &ctx.base_url); +async fn serve_description( + State(ctx): State>, + ConnectInfo(conn): ConnectInfo, +) -> Response { + let body = description::device_descriptor(&ctx.server_name, &ctx.base_url_for(conn)); xml_response(body) } @@ -281,6 +322,26 @@ fn mime_for_art(name: &str) -> &'static str { mod tests { use super::*; + #[test] + fn base_url_names_the_address_the_peer_connected_to() { + let fallback = "http://192.168.1.20:8200"; + let local = |s: &str| Some(s.parse::().unwrap()); + assert_eq!( + base_url(local("172.20.0.1:8200"), fallback), + "http://172.20.0.1:8200" + ); + assert_eq!( + base_url(local("[::ffff:10.0.0.2]:8200"), fallback), + "http://10.0.0.2:8200" + ); + assert_eq!( + base_url(local("[fe80::1]:8200"), fallback), + "http://[fe80::1]:8200" + ); + assert_eq!(base_url(local("0.0.0.0:8200"), fallback), fallback); + assert_eq!(base_url(None, fallback), fallback); + } + #[test] fn parse_range_full_form() { assert_eq!(parse_range("bytes=0-99", 1000), Some((0, 99))); diff --git a/src-tauri/crates/app/src/dlna/mod.rs b/src-tauri/crates/app/src/dlna/mod.rs index 97ff20d3f..e77b2d7ed 100644 --- a/src-tauri/crates/app/src/dlna/mod.rs +++ b/src-tauri/crates/app/src/dlna/mod.rs @@ -43,6 +43,7 @@ pub mod description; pub mod http; pub mod ssdp; +use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; use crossbeam_channel::{unbounded, Sender}; @@ -198,7 +199,14 @@ impl WorkerState { return; } }; - let lan_ip = pick_lan_ip().unwrap_or_else(|| actual.ip().to_string()); + // One snapshot of the interfaces for the whole session: SSDP + // joins each of them, and the first one is the address shown in + // Settings. Each HTTP reply names the address its own request + // came in on instead (see `http::LocalAddr`). + let lan = lan_addrs(); + let lan_ip = lan + .first() + .map_or_else(|| actual.ip().to_string(), |a| a.ip.to_string()); let url = format!("http://{lan_ip}:{}", actual.port()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -213,7 +221,10 @@ impl WorkerState { let app = http::router(ctx); tokio::spawn(async move { - let serve = axum::serve(listener, app); + let serve = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); tokio::select! { res = serve => { if let Err(err) = res { @@ -231,7 +242,7 @@ impl WorkerState { // our LOCATION can keep talking to us; only auto-discovery // breaks. Surface the error in `last_error` so the Settings // UI can warn the user. - let ssdp_err = match ssdp::spawn(cfg.server_name.clone(), lan_ip.clone(), actual.port()) { + let ssdp_err = match ssdp::spawn(cfg.server_name.clone(), lan, actual.port()) { Ok(handle) => { self.ssdp = Some(handle); None @@ -267,19 +278,179 @@ impl WorkerState { } } -/// Pick the first non-loopback IPv4 interface. Returned as a string -/// because every consumer (description.xml, status URL, SSDP LOCATION, -/// and the MPD server's advertised address) formats it back into URLs -/// anyway — keeping it as `String` avoids re-running the lookup at each -/// touch point. -pub(crate) fn pick_lan_ip() -> Option { - let addrs = if_addrs::get_if_addrs().ok()?; - addrs +/// One IPv4 address of this host, with the netmask that tells which +/// peers sit on the same link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LanAddr { + pub ip: Ipv4Addr, + pub netmask: Ipv4Addr, +} + +impl LanAddr { + /// Whether `peer` is on this address's subnet. A `/0` mask would + /// claim every peer, so it claims none. + pub fn contains(&self, peer: Ipv4Addr) -> bool { + let mask = u32::from(self.netmask); + mask != 0 && u32::from(self.ip) & mask == u32::from(peer) & mask + } +} + +/// The address to hand a peer: the one on its subnet, else the +/// preferred one. An IPv4-mapped IPv6 peer counts as IPv4. +pub(crate) fn local_ip_for(addrs: &[LanAddr], peer: IpAddr) -> Option { + let on_link = match peer.to_canonical() { + IpAddr::V4(v4) => addrs.iter().find(|a| a.contains(v4)), + IpAddr::V6(_) => None, + }; + on_link.or_else(|| addrs.first()).map(|a| a.ip) +} + +/// Every non-loopback IPv4 address of this host, best first (see +/// [`interface_rank`]). Empty when enumeration fails. +pub(crate) fn lan_addrs() -> Vec { + let interfaces = match if_addrs::get_if_addrs() { + Ok(interfaces) => interfaces, + Err(err) => { + tracing::warn!(?err, "listing network interfaces failed"); + return Vec::new(); + } + }; + let mut ranked: Vec<(u8, LanAddr)> = interfaces .into_iter() - .filter(|a| !a.is_loopback()) - .filter_map(|a| match a.ip() { - std::net::IpAddr::V4(v4) => Some(v4.to_string()), - std::net::IpAddr::V6(_) => None, + .filter(|i| !i.is_loopback()) + .filter_map(|i| match &i.addr { + if_addrs::IfAddr::V4(v4) => Some(( + interface_rank(&i.name, v4.ip, i.is_oper_up(), i.is_p2p()), + LanAddr { + ip: v4.ip, + netmask: v4.netmask, + }, + )), + if_addrs::IfAddr::V6(_) => None, }) - .next() + .collect(); + // Stable: within a rank, the OS order still decides. + ranked.sort_by_key(|(rank, _)| *rank); + ranked.into_iter().map(|(_, addr)| addr).collect() +} + +/// Lower is better. A receiver can only reach an address on its own +/// LAN, so a private address on a physical, running interface comes +/// first; a VPN tunnel or a VM / container bridge (Docker, WSL and +/// Hyper-V's default switch hand out private 172.x addresses too) is +/// demoted below any of those, and link-local comes last. Nothing is +/// dropped: a host whose only address looks virtual still gets served +/// on it. +fn interface_rank(name: &str, ip: Ipv4Addr, up: bool, p2p: bool) -> u8 { + if ip.is_link_local() { + return 4; + } + let demoted = !up || p2p || looks_virtual(name); + match (demoted, ip.is_private()) { + (false, true) => 0, + (false, false) => 1, + (true, true) => 2, + (true, false) => 3, + } +} + +/// Interface names that virtualisation and VPN software give the +/// adapters it creates: the adapter's friendly name on Windows, the +/// kernel name on unix. A heuristic, which is why it only ever demotes +/// (see [`interface_rank`]). +fn looks_virtual(name: &str) -> bool { + const PREFIXES: &[&str] = &[ + "docker", "br-", "veth", "virbr", "vboxnet", "vmnet", "tun", "tap", "wg", "zt", "utun", + "lxcbr", "lxdbr", "podman", "cni", "flannel", + ]; + const WORDS: &[&str] = &[ + "vethernet", + "virtualbox", + "vmware", + "hyper-v", + "wsl", + "docker", + "tailscale", + "zerotier", + "wireguard", + "openvpn", + "nordlynx", + "vpn", + ]; + let name = name.to_ascii_lowercase(); + PREFIXES.iter().any(|p| name.starts_with(p)) || WORDS.iter().any(|w| name.contains(w)) +} + +/// The preferred LAN address, for a display that can show only one: +/// the DLNA status URL, and the address the MPD server shows in +/// Settings. +pub(crate) fn pick_lan_ip() -> Option { + lan_addrs().first().map(|a| a.ip.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn addr(ip: [u8; 4], prefix: u32) -> LanAddr { + LanAddr { + ip: Ipv4Addr::from(ip), + netmask: Ipv4Addr::from(u32::MAX.checked_shl(32 - prefix).unwrap_or(0)), + } + } + + #[test] + fn a_physical_lan_beats_a_virtual_bridge_listed_first() { + let wsl = interface_rank("vEthernet (WSL)", Ipv4Addr::new(172, 20, 0, 1), true, false); + let docker = interface_rank("docker0", Ipv4Addr::new(172, 17, 0, 1), true, false); + let wifi = interface_rank("Wi-Fi", Ipv4Addr::new(192, 168, 1, 20), true, false); + assert!(wifi < wsl && wifi < docker); + } + + #[test] + fn tunnels_down_links_and_link_local_are_demoted() { + let lan = interface_rank("eth0", Ipv4Addr::new(10, 0, 0, 2), true, false); + let tunnel = interface_rank("ppp0", Ipv4Addr::new(10, 8, 0, 2), true, true); + let down = interface_rank("eth1", Ipv4Addr::new(192, 168, 2, 2), false, false); + let apipa = interface_rank("Ethernet 2", Ipv4Addr::new(169, 254, 3, 4), true, false); + let vpn = interface_rank("tailscale0", Ipv4Addr::new(100, 64, 0, 7), true, false); + assert!(lan < tunnel && lan < down && lan < vpn); + assert!(vpn < apipa); + } + + #[test] + fn a_physical_public_address_beats_a_virtual_private_one() { + let public = interface_rank("eno1", Ipv4Addr::new(130, 79, 1, 2), true, false); + let bridge = interface_rank("virbr0", Ipv4Addr::new(192, 168, 122, 1), true, false); + assert!(public < bridge); + } + + #[test] + fn a_plain_lan_name_is_not_virtual() { + for name in ["eth0", "enp3s0", "wlan0", "en0", "Ethernet", "Wi-Fi", "br0"] { + assert!(!looks_virtual(name), "{name}"); + } + } + + #[test] + fn the_peer_gets_the_address_on_its_subnet() { + let addrs = [addr([192, 168, 1, 20], 24), addr([172, 20, 0, 1], 20)]; + let peer = |ip: [u8; 4]| IpAddr::V4(Ipv4Addr::from(ip)); + let wsl = Some(Ipv4Addr::new(172, 20, 0, 1)); + let lan = Some(Ipv4Addr::new(192, 168, 1, 20)); + assert_eq!(local_ip_for(&addrs, peer([172, 20, 3, 9])), wsl); + assert_eq!(local_ip_for(&addrs, peer([192, 168, 1, 77])), lan); + // Off every subnet (a routed peer): the preferred address. + assert_eq!(local_ip_for(&addrs, peer([10, 1, 1, 1])), lan); + let mapped = IpAddr::V6(Ipv4Addr::new(172, 20, 3, 9).to_ipv6_mapped()); + assert_eq!(local_ip_for(&addrs, mapped), wsl); + assert_eq!(local_ip_for(&[], peer([10, 1, 1, 1])), None); + } + + #[test] + fn a_zero_mask_claims_no_peer() { + assert!(!addr([10, 0, 0, 1], 0).contains(Ipv4Addr::new(192, 168, 1, 1))); + assert!(addr([10, 0, 0, 1], 32).contains(Ipv4Addr::new(10, 0, 0, 1))); + assert!(!addr([10, 0, 0, 1], 32).contains(Ipv4Addr::new(10, 0, 0, 2))); + } } diff --git a/src-tauri/crates/app/src/dlna/ssdp.rs b/src-tauri/crates/app/src/dlna/ssdp.rs index 8bf715c0f..d33c60feb 100644 --- a/src-tauri/crates/app/src/dlna/ssdp.rs +++ b/src-tauri/crates/app/src/dlna/ssdp.rs @@ -16,6 +16,16 @@ //! and reply with a unicast HTTP/1.1 200 OK pointing at our //! `/description.xml`. //! +//! ## One host, several interfaces +//! +//! A desktop routinely carries a VPN, WSL, Hyper-V or Docker adapter +//! next to its real LAN, often listed first. The group is joined on +//! every IPv4 interface, each NOTIFY batch goes out of each interface +//! with that interface's own address in `LOCATION`, and an M-SEARCH +//! reply names the address on the requester's subnet (the preferred +//! one when none matches). An address the receiver cannot reach is +//! never the only one it hears about. +//! //! ## Search targets answered //! //! - `ssdp:all` — everything @@ -32,7 +42,7 @@ //! [`WorkerState`](super::WorkerState); a Stop or Reconfigure replaces //! the handle, terminating the previous announcer cleanly. -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; use std::time::Duration; @@ -40,7 +50,7 @@ use socket2::{Domain, Protocol, Socket, Type}; use tokio::net::UdpSocket; use tokio::task::JoinHandle; -use crate::dlna::description::device_uuid; +use crate::dlna::{description::device_uuid, local_ip_for, LanAddr}; /// Standard SSDP multicast endpoint. Same on every UPnP network. const SSDP_ADDR: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(239, 255, 255, 250), 1900); @@ -64,26 +74,32 @@ impl Drop for SsdpHandle { /// Spawn the announcer + M-SEARCH responder tasks. /// -/// `lan_ip` is the address advertised in `LOCATION:` headers — the -/// caller has already resolved the host's primary LAN interface so -/// controllers can reach `/description.xml`. +/// `addrs` are the host's IPv4 addresses, best first (see +/// [`lan_addrs`](super::lan_addrs)); each one is joined to the group +/// and advertised on its own link. /// /// `port` is the HTTP port the description lives on (already bound /// by the [`http`](super::http) layer). -pub fn spawn(server_name: String, lan_ip: String, port: u16) -> std::io::Result { +pub fn spawn(server_name: String, addrs: Vec, port: u16) -> std::io::Result { + if addrs.is_empty() { + // Nothing to advertise that a receiver could reach: say so + // rather than announce `0.0.0.0`. + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no IPv4 network interface", + )); + } let uuid = device_uuid(&server_name).to_string(); - let location = format!("http://{lan_ip}:{port}/description.xml"); let server_header = "WaveFlow/0.1 UPnP/1.0 WaveFlowMediaServer/1.0".to_string(); + let mcast_socket = build_multicast_socket(&addrs)?; let shared = Arc::new(SsdpShared { uuid, - location, + addrs, + port, server: server_header, }); - let bind_ip: Ipv4Addr = lan_ip.parse().unwrap_or(Ipv4Addr::UNSPECIFIED); - let mcast_socket = build_multicast_socket(bind_ip)?; - let announcer = tokio::spawn(announce_loop(shared.clone())); let responder = tokio::spawn(respond_loop(shared, mcast_socket)); @@ -96,16 +112,31 @@ pub fn spawn(server_name: String, lan_ip: String, port: u16) -> std::io::Result< #[derive(Debug)] struct SsdpShared { uuid: String, - location: String, + addrs: Vec, + port: u16, server: String, } -/// Build a UDP socket joined to the SSDP multicast group. We use -/// socket2 because tokio's `UdpSocket::join_multicast_v4` works but -/// doesn't expose the `SO_REUSEADDR` / `SO_REUSEPORT` flags we need -/// for the responder to coexist with other UPnP services running on -/// the same machine (e.g. Windows Media Player). -fn build_multicast_socket(interface: Ipv4Addr) -> std::io::Result { +impl SsdpShared { + fn location(&self, ip: Ipv4Addr) -> String { + location_url(ip, self.port) + } +} + +fn location_url(ip: Ipv4Addr, port: u16) -> String { + format!("http://{ip}:{port}/description.xml") +} + +/// Build a UDP socket joined to the SSDP multicast group on every +/// address in `addrs`. We use socket2 because tokio's +/// `UdpSocket::join_multicast_v4` works but doesn't expose the +/// `SO_REUSEADDR` / `SO_REUSEPORT` flags we need for the responder to +/// coexist with other UPnP services running on the same machine (e.g. +/// Windows Media Player). +/// +/// An interface that refuses the membership (a link that is down, a +/// tunnel without multicast) is skipped; only refusing them all fails. +fn build_multicast_socket(addrs: &[LanAddr]) -> std::io::Result { let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; // SO_REUSEADDR is enough for our use case: it lets the SSDP // socket coexist with other UPnP services already bound to @@ -117,7 +148,20 @@ fn build_multicast_socket(interface: Ipv4Addr) -> std::io::Result { sock.set_nonblocking(true)?; let bind: SocketAddr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 1900).into(); sock.bind(&bind.into())?; - sock.join_multicast_v4(SSDP_ADDR.ip(), &interface)?; + let mut joined = 0; + let mut last_err = None; + for addr in addrs { + match sock.join_multicast_v4(SSDP_ADDR.ip(), &addr.ip) { + Ok(()) => joined += 1, + Err(err) => { + tracing::debug!(ip = %addr.ip, ?err, "SSDP join failed on interface"); + last_err = Some(err); + } + } + } + if joined == 0 { + return Err(last_err.unwrap_or_else(|| std::io::ErrorKind::NotFound.into())); + } sock.set_multicast_loop_v4(false)?; sock.set_multicast_ttl_v4(4)?; @@ -133,16 +177,35 @@ async fn announce_loop(shared: Arc) { // First batch goes out immediately so controllers learn about us // without waiting the full interval. loop { - if let Err(err) = send_alive_batch(&shared).await { - tracing::warn!(?err, "SSDP NOTIFY alive failed"); + // One batch per interface: a multicast frame leaves through a + // single interface, so one batch would reach one link only. + let mut sent = false; + for addr in &shared.addrs { + match send_alive_batch(&shared, addr.ip).await { + Ok(()) => sent = true, + Err(err) => tracing::debug!(ip = %addr.ip, ?err, "SSDP NOTIFY failed on interface"), + } + } + if !sent { + tracing::warn!("SSDP NOTIFY alive failed on every interface"); } tokio::time::sleep(interval).await; } } -async fn send_alive_batch(shared: &SsdpShared) -> std::io::Result<()> { - let socket = UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)).await?; - socket.set_multicast_ttl_v4(4)?; +/// Send one NOTIFY batch out of the interface that owns `ip`, +/// advertising `ip` itself. +async fn send_alive_batch(shared: &SsdpShared, ip: Ipv4Addr) -> std::io::Result<()> { + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + sock.set_nonblocking(true)?; + // Name the egress interface and bind its address too, so the frame + // leaves through that link and carries a source address on it. + let bind: SocketAddr = SocketAddrV4::new(ip, 0).into(); + sock.bind(&bind.into())?; + sock.set_multicast_if_v4(&ip)?; + sock.set_multicast_ttl_v4(4)?; + let socket = UdpSocket::from_std(sock.into())?; + let location = shared.location(ip); for nt in NOTIFICATION_TARGETS { let nt_str = expand_target(nt, &shared.uuid); let usn = build_usn(&nt_str, &shared.uuid); @@ -155,7 +218,6 @@ async fn send_alive_batch(shared: &SsdpShared) -> std::io::Result<()> { NTS: ssdp:alive\r\n\ SERVER: {server}\r\n\ USN: {usn}\r\n\r\n", - location = shared.location, server = shared.server, ); socket.send_to(frame.as_bytes(), SSDP_ADDR).await?; @@ -187,6 +249,12 @@ async fn respond_loop(shared: Arc, socket: UdpSocket) { Some(s) => s, None => continue, }; + // The address on the requester's subnet is one it can reach; + // `spawn` refuses an empty list, so there is always a fallback. + let Some(ip) = local_ip_for(&shared.addrs, peer.ip()) else { + continue; + }; + let location = shared.location(ip); let targets = matching_targets(&st, &shared.uuid); for target in targets { let usn = build_usn(&target, &shared.uuid); @@ -198,7 +266,6 @@ async fn respond_loop(shared: Arc, socket: UdpSocket) { SERVER: {server}\r\n\ ST: {target}\r\n\ USN: {usn}\r\n\r\n", - location = shared.location, server = shared.server, ); if let Err(err) = socket.send_to(reply.as_bytes(), peer).await { @@ -308,6 +375,20 @@ mod tests { assert!(matching_targets("urn:foo:bar", "abc").is_empty()); } + #[test] + fn location_names_the_interface_address() { + assert_eq!( + location_url(Ipv4Addr::new(192, 168, 1, 20), 8200), + "http://192.168.1.20:8200/description.xml" + ); + } + + #[test] + fn spawn_refuses_an_empty_interface_list() { + let err = spawn("WaveFlow".into(), Vec::new(), 8200).err(); + assert_eq!(err.map(|e| e.kind()), Some(std::io::ErrorKind::NotFound)); + } + #[test] fn build_usn_handles_uuid_only_target() { assert_eq!(build_usn("uuid:abc", "abc"), "uuid:abc");