UPNP server integrated into rqbit.

How to use: https://github.com/ikatson/rqbit/pull/208
This commit is contained in:
Igor Katson 2024-08-21 23:57:21 +01:00
parent e8bd7ca7e5
commit 9e7b656f0b
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
34 changed files with 2420 additions and 234 deletions

View file

@ -0,0 +1,9 @@
pub const UPNP_KIND_ROOT_DEVICE: &str = "upnp:rootdevice";
pub const UPNP_KIND_MEDIASERVER: &str = "urn:schemas-upnp-org:device:MediaServer:1";
pub const SOAP_ACTION_CONTENT_DIRECTORY_BROWSE: &[u8] =
b"\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\"";
pub const SOAP_ACTION_GET_SYSTEM_UPDATE_ID: &[u8] =
b"\"urn:schemas-upnp-org:service:ContentDirectory:1#GetSystemUpdateID\"";
pub const CONTENT_TYPE_XML_UTF8: &str = "text/xml; charset=\"utf-8\"";

View file

@ -0,0 +1,214 @@
use std::{sync::atomic::Ordering, time::Duration};
use anyhow::Context;
use axum::{
body::Bytes,
extract::State,
handler::HandlerWithoutStateExt,
response::IntoResponse,
routing::{get, post},
};
use bstr::BStr;
use http::{header::CONTENT_TYPE, HeaderMap, HeaderName, StatusCode};
use tokio_util::sync::CancellationToken;
use tracing::{debug, trace, warn};
use crate::{
constants::{
CONTENT_TYPE_XML_UTF8, SOAP_ACTION_CONTENT_DIRECTORY_BROWSE,
SOAP_ACTION_GET_SYSTEM_UPDATE_ID,
},
state::{UnpnServerState, UpnpServerStateInner},
templates::{
render_content_directory_browse, render_content_directory_control_get_system_update_id,
render_root_description_xml, RootDescriptionInputs,
},
upnp_types::content_directory::{
request::ContentDirectoryControlRequest, ContentDirectoryBrowseProvider,
},
};
async fn description_xml(State(state): State<UnpnServerState>) -> impl IntoResponse {
state.rendered_root_description.clone()
}
async fn generate_content_directory_control_response(
headers: HeaderMap,
State(state): State<UnpnServerState>,
body: Bytes,
) -> impl IntoResponse {
let body = BStr::new(&body);
let action = headers.get("soapaction").map(|v| BStr::new(v.as_bytes()));
trace!(?body, ?action, "received control request");
let action = match action {
Some(action) => action,
None => {
debug!("missing SOAPACTION header");
return (StatusCode::BAD_REQUEST, "").into_response();
}
};
match action.as_ref() {
SOAP_ACTION_CONTENT_DIRECTORY_BROWSE => {
let body = match std::str::from_utf8(body) {
Ok(body) => body,
Err(_) => return (StatusCode::BAD_REQUEST, "cannot parse request").into_response(),
};
let request = match ContentDirectoryControlRequest::parse(body) {
Ok(req) => req,
Err(e) => {
debug!(error=?e, "error parsing XML");
return (StatusCode::BAD_REQUEST, "cannot parse request").into_response();
}
};
(
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
render_content_directory_browse(
state.provider.browse_direct_children(request.object_id),
),
)
.into_response()
}
SOAP_ACTION_GET_SYSTEM_UPDATE_ID => {
let update_id = state.system_update_id.load(Ordering::Relaxed);
(
[(CONTENT_TYPE, CONTENT_TYPE_XML_UTF8)],
render_content_directory_control_get_system_update_id(update_id),
)
.into_response()
}
_ => {
debug!(?action, "unsupported ContentDirectory action");
(StatusCode::NOT_IMPLEMENTED, "").into_response()
}
}
}
async fn subscription(
State(state): State<UnpnServerState>,
request: axum::extract::Request,
) -> impl IntoResponse {
if request.method().as_str() != "SUBSCRIBE" {
return (StatusCode::METHOD_NOT_ALLOWED, "").into_response();
}
let (parts, _body) = request.into_parts();
trace!(?parts.headers, "subscription request");
let is_event = parts
.headers
.get(HeaderName::from_static("nt"))
.map(|v| v.as_bytes() == b"upnp:event")
.unwrap_or_default();
if !is_event {
return (StatusCode::BAD_REQUEST, "expected NT: upnp:event header").into_response();
}
let callback = parts
.headers
.get(HeaderName::from_static("callback"))
.and_then(|v| v.to_str().ok())
.map(|s| s.trim_matches(|c| c == '>' || c == '<'))
.and_then(|u| url::Url::parse(u).ok());
let callback = match callback {
Some(c) => c,
None => return (StatusCode::BAD_REQUEST, "callback not provided").into_response(),
};
let subscription_id = parts
.headers
.get(HeaderName::from_static("sid"))
.and_then(|v| v.to_str().ok());
let timeout = parts
.headers
.get(HeaderName::from_static("timeout"))
.and_then(|v| v.to_str().ok())
.and_then(|t| t.strip_prefix("Second-"))
.and_then(|t| t.parse::<u16>().ok())
.map(|t| Duration::from_secs(t as u64));
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(1800);
let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
if let Some(sid) = subscription_id {
match state.renew_subscription(sid, timeout) {
Ok(()) => (
StatusCode::OK,
[
("SID", sid.to_owned()),
("TIMEOUT", format!("Second-{}", timeout.as_secs())),
],
)
.into_response(),
Err(e) => {
warn!(sid, error=?e, "error renewing subscription");
StatusCode::NOT_FOUND.into_response()
}
}
} else {
match state.new_subscription(callback, timeout) {
Ok(sid) => (
StatusCode::OK,
[
("SID", sid),
("TIMEOUT", format!("Second-{}", timeout.as_secs())),
],
)
.into_response(),
Err(e) => {
warn!(error=?e, "error creating subscription");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
pub fn make_router(
friendly_name: String,
http_prefix: String,
upnp_usn: String,
browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
cancellation_token: CancellationToken,
) -> anyhow::Result<axum::Router> {
let root_desc = render_root_description_xml(&RootDescriptionInputs {
friendly_name: &friendly_name,
manufacturer: "rqbit developers",
model_name: "1.0.0",
unique_id: &upnp_usn,
http_prefix: &http_prefix,
});
let state = UpnpServerStateInner::new(root_desc.into(), browse_provider, cancellation_token)
.context("error creating UPNP server")?;
let sub_handler = {
let state = state.clone();
move |request: axum::extract::Request| async move {
subscription(State(state.clone()), request).await
}
};
let app = axum::Router::new()
.route("/description.xml", get(description_xml))
.route(
"/scpd/ContentDirectory.xml",
get(|| async { include_str!("resources/scpd_content_directory.xml") }),
)
.route(
"/scpd/ConnectionManager.xml",
get(|| async { include_str!("resources/scpd_connection_manager.xml") }),
)
.route(
"/control/ContentDirectory",
post(generate_content_directory_control_response),
)
.route(
"/control/ConnectionManager",
post(|| async { (StatusCode::NOT_IMPLEMENTED, "") }),
)
.route_service("/subscribe", sub_handler.into_service())
.with_state(state);
Ok(app)
}

View file

@ -0,0 +1,102 @@
use std::{io::Write, time::Duration};
use anyhow::Context;
use gethostname::gethostname;
use http_handlers::make_router;
use librqbit_sha1_wrapper::ISha1;
use ssdp::SsdpRunner;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use upnp_types::content_directory::ContentDirectoryBrowseProvider;
mod constants;
mod http_handlers;
mod ssdp;
pub mod state;
mod subscriptions;
mod templates;
pub mod upnp_types;
pub struct UpnpServerOptions {
pub friendly_name: String,
pub http_hostname: String,
pub http_listen_port: u16,
pub http_prefix: String,
pub browse_provider: Box<dyn ContentDirectoryBrowseProvider>,
pub cancellation_token: CancellationToken,
}
pub struct UpnpServer {
axum_router: Option<axum::Router>,
ssdp_runner: SsdpRunner,
}
fn create_usn(opts: &UpnpServerOptions) -> anyhow::Result<String> {
let mut buf = Vec::new();
buf.write_all(gethostname().as_encoded_bytes())?;
write!(
&mut buf,
"{}{}{}",
opts.friendly_name, opts.http_listen_port, opts.http_prefix
)?;
let mut sha1 = librqbit_sha1_wrapper::Sha1::new();
sha1.update(&buf);
let hash = sha1.finish();
let uuid = uuid::Builder::from_slice(&hash[..16])
.context("error generating UUID")?
.into_uuid();
Ok(format!("uuid:{}", uuid))
}
impl UpnpServer {
pub async fn new(opts: UpnpServerOptions) -> anyhow::Result<Self> {
let usn = create_usn(&opts).context("error generating USN")?;
let description_http_location = {
let hostname = &opts.http_hostname;
let port = opts.http_listen_port;
let http_prefix = &opts.http_prefix;
format!("http://{hostname}:{port}{http_prefix}/description.xml")
};
let ssdp_runner = crate::ssdp::SsdpRunner::new(ssdp::SsdpRunnerOptions {
usn: usn.clone(),
description_http_location,
server_string: "Linux/3.4 UPnP/1.0 rqbit/1".to_owned(),
notify_interval: Duration::from_secs(60),
})
.await
.context("error initializing SsdpRunner")?;
let router = make_router(
opts.friendly_name,
opts.http_prefix,
usn,
opts.browse_provider,
opts.cancellation_token,
)?;
Ok(Self {
axum_router: Some(router),
ssdp_runner,
})
}
pub fn take_router(&mut self) -> anyhow::Result<axum::Router> {
self.axum_router
.take()
.context("programming error: router already taken")
}
pub async fn run_ssdp_forever(&self) -> anyhow::Result<()> {
debug!("starting SSDP");
self.ssdp_runner
.run_forever()
.await
.context("error running SSDP loop")
}
}

View file

@ -0,0 +1,182 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>GetProtocolInfo</name>
<argumentList>
<argument>
<name>Source</name>
<direction>out</direction>
<relatedStateVariable>SourceProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>Sink</name>
<direction>out</direction>
<relatedStateVariable>SinkProtocolInfo</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>PrepareForConnection</name>
<argumentList>
<argument>
<name>RemoteProtocolInfo</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>ConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>ConnectionComplete</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionIDs</name>
<argumentList>
<argument>
<name>ConnectionIDs</name>
<direction>out</direction>
<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable>
</argument>
</argumentList>
</action>
<action>
<name>GetCurrentConnectionInfo</name>
<argumentList>
<argument>
<name>ConnectionID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>RcsID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable>
</argument>
<argument>
<name>AVTransportID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable>
</argument>
<argument>
<name>ProtocolInfo</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionManager</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable>
</argument>
<argument>
<name>PeerConnectionID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable>
</argument>
<argument>
<name>Direction</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable>
</argument>
<argument>
<name>Status</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="yes">
<name>SourceProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SinkProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>CurrentConnectionIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>OK</allowedValue>
<allowedValue>ContentFormatMismatch</allowedValue>
<allowedValue>InsufficientBandwidth</allowedValue>
<allowedValue>UnreliableChannel</allowedValue>
<allowedValue>Unknown</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionManager</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Direction</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>Input</allowedValue>
<allowedValue>Output</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ProtocolInfo</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ConnectionID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_AVTransportID</name>
<dataType>i4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RcsID</name>
<dataType>i4</dataType>
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,184 @@
<?xml version="1.0"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<actionList>
<action>
<name>Browse</name>
<argumentList>
<argument>
<name>ObjectID</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable>
</argument>
<argument>
<name>BrowseFlag</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable>
</argument>
<argument>
<name>Filter</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable>
</argument>
<argument>
<name>StartingIndex</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable>
</argument>
<argument>
<name>RequestedCount</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>SortCriteria</name>
<direction>in</direction>
<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable>
</argument>
<argument>
<name>Result</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable>
</argument>
<argument>
<name>NumberReturned</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>TotalMatches</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable>
</argument>
<argument>
<name>UpdateID</name>
<direction>out</direction>
<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable>
</argument>
</argumentList>
</action>
</actionList>
<serviceStateTable>
<stateVariable sendEvents="no">
<name>SearchCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>SortExtensionCapabilities</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>SystemUpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>ContainerUpdateIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="yes">
<name>TransferIDs</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>FeatureList</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_ObjectID</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Result</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SearchCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_BrowseFlag</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>BrowseMetadata</allowedValue>
<allowedValue>BrowseDirectChildren</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Filter</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_SortCriteria</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Index</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Count</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_UpdateID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferID</name>
<dataType>ui4</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferStatus</name>
<dataType>string</dataType>
<allowedValueList>
<allowedValue>COMPLETED</allowedValue>
<allowedValue>ERROR</allowedValue>
<allowedValue>IN_PROGRESS</allowedValue>
<allowedValue>STOPPED</allowedValue>
</allowedValueList>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferLength</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TransferTotal</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_TagValueList</name>
<dataType>string</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_URI</name>
<dataType>uri</dataType>
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_CategoryType</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_RID</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_PosSec</name>
<dataType>ui4</dataType>
<defaultValue />
</stateVariable>
<stateVariable sendEvents="no">
<name>A_ARG_TYPE_Featurelist</name>
<dataType>string</dataType>
<defaultValue />
</stateVariable>
</serviceStateTable>
</scpd>

View file

@ -0,0 +1,4 @@
<container id="{id}" parentID="{parent_id}" restricted="true" {childCountTag}>
<dc:title>{title}</dc:title>
<upnp:class>object.container.storageFolder</upnp:class>
</container>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<Result><![CDATA[{result}]]></Result>
<NumberReturned>{number_returned}</NumberReturned>
<TotalMatches>{total_matches}</TotalMatches>
<UpdateID>{update_id}</UpdateID>
</u:BrowseResponse>
</s:Body>
</s:Envelope>

View file

@ -0,0 +1,5 @@
<item id="{id}" parentID="{parent_id}" restricted="true">
<dc:title>{title}</dc:title>
<upnp:class>{upnp_class}</upnp:class>
<res protocolInfo="http-get:*:{mime_type}:DLNA.ORG_OP=01">{url}</res>
</item>

View file

@ -0,0 +1,5 @@
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
{items}
</DIDL-Lite>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetSystemUpdateIDResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<Id>{id}</Id>
</u:GetSystemUpdateIDResponse>
</s:Body>
</s:Envelope>

View file

@ -0,0 +1,5 @@
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
<e:property>
<SystemUpdateID>{system_update_id}</SystemUpdateID>
</e:property>
</e:propertyset>

View file

@ -0,0 +1,31 @@
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor>
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>{friendly_name}</friendlyName>
<manufacturer>{manufacturer}</manufacturer>
<modelName>{model_name}</modelName>
<UDN>{unique_id}</UDN>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>{http_prefix}/scpd/ContentDirectory.xml</SCPDURL>
<controlURL>{http_prefix}/control/ContentDirectory</controlURL>
<eventSubURL>{http_prefix}/subscribe</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>{http_prefix}/scpd/ConnectionManager.xml</SCPDURL>
<controlURL>{http_prefix}/control/ConnectionManager</controlURL>
</service>
</serviceList>
<presentationURL>/</presentationURL>
</device>
</root>

View file

@ -0,0 +1,12 @@
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<ObjectID>5</ObjectID>
<BrowseFlag>BrowseDirectChildren</BrowseFlag>
<Filter>*</Filter>
<StartingIndex>0</StartingIndex>
<RequestedCount>5000</RequestedCount>
<SortCriteria></SortCriteria>
</u:Browse>
</s:Body>
</s:Envelope>

View file

@ -0,0 +1,255 @@
use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
time::Duration,
};
use anyhow::{bail, Context};
use bstr::BStr;
use tokio::net::UdpSocket;
use tracing::{debug, trace, warn};
use crate::constants::{UPNP_KIND_MEDIASERVER, UPNP_KIND_ROOT_DEVICE};
const UPNP_PORT: u16 = 1900;
const UPNP_BROADCAST_IP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
const UPNP_BROADCAST_ADDR: SocketAddrV4 = SocketAddrV4::new(UPNP_BROADCAST_IP, UPNP_PORT);
#[derive(Debug)]
pub enum SsdpMessage<'a, 'h> {
MSearch(SsdpMSearchRequest<'a>),
#[allow(dead_code)]
OtherRequest(httparse::Request<'h, 'a>),
#[allow(dead_code)]
Response(httparse::Response<'h, 'a>),
}
#[derive(Debug)]
pub struct SsdpMSearchRequest<'a> {
pub host: &'a BStr,
pub man: &'a BStr,
pub st: &'a BStr,
}
impl<'a> SsdpMSearchRequest<'a> {
fn matches_media_server(&self) -> bool {
if self.host != "239.255.255.250:1900" {
return false;
}
if self.man != "\"ssdp:discover\"" {
return false;
}
if self.st == UPNP_KIND_ROOT_DEVICE || self.st == UPNP_KIND_MEDIASERVER {
return true;
}
false
}
}
pub fn try_parse_ssdp<'a, 'h>(
buf: &'a [u8],
headers: &'h mut [httparse::Header<'a>],
) -> anyhow::Result<SsdpMessage<'a, 'h>> {
if buf.starts_with(b"HTTP/") {
let mut resp = httparse::Response::new(headers);
resp.parse(buf).context("error parsing response")?;
return Ok(SsdpMessage::Response(resp));
}
let mut req = httparse::Request::new(headers);
req.parse(buf).context("error parsing request")?;
match req.method {
Some("M-SEARCH") => {
let mut host = None;
let mut man = None;
let mut st = None;
for header in req.headers.iter() {
match header.name {
"HOST" | "Host" | "host" => host = Some(header.value),
"MAN" | "Man" | "man" => man = Some(header.value),
"ST" | "St" | "st" => st = Some(header.value),
other => trace!(header=?BStr::new(other), "ignoring SSDP header"),
}
}
match (host, man, st) {
(Some(host), Some(man), Some(st)) => {
return Ok(SsdpMessage::MSearch(SsdpMSearchRequest {
host: BStr::new(host),
man: BStr::new(man),
st: BStr::new(st),
}))
}
_ => bail!("not all of host, man and st are set"),
}
}
_ => return Ok(SsdpMessage::OtherRequest(req)),
}
}
pub struct SsdpRunnerOptions {
pub usn: String,
pub description_http_location: String,
pub server_string: String,
pub notify_interval: Duration,
}
pub struct SsdpRunner {
opts: SsdpRunnerOptions,
socket: UdpSocket,
}
impl SsdpRunner {
pub async fn new(opts: SsdpRunnerOptions) -> anyhow::Result<Self> {
let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, UPNP_PORT);
trace!(addr=?bind_addr, "binding UDP");
let socket =
tokio::net::UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, UPNP_PORT))
.await
.context("error binding")?;
trace!(multiaddr=?UPNP_BROADCAST_IP, interface=?Ipv4Addr::UNSPECIFIED, "joining multicast v4 group");
socket
.join_multicast_v4(UPNP_BROADCAST_IP, Ipv4Addr::UNSPECIFIED)
.context("error joining multicast group")?;
Ok(Self { opts, socket })
}
fn generate_notify_message(&self, kind: &str) -> String {
let usn: &str = &self.opts.usn;
let description_http_location = &self.opts.description_http_location;
let server: &str = &self.opts.server_string;
let bcast_addr = UPNP_BROADCAST_ADDR;
format!(
"NOTIFY * HTTP/1.1\r
Host: {bcast_addr}\r
Cache-Control: max-age=75\r
Location: {description_http_location}\r
NT: {kind}\r
NTS: ssdp:alive\r
Server: {server}\r
USN: {usn}::{kind}\r
\r
"
)
}
fn generate_ssdp_discover_response(&self) -> String {
let location = &self.opts.description_http_location;
let usn = &self.opts.usn;
let media_server = UPNP_KIND_MEDIASERVER;
let server = &self.opts.server_string;
format!(
"HTTP/1.1 200 OK\r
Cache-Control: max-age=75\r
Ext: \r
Location: {location}\r
Server: {server}\r
St: {media_server}\r
Usn: {usn}::{media_server}\r
Content-Length: 0\r\n\r\n"
)
}
async fn try_send_notifies(&self) {
for kind in [UPNP_KIND_ROOT_DEVICE, UPNP_KIND_MEDIASERVER] {
let msg = self.generate_notify_message(kind);
trace!(content=?msg, addr=?UPNP_BROADCAST_ADDR, "sending SSDP NOTIFY");
if let Err(e) = self
.socket
.send_to(msg.as_bytes(), UPNP_BROADCAST_ADDR)
.await
{
warn!(error=?e, "error sending SSDP NOTIFY")
}
}
}
async fn task_send_notifies_periodically(&self) -> anyhow::Result<()> {
let mut interval = tokio::time::interval(self.opts.notify_interval);
loop {
interval.tick().await;
self.try_send_notifies().await;
}
}
async fn process_incoming_message(&self, msg: &[u8], addr: SocketAddr) -> anyhow::Result<()> {
let mut headers = [httparse::EMPTY_HEADER; 16];
trace!(content = ?BStr::new(msg), ?addr, "received message");
let parsed = try_parse_ssdp(msg, &mut headers);
let msg = match parsed {
Ok(SsdpMessage::MSearch(msg)) => msg,
Ok(m) => {
trace!("ignoring {m:?}");
return Ok(());
}
Err(e) => {
debug!(error=?e, "error parsing SSDP message");
return Ok(());
}
};
if !msg.matches_media_server() {
trace!("not a media server request, ignoring");
return Ok(());
}
let response = self.generate_ssdp_discover_response();
trace!(content = response, ?addr, "sending SSDP discover response");
self.socket
.send_to(response.as_bytes(), addr)
.await
.context("error sending")?;
Ok(())
}
async fn task_respond_on_msearches(&self) -> anyhow::Result<()> {
let mut buf = vec![0u8; 16184];
loop {
let (sz, addr) = self
.socket
.recv_from(&mut buf)
.await
.context("error receiving")?;
let msg = &buf[..sz];
if let Err(e) = self.process_incoming_message(msg, addr).await {
warn!(error=?e, ?addr, "error processing incoming SSDP message")
}
}
}
async fn send_msearch(&self) -> anyhow::Result<()> {
let msearch_msg = "M-SEARCH * HTTP/1.1\r
HOST: 239.255.255.250:1900\r
ST: urn:schemas-upnp-org:device:MediaServer:1\r
MAN: \"ssdp:discover\"\r
MX: 2\r\n\r\n";
trace!(content = msearch_msg, "multicasting M-SEARCH");
self.socket
.send_to(msearch_msg.as_bytes(), UPNP_BROADCAST_ADDR)
.await
.context("error sending msearch")?;
Ok(())
}
pub async fn run_forever(&self) -> anyhow::Result<()> {
// This isn't necessary, but would show that it works.
self.send_msearch().await?;
let t1 = self.task_respond_on_msearches();
let t2 = self.task_send_notifies_periodically();
tokio::pin!(t1);
tokio::pin!(t2);
tokio::select! {
r = &mut t1 => r,
r = &mut t2 => r,
}
}
}

View file

@ -0,0 +1,78 @@
use std::{
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use anyhow::Context;
use axum::body::Bytes;
use librqbit_core::spawn_utils::spawn_with_cancel;
use tokio_util::sync::CancellationToken;
use tracing::{error_span, Span};
use crate::{
subscriptions::Subscriptions, upnp_types::content_directory::ContentDirectoryBrowseProvider,
};
pub struct UpnpServerStateInner {
pub rendered_root_description: Bytes,
pub provider: Box<dyn ContentDirectoryBrowseProvider>,
pub system_update_id: AtomicU64,
pub subscriptions: Subscriptions,
pub span: Span,
pub system_update_bcast_tx: tokio::sync::broadcast::Sender<u64>,
pub cancel_token: tokio_util::sync::CancellationToken,
_drop_guard: tokio_util::sync::DropGuard,
}
fn new_system_update_id() -> anyhow::Result<u64> {
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
}
impl UpnpServerStateInner {
pub fn new(
rendered_root_description: Bytes,
provider: Box<dyn ContentDirectoryBrowseProvider>,
cancellation_token: CancellationToken,
) -> anyhow::Result<Arc<Self>> {
let cancel_token = cancellation_token.child_token();
let drop_guard = cancel_token.clone().drop_guard();
let (btx, _) = tokio::sync::broadcast::channel(32);
let span = error_span!(parent: None, "upnp-server");
let state = Arc::new(Self {
rendered_root_description,
provider,
system_update_id: AtomicU64::new(new_system_update_id()?),
subscriptions: Default::default(),
system_update_bcast_tx: btx,
_drop_guard: drop_guard,
span: span.clone(),
cancel_token: cancel_token.clone(),
});
spawn_with_cancel(
error_span!(parent: span, "system_update_id_updater"),
cancel_token,
{
let state = Arc::downgrade(&state);
async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
loop {
interval.tick().await;
let new_value = new_system_update_id()?;
let state = state.upgrade().context("upnp server is dead")?;
state.system_update_id.store(new_value, Ordering::Relaxed);
let _ = state.system_update_bcast_tx.send(new_value);
}
}
},
);
Ok(state)
}
}
pub type UnpnServerState = Arc<UpnpServerStateInner>;

View file

@ -0,0 +1,200 @@
use crate::state::UpnpServerStateInner;
use crate::templates::render_notify_subscription_system_update_id;
use anyhow::Context;
use http::Method;
use librqbit_core::spawn_utils::spawn_with_cancel;
use parking_lot::RwLock;
use std::{
collections::HashMap,
sync::{atomic::Ordering, Arc},
time::Duration,
};
use tokio::sync::{broadcast::error::RecvError, Notify};
use tracing::{error_span, warn, Instrument};
pub struct Subscription {
pub url: url::Url,
pub seq: u64,
pub timeout: Duration,
pub refresh_notify: Arc<Notify>,
}
#[derive(Default)]
pub struct Subscriptions {
subs: RwLock<HashMap<String, Subscription>>,
}
impl Subscriptions {
pub fn add(&self, url: url::Url, timeout: Duration) -> (String, Arc<Notify>) {
let sid = format!("uuid:{}", uuid::Uuid::new_v4());
let notify = Arc::new(Notify::default());
self.subs.write().insert(
sid.clone(),
Subscription {
url,
seq: 0,
timeout,
refresh_notify: notify.clone(),
},
);
(sid, notify)
}
pub fn update_timeout(&self, sid: &str, timeout: Duration) -> anyhow::Result<()> {
let mut g = self.subs.write();
let s = g.get_mut(sid).context("no such subscription")?;
s.timeout = timeout;
s.refresh_notify.notify_waiters();
Ok(())
}
pub fn next_seq(&self, sid: &str) -> anyhow::Result<u64> {
let mut g = self.subs.write();
let s = g.get_mut(sid).context("no such subscription")?;
let id = s.seq;
s.seq += 1;
Ok(id)
}
pub fn get_timeout(&self, sid: &str) -> anyhow::Result<Duration> {
let mut g = self.subs.write();
let s = g.get_mut(sid).context("no such subscription")?;
Ok(s.timeout)
}
pub fn remove(&self, sid: &str) -> anyhow::Result<Subscription> {
let mut g = self.subs.write();
let s = g.remove(sid).context("no such subscription")?;
Ok(s)
}
}
pub async fn notify_subscription_system_update(
url: &url::Url,
sid: &str,
seq: u64,
system_update_id: u64,
) -> anyhow::Result<()> {
// NOTIFY /callback_path HTTP/1.1
// CONTENT-TYPE: text/xml; charset="utf-8"
// NT: upnp:event
// NTS: upnp:propchange
// SID: uuid:<Subscription ID>
// SEQ: <sequence number>
//
let body = render_notify_subscription_system_update_id(system_update_id);
let resp = reqwest::Client::builder()
.build()?
.request(Method::from_bytes(b"NOTIFY")?, url.clone())
.header("Content-Type", r#"text/xml; charset="utf-8""#)
.header("NT", "upnp:event")
.header("NTS", "upnp:propchange")
.header("SID", sid)
.header("SEQ", seq.to_string())
.body(body)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("{:?}", resp.status())
}
Ok(())
}
impl UpnpServerStateInner {
pub fn renew_subscription(&self, sid: &str, new_timeout: Duration) -> anyhow::Result<()> {
self.subscriptions.update_timeout(sid, new_timeout)
}
pub fn new_subscription(
self: &Arc<Self>,
url: url::Url,
timeout: Duration,
) -> anyhow::Result<String> {
let (sid, refresh_notify) = self.subscriptions.add(url.clone(), timeout);
let token = self.cancel_token.child_token();
// Spawn a task that will notify it of system id changes.
// Spawn a task that will wait for timeout or subscription refreshes.
// When it times out, kill all of them.
let pspan = self.span.clone();
let subscription_manager = {
let mut brx = self.system_update_bcast_tx.subscribe();
let state = Arc::downgrade(self);
let sid = sid.clone();
let url = url.clone();
async move {
let system_update_id_notifier = async {
loop {
let res = brx.recv().await;
let state = state.upgrade().context("upnp server dead")?;
let seq = state.subscriptions.next_seq(&sid)?;
match res {
Ok(system_update_id) => {
if let Err(e) = notify_subscription_system_update(
&url,
&sid,
seq,
system_update_id,
)
.await
{
warn!(error=?e, "error updating UPNP subscription");
}
}
Err(RecvError::Lagged(by)) => {
warn!(by, "UPNP subscription lagged");
let seq = state.subscriptions.next_seq(&sid)?;
let system_update_id =
state.system_update_id.load(Ordering::Relaxed);
if let Err(e) = notify_subscription_system_update(
&url,
&sid,
seq,
system_update_id,
)
.await
{
warn!(error=?e, "error updating UPNP subscription");
}
}
Err(RecvError::Closed) => return Ok(()),
}
}
}
.instrument(error_span!("system-update-id-notifier"));
let timeout_notifier = async {
let mut timeout = timeout;
loop {
tokio::select! {
_ = refresh_notify.notified() => {
timeout = state.upgrade().context("upnp server dead")?.subscriptions.get_timeout(&sid)?;
},
_ = tokio::time::sleep(timeout) => {
state.upgrade().context("upnp server dead")?.subscriptions.remove(&sid)?;
return Ok(())
}
}
}
}.instrument(error_span!("timeout-killer"));
tokio::select! {
r = system_update_id_notifier => r,
r = timeout_notifier => r,
}
}
};
spawn_with_cancel(
error_span!(parent: pspan, "subscription-manager", %url),
token,
subscription_manager,
);
Ok(sid)
}
}

View file

@ -0,0 +1,124 @@
use crate::upnp_types::content_directory::response::{Container, Item, ItemOrContainer};
pub struct RootDescriptionInputs<'a> {
pub friendly_name: &'a str,
pub manufacturer: &'a str,
pub model_name: &'a str,
pub unique_id: &'a str,
pub http_prefix: &'a str,
}
pub fn render_root_description_xml(input: &RootDescriptionInputs<'_>) -> String {
let tmpl = include_str!("resources/templates/root_desc.tmpl.xml").trim();
// This isn't great perf-wise but whatever.
tmpl.replace("{friendly_name}", input.friendly_name)
.replace("{manufacturer}", input.manufacturer)
.replace("{model_name}", input.model_name)
.replace("{unique_id}", input.unique_id)
.replace("{http_prefix}", input.http_prefix)
}
pub fn render_content_directory_browse(items: impl IntoIterator<Item = ItemOrContainer>) -> String {
fn item_or_container(item_or_container: &ItemOrContainer) -> Option<String> {
fn item(item: &Item) -> Option<String> {
let tmpl =
include_str!("resources/templates/content_directory_control_browse_item.tmpl.xml")
.trim();
let mime = item.mime_type.as_ref()?;
let upnp_class = match mime.type_().as_str() {
"video" => "object.item.videoItem",
_ => return None,
};
let mime = mime.to_string();
Some(
tmpl.replace("{id}", &item.id.to_string())
.replace("{parent_id}", &item.parent_id.unwrap_or(0).to_string())
.replace("{mime_type}", &mime)
.replace("{url}", &item.url)
.replace("{upnp_class}", upnp_class)
.replace("{title}", &item.title),
)
}
fn container(item: &Container) -> String {
let tmpl = include_str!(
"resources/templates/content_directory_control_browse_container.tmpl.xml"
)
.trim();
tmpl.replace("{id}", &format!("{}", item.id))
.replace("{parent_id}", &item.parent_id.unwrap_or(0).to_string())
.replace("{title}", &item.title)
.replace(
"{childCountTag}",
&match item.children_count {
Some(cc) => format!("childCount=\"{}\"", cc),
None => String::new(),
},
)
}
match item_or_container {
ItemOrContainer::Container(c) => Some(container(c)),
ItemOrContainer::Item(i) => item(i),
}
}
struct Envelope<'a> {
result: &'a str,
number_returned: usize,
total_matches: usize,
update_id: u64,
}
fn render_content_directory_envelope(envelope: &Envelope<'_>) -> String {
let tmpl =
include_str!("resources/templates/content_directory_control_browse_envelope.tmpl.xml")
.trim();
tmpl.replace("{result}", envelope.result)
.replace("{number_returned}", &envelope.number_returned.to_string())
.replace("{total_matches}", &envelope.total_matches.to_string())
.replace("{update_id}", &envelope.update_id.to_string())
}
fn render_content_directory_browse_result(items: &str) -> String {
let tmpl =
include_str!("resources/templates/content_directory_control_browse_result.tmpl.xml")
.trim();
tmpl.replace("{items}", items)
}
let all_items = items
.into_iter()
.filter_map(|item| item_or_container(&item))
.collect::<Vec<_>>();
let total = all_items.len();
let all_items = all_items.join("");
let result = render_content_directory_browse_result(&all_items);
use std::time::{SystemTime, UNIX_EPOCH};
let update_id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
render_content_directory_envelope(&Envelope {
result: &result,
number_returned: total,
total_matches: total,
update_id,
})
}
pub fn render_notify_subscription_system_update_id(update_id: u64) -> String {
include_str!("resources/templates/notify_subscription.tmpl.xml")
.replace("{system_update_id}", &update_id.to_string())
}
pub fn render_content_directory_control_get_system_update_id(update_id: u64) -> String {
include_str!("resources/templates/content_directory_control_get_system_update_id.tmpl.xml")
.replace("{id}", &update_id.to_string())
}

View file

@ -0,0 +1,81 @@
pub mod content_directory {
use response::ItemOrContainer;
pub mod request {
pub struct ContentDirectoryControlRequest {
pub object_id: usize,
}
impl ContentDirectoryControlRequest {
pub fn parse(s: &str) -> anyhow::Result<Self> {
let mut reader = quick_xml::Reader::from_str(s);
use quick_xml::events::Event::{Eof, Start};
let mut object_id: Option<usize> = None;
loop {
match reader.read_event()? {
Eof => break,
Start(e) if e.name().as_ref() == b"ObjectID" => {
let t = reader.read_text(e.to_end().name())?;
object_id = t.trim().parse().ok();
}
_ => continue,
}
}
Ok(ContentDirectoryControlRequest {
object_id: object_id.unwrap_or(0),
})
}
}
}
pub mod response {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Container {
pub id: usize,
pub parent_id: Option<usize>,
pub children_count: Option<usize>,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub id: usize,
pub parent_id: Option<usize>,
pub title: String,
pub mime_type: Option<mime_guess::Mime>,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ItemOrContainer {
Container(Container),
Item(Item),
}
}
pub trait ContentDirectoryBrowseProvider: Send + Sync {
fn browse_direct_children(&self, parent_id: usize) -> Vec<ItemOrContainer>;
}
impl ContentDirectoryBrowseProvider for Vec<ItemOrContainer> {
fn browse_direct_children(&self, _parent_id: usize) -> Vec<ItemOrContainer> {
self.clone()
}
}
}
#[cfg(test)]
mod tests {
use crate::upnp_types::content_directory::request::ContentDirectoryControlRequest;
#[test]
fn test_parse_content_directory_request() {
let s = include_str!("resources/test/ContentDirectoryControlExampleRequest.xml");
let req = ContentDirectoryControlRequest::parse(s).unwrap();
assert_eq!(req.object_id, 5);
}
}