UPNP: send notifies on all interfaces, no need to specify hostname anymore

This commit is contained in:
Igor Katson 2024-08-28 13:38:11 +01:00
parent 0214817122
commit b174afaa12
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
12 changed files with 101 additions and 65 deletions

1
Cargo.lock generated
View file

@ -2493,6 +2493,7 @@ dependencies = [
"clap_complete", "clap_complete",
"console-subscriber", "console-subscriber",
"futures", "futures",
"gethostname",
"libc", "libc",
"librqbit", "librqbit",
"librqbit-upnp-serve", "librqbit-upnp-serve",

View file

@ -16,7 +16,7 @@ webui-build: webui-deps
npm run build npm run build
# NOTE: on LG TV using hostname is unstable for some reason, use IP address. # NOTE: on LG TV using hostname is unstable for some reason, use IP address.
export RQBIT_UPNP_SERVER_HOSTNAME ?= $(shell hostname) export RQBIT_UPNP_SERVER_ENABLE ?= true
export RQBIT_UPNP_SERVER_FRIENDLY_NAME ?= rqbit-dev export RQBIT_UPNP_SERVER_FRIENDLY_NAME ?= rqbit-dev
export RQBIT_HTTP_API_LISTEN_ADDR ?= 0.0.0.0:3030 export RQBIT_HTTP_API_LISTEN_ADDR ?= 0.0.0.0:3030
RQBIT_OUTPUT_FOLDER ?= /tmp/scratch RQBIT_OUTPUT_FOLDER ?= /tmp/scratch

View file

@ -11,7 +11,6 @@ use crate::{session::TorrentId, ManagedTorrent, Session};
#[derive(Clone)] #[derive(Clone)]
pub struct UpnpServerSessionAdapter { pub struct UpnpServerSessionAdapter {
session: Arc<Session>, session: Arc<Session>,
hostname: String,
port: u16, port: u16,
} }
@ -54,6 +53,7 @@ impl TorrentFileTreeNode {
fn as_item_or_container( fn as_item_or_container(
&self, &self,
id: usize, id: usize,
http_host: &str,
torrent: &ManagedTorrent, torrent: &ManagedTorrent,
adapter: &UpnpServerSessionAdapter, adapter: &UpnpServerSessionAdapter,
) -> ItemOrContainer { ) -> ItemOrContainer {
@ -79,7 +79,7 @@ impl TorrentFileTreeNode {
mime_type: mime_guess::from_path(filename).first(), mime_type: mime_guess::from_path(filename).first(),
url: format!( url: format!(
"http://{}:{}/torrents/{}/stream/{}/{}", "http://{}:{}/torrents/{}/stream/{}/{}",
adapter.hostname, http_host,
adapter.port, adapter.port,
torrent.id(), torrent.id(),
fid, fid,
@ -197,7 +197,7 @@ impl TorrentFileTree {
} }
impl UpnpServerSessionAdapter { impl UpnpServerSessionAdapter {
fn build_root(&self) -> Vec<ItemOrContainer> { fn build_root(&self, hostname: &str) -> Vec<ItemOrContainer> {
let mut all = self let mut all = self
.session .session
.with_torrents(|torrents| torrents.map(|(_, t)| t.clone()).collect_vec()); .with_torrents(|torrents| torrents.map(|(_, t)| t.clone()).collect_vec());
@ -216,7 +216,7 @@ impl UpnpServerSessionAdapter {
let mime_type = mime_guess::from_path(rf).first(); let mime_type = mime_guess::from_path(rf).first();
let url = format!( let url = format!(
"http://{}:{}/torrents/{real_id}/stream/0/{title}", "http://{}:{}/torrents/{real_id}/stream/0/{title}",
self.hostname, self.port hostname, self.port
); );
Some(ItemOrContainer::Item(Item { Some(ItemOrContainer::Item(Item {
id: upnp_id, id: upnp_id,
@ -249,9 +249,13 @@ impl UpnpServerSessionAdapter {
} }
impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter {
fn browse_direct_children(&self, parent_id: usize) -> Vec<ItemOrContainer> { fn browse_direct_children(
&self,
parent_id: usize,
http_hostname: &str,
) -> Vec<ItemOrContainer> {
if parent_id == 0 { if parent_id == 0 {
return self.build_root(); return self.build_root(http_hostname);
} }
let (node_id, torrent_id) = match decode_id(parent_id) { let (node_id, torrent_id) = match decode_id(parent_id) {
@ -292,14 +296,19 @@ impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter {
let mut result = Vec::new(); let mut result = Vec::new();
if node.real_torrent_file_id.is_some() { if node.real_torrent_file_id.is_some() {
result.push(node.as_item_or_container(node_id, &torrent, self)) result.push(node.as_item_or_container(node_id, http_hostname, &torrent, self))
} else { } else {
for (child_node_id, child_node) in node for (child_node_id, child_node) in node
.children .children
.iter() .iter()
.filter_map(|id| Some((*id, tree.nodes.get(*id)?))) .filter_map(|id| Some((*id, tree.nodes.get(*id)?)))
{ {
result.push(child_node.as_item_or_container(child_node_id, &torrent, self)); result.push(child_node.as_item_or_container(
child_node_id,
http_hostname,
&torrent,
self,
));
} }
}; };
@ -311,17 +320,14 @@ impl Session {
pub async fn make_upnp_adapter( pub async fn make_upnp_adapter(
self: &Arc<Self>, self: &Arc<Self>,
friendly_name: String, friendly_name: String,
http_hostname: String,
http_listen_port: u16, http_listen_port: u16,
) -> anyhow::Result<UpnpServer> { ) -> anyhow::Result<UpnpServer> {
UpnpServer::new(UpnpServerOptions { UpnpServer::new(UpnpServerOptions {
friendly_name, friendly_name,
http_hostname: http_hostname.clone(),
http_listen_port, http_listen_port,
http_prefix: "/upnp".to_owned(), http_prefix: "/upnp".to_owned(),
browse_provider: Box::new(UpnpServerSessionAdapter { browse_provider: Box::new(UpnpServerSessionAdapter {
session: self.clone(), session: self.clone(),
hostname: http_hostname,
port: http_listen_port, port: http_listen_port,
}), }),
cancellation_token: self.cancellation_token().child_token(), cancellation_token: self.cancellation_token().child_token(),
@ -536,12 +542,11 @@ mod tests {
let adapter = UpnpServerSessionAdapter { let adapter = UpnpServerSessionAdapter {
session, session,
hostname: "127.0.0.1".into(),
port: 9005, port: 9005,
}; };
assert_eq!( assert_eq!(
adapter.browse_direct_children(0), adapter.browse_direct_children(0, "127.0.0.1"),
vec![ vec![
ItemOrContainer::Item(Item { ItemOrContainer::Item(Item {
id: encode_id(0, 0), id: encode_id(0, 0),
@ -560,7 +565,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
adapter.browse_direct_children(encode_id(0, 1)), adapter.browse_direct_children(encode_id(0, 1), "127.0.0.1"),
vec![ItemOrContainer::Container(Container { vec![ItemOrContainer::Container(Container {
id: encode_id(1, 1), id: encode_id(1, 1),
parent_id: Some(encode_id(0, 1)), parent_id: Some(encode_id(0, 1)),
@ -570,7 +575,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
adapter.browse_direct_children(encode_id(1, 1)), adapter.browse_direct_children(encode_id(1, 1), "127.0.0.1"),
vec![ItemOrContainer::Item(Item { vec![ItemOrContainer::Item(Item {
id: encode_id(2, 1), id: encode_id(2, 1),
parent_id: Some(encode_id(1, 1)), parent_id: Some(encode_id(1, 1)),

View file

@ -48,6 +48,7 @@ upnp-serve = { path = "../upnp-serve", default-features = false, version = "0.1.
libc = "0.2.158" libc = "0.2.158"
signal-hook = "0.3.17" signal-hook = "0.3.17"
tokio-util = "0.7.11" tokio-util = "0.7.11"
gethostname = "0.5.0"
[dev-dependencies] [dev-dependencies]
futures = { version = "0.3" } futures = { version = "0.3" }

View file

@ -139,13 +139,16 @@ struct Opts {
tcp_listen_max_port: u16, tcp_listen_max_port: u16,
/// If set, will try to publish the chosen port through upnp on your router. /// If set, will try to publish the chosen port through upnp on your router.
#[arg(long = "disable-upnp", env = "RQBIT_UPNP_DISABLE_PORT_FORWARD")] #[arg(
disable_upnp: bool, long = "disable-upnp-port-forward",
env = "RQBIT_UPNP_PORT_FORWARD_DISABLE"
)]
disable_upnp_port_forward: bool,
/// If set, will run a UPNP Media server and stream all the torrents through it. /// If set, will run a UPNP Media server and stream all the torrents through it.
/// Should be set to your hostname/IP as seen by your LAN neighbors. /// Should be set to your hostname/IP as seen by your LAN neighbors.
#[arg(long = "upnp-server-hostname", env = "RQBIT_UPNP_SERVER_HOSTNAME")] #[arg(long = "enable-upnp-server", env = "RQBIT_UPNP_SERVER_ENABLE")]
upnp_server_hostname: Option<String>, enable_upnp_server: bool,
/// UPNP server name that would be displayed on devices in your network. /// UPNP server name that would be displayed on devices in your network.
#[arg( #[arg(
@ -425,7 +428,7 @@ async fn async_main(opts: Opts, cancel: CancellationToken) -> anyhow::Result<()>
} else { } else {
None None
}, },
enable_upnp_port_forwarding: !opts.disable_upnp, enable_upnp_port_forwarding: !opts.disable_upnp_port_forward,
defer_writes_up_to: opts.defer_writes_up_to, defer_writes_up_to: opts.defer_writes_up_to,
default_storage_factory: Some({ default_storage_factory: Some({
fn wrap<S: StorageFactory + Clone>(s: S) -> impl StorageFactory { fn wrap<S: StorageFactory + Clone>(s: S) -> impl StorageFactory {
@ -544,23 +547,26 @@ async fn async_main(opts: Opts, cancel: CancellationToken) -> anyhow::Result<()>
); );
let mut upnp_server = { let mut upnp_server = {
match opts.upnp_server_hostname { match opts.enable_upnp_server {
Some(hn) => { true => {
if opts.http_api_listen_addr.ip().is_loopback() { if opts.http_api_listen_addr.ip().is_loopback() {
bail!("cannot enable UPNP server as HTTP API listen addr is localhost. Change --http-api-listen-addr to start with 0.0.0.0"); bail!("cannot enable UPNP server as HTTP API listen addr is localhost. Change --http-api-listen-addr to start with 0.0.0.0");
} }
let server = session let server = session
.make_upnp_adapter( .make_upnp_adapter(
opts.upnp_server_friendly_name opts.upnp_server_friendly_name.unwrap_or_else(|| {
.unwrap_or_else(|| format!("rqbit at {hn}")), format!(
hn, "rqbit@{}",
gethostname::gethostname().to_string_lossy()
)
}),
opts.http_api_listen_addr.port(), opts.http_api_listen_addr.port(),
) )
.await .await
.context("error starting UPNP server")?; .context("error starting UPNP server")?;
Some(server) Some(server)
} }
None => None, false => None,
} }
}; };

View file

@ -34,8 +34,6 @@ async fn main() -> anyhow::Result<()> {
info!("Creating UpnpServer"); info!("Creating UpnpServer");
let mut server = UpnpServer::new(UpnpServerOptions { let mut server = UpnpServer::new(UpnpServerOptions {
friendly_name: "demo upnp server".to_owned(), friendly_name: "demo upnp server".to_owned(),
http_hostname: std::env::var("UPNP_HOSTNAME")
.context("you need to set UPNP_HOSTNAME to your IP visible from LAN")?,
http_listen_port: HTTP_PORT, http_listen_port: HTTP_PORT,
http_prefix: HTTP_PREFIX.to_owned(), http_prefix: HTTP_PREFIX.to_owned(),
browse_provider: Box::new(items), browse_provider: Box::new(items),

View file

@ -52,6 +52,15 @@ async fn generate_content_directory_control_response(
}; };
match action.as_ref() { match action.as_ref() {
SOAP_ACTION_CONTENT_DIRECTORY_BROWSE => { SOAP_ACTION_CONTENT_DIRECTORY_BROWSE => {
let http_hostname = headers
.get("host")
.and_then(|h| std::str::from_utf8(h.as_bytes()).ok())
.and_then(|h| h.split(':').next());
let http_hostname = match http_hostname {
Some(h) => h,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let body = match std::str::from_utf8(body) { let body = match std::str::from_utf8(body) {
Ok(body) => body, Ok(body) => body,
Err(_) => return (StatusCode::BAD_REQUEST, "cannot parse request").into_response(), Err(_) => return (StatusCode::BAD_REQUEST, "cannot parse request").into_response(),
@ -71,7 +80,9 @@ async fn generate_content_directory_control_response(
BrowseFlag::BrowseDirectChildren => ( BrowseFlag::BrowseDirectChildren => (
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)], [(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
render_content_directory_browse( render_content_directory_browse(
state.provider.browse_direct_children(request.object_id), state
.provider
.browse_direct_children(request.object_id, http_hostname),
), ),
) )
.into_response(), .into_response(),

View file

@ -20,7 +20,6 @@ pub mod upnp_types;
pub struct UpnpServerOptions { pub struct UpnpServerOptions {
pub friendly_name: String, pub friendly_name: String,
pub http_hostname: String,
pub http_listen_port: u16, pub http_listen_port: u16,
pub http_prefix: String, pub http_prefix: String,
pub browse_provider: Box<dyn ContentDirectoryBrowseProvider>, pub browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
@ -57,14 +56,16 @@ impl UpnpServer {
let usn = create_usn(&opts).context("error generating USN")?; let usn = create_usn(&opts).context("error generating USN")?;
let description_http_location = { let description_http_location = {
let hostname = &opts.http_hostname;
let port = opts.http_listen_port; let port = opts.http_listen_port;
let http_prefix = &opts.http_prefix; let http_prefix = &opts.http_prefix;
format!("http://{hostname}:{port}{http_prefix}/description.xml") let surl = format!("http://0.0.0.0:{port}{http_prefix}/description.xml");
url::Url::parse(&surl)
.context(surl)
.context("error parsing url")?
}; };
info!( info!(
location = description_http_location, location = %description_http_location,
"starting UPnP/SSDP announcer for MediaServer" "starting UPnP/SSDP announcer for MediaServer"
); );
let ssdp_runner = crate::ssdp::SsdpRunner::new(ssdp::SsdpRunnerOptions { let ssdp_runner = crate::ssdp::SsdpRunner::new(ssdp::SsdpRunnerOptions {

View file

@ -94,7 +94,7 @@ pub fn try_parse_ssdp<'a, 'h>(
pub struct SsdpRunnerOptions { pub struct SsdpRunnerOptions {
pub usn: String, pub usn: String,
pub description_http_location: String, pub description_http_location: url::Url,
pub server_string: String, pub server_string: String,
pub notify_interval: Duration, pub notify_interval: Duration,
pub shutdown: CancellationToken, pub shutdown: CancellationToken,
@ -151,11 +151,20 @@ USN: {usn}::{kind}\r
) )
} }
fn generate_ssdp_discover_response(&self, st: &str) -> String { fn generate_ssdp_discover_response(
let location = &self.opts.description_http_location; &self,
st: &str,
addr: SocketAddr,
) -> anyhow::Result<String> {
let local_ip = ::librqbit_upnp::get_local_ip_relative_to(addr.ip())?;
let location = {
let mut loc = self.opts.description_http_location.clone();
loc.set_host(Some(&format!("{local_ip}")))?;
loc
};
let usn = &self.opts.usn; let usn = &self.opts.usn;
let server = &self.opts.server_string; let server = &self.opts.server_string;
format!( Ok(format!(
"HTTP/1.1 200 OK\r "HTTP/1.1 200 OK\r
Cache-Control: max-age=75\r Cache-Control: max-age=75\r
Ext: \r Ext: \r
@ -164,7 +173,7 @@ Server: {server}\r
St: {st}\r St: {st}\r
Usn: {usn}::{st}\r Usn: {usn}::{st}\r
Content-Length: 0\r\n\r\n" Content-Length: 0\r\n\r\n"
) ))
} }
async fn try_send_notifies(&self, nts: &str) { async fn try_send_notifies(&self, nts: &str) {
@ -178,18 +187,12 @@ Content-Length: 0\r\n\r\n"
} }
}; };
let location = match url::Url::parse(&self.opts.description_http_location) {
Ok(u) => u,
// TODO: rewrite this
Err(e) => {
warn!(error=?e, "error parsing description_http_location");
return;
}
};
for ni in interfaces { for ni in interfaces {
for niaddr in ni.addr { for niaddr in ni.addr {
let ip = niaddr.ip(); let ip = niaddr.ip();
if ip.is_ipv6() || ip.is_loopback() {
continue;
}
let addr = SocketAddr::new(ip, 0); let addr = SocketAddr::new(ip, 0);
let sock = match tokio::net::UdpSocket::bind(addr).await { let sock = match tokio::net::UdpSocket::bind(addr).await {
Ok(sock) => sock, Ok(sock) => sock,
@ -199,16 +202,16 @@ Content-Length: 0\r\n\r\n"
} }
}; };
let mut location = location.clone(); let mut location = self.opts.description_http_location.clone();
location.set_host(Some(&format!("{ip}"))).unwrap(); location.set_host(Some(&format!("{ip}"))).unwrap();
for kind in [UPNP_KIND_ROOT_DEVICE, UPNP_KIND_MEDIASERVER] { for kind in [UPNP_KIND_ROOT_DEVICE, UPNP_KIND_MEDIASERVER] {
let msg = self.generate_notify_message(kind, nts, &format!("{location}")); let msg = self.generate_notify_message(kind, nts, &format!("{location}"));
trace!(content=?msg, addr=?UPNP_BROADCAST_ADDR, "sending SSDP NOTIFY"); trace!(content=?msg, addr=?UPNP_BROADCAST_ADDR, "sending SSDP NOTIFY");
if let Err(e) = sock.send_to(msg.as_bytes(), UPNP_BROADCAST_ADDR).await { if let Err(e) = sock.send_to(msg.as_bytes(), UPNP_BROADCAST_ADDR).await {
warn!(error=?e, "error sending SSDP NOTIFY") warn!(sock_addr=%addr, error=%e, "error sending SSDP NOTIFY")
} else { } else {
debug!(kind, nts, "sent SSDP NOTIFY") debug!(kind, nts, %location, "sent SSDP NOTIFY")
} }
} }
} }
@ -244,7 +247,7 @@ Content-Length: 0\r\n\r\n"
} }
if let Ok(st) = std::str::from_utf8(msg.st) { if let Ok(st) = std::str::from_utf8(msg.st) {
let response = self.generate_ssdp_discover_response(st); let response = self.generate_ssdp_discover_response(st, addr)?;
trace!(content = response, ?addr, "sending SSDP discover response"); trace!(content = response, ?addr, "sending SSDP discover response");
self.socket self.socket
.send_to(response.as_bytes(), addr) .send_to(response.as_bytes(), addr)

View file

@ -70,11 +70,19 @@ pub mod content_directory {
} }
pub trait ContentDirectoryBrowseProvider: Send + Sync { pub trait ContentDirectoryBrowseProvider: Send + Sync {
fn browse_direct_children(&self, parent_id: usize) -> Vec<ItemOrContainer>; fn browse_direct_children(
&self,
parent_id: usize,
http_hostname: &str,
) -> Vec<ItemOrContainer>;
} }
impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> { impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> {
fn browse_direct_children(&self, _parent_id: usize) -> Vec<ItemOrContainer> { fn browse_direct_children(
&self,
_parent_id: usize,
_http_host: &str,
) -> Vec<ItemOrContainer> {
self.clone() self.clone()
} }
} }

View file

@ -6,7 +6,7 @@ use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use std::{ use std::{
collections::HashSet, collections::HashSet,
net::{Ipv4Addr, SocketAddr, SocketAddrV4}, net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4},
time::Duration, time::Duration,
}; };
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender}; use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
@ -23,7 +23,14 @@ const SSDP_SEARCH_REQUEST: &str = "M-SEARCH * HTTP/1.1\r\n\
ST: urn:schemas-upnp-org:service:WANIPConnection:1\r\n\ ST: urn:schemas-upnp-org:service:WANIPConnection:1\r\n\
\r\n"; \r\n";
fn get_local_ip_relative_to(local_dest: Ipv4Addr) -> anyhow::Result<Ipv4Addr> { pub fn get_local_ip_relative_to(local_dest: IpAddr) -> anyhow::Result<Ipv4Addr> {
let local_dest = match local_dest {
IpAddr::V4(v4) => v4,
IpAddr::V6(v6) => {
anyhow::bail!("get_local_ip_relative_to not implemented for IPv6; addr={v6}")
}
};
// Ipv4Addr.to_bits() is only there in nightly rust, so copying here for now. // Ipv4Addr.to_bits() is only there in nightly rust, so copying here for now.
fn ip_bits(addr: Ipv4Addr) -> u32 { fn ip_bits(addr: Ipv4Addr) -> u32 {
u32::from_be_bytes(addr.octets()) u32::from_be_bytes(addr.octets())
@ -214,14 +221,9 @@ impl UpnpEndpoint {
} }
fn my_local_ip(&self) -> anyhow::Result<Ipv4Addr> { fn my_local_ip(&self) -> anyhow::Result<Ipv4Addr> {
let dest_ipv4 = match self.discover_response.received_from { let dest_ip = self.discover_response.received_from.ip();
SocketAddr::V4(v4) => *v4.ip(), let local_ip = get_local_ip_relative_to(dest_ip)
SocketAddr::V6(v6) => { .with_context(|| format!("can't determine local IP relative to {dest_ip}"))?;
bail!("don't support IPv6, but remote ip is {}", v6.ip())
}
};
let local_ip = get_local_ip_relative_to(dest_ipv4)
.with_context(|| format!("can't determine local IP relative to {dest_ipv4}"))?;
Ok(local_ip) Ok(local_ip)
} }

View file

@ -11,7 +11,7 @@ services:
- 4240:4240 # TCP BitTorrent port - 4240:4240 # TCP BitTorrent port
environment: environment:
# Replace this with your LAN hostname or IP, resolvable from other devices in your LAN # Replace this with your LAN hostname or IP, resolvable from other devices in your LAN
RQBIT_UPNP_SERVER_HOSTNAME: 192.168.0.112 RQBIT_UPNP_SERVER_ENABLE: "true"
RQBIT_UPNP_SERVER_FRIENDLY_NAME: rqbit-docker RQBIT_UPNP_SERVER_FRIENDLY_NAME: rqbit-docker
# Replace this if you want to change the HTTP/Web UI port # Replace this if you want to change the HTTP/Web UI port