UPnP server configurable from UI

This commit is contained in:
Igor Katson 2024-08-24 00:34:57 +01:00
parent 3110f68f36
commit 9f340d92e5
No known key found for this signature in database
GPG key ID: B4EC22B66D61A3F5
6 changed files with 166 additions and 8 deletions

View file

@ -317,6 +317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c"
dependencies = [
"memchr",
"regex-automata 0.4.7",
"serde",
]
@ -1182,6 +1183,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30"
dependencies = [
"rustix",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -1855,6 +1866,7 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
"upnp-serve",
"url",
"urlencoding",
"uuid",
@ -2670,7 +2682,7 @@ checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
dependencies = [
"base64 0.22.1",
"indexmap 2.4.0",
"quick-xml",
"quick-xml 0.32.0",
"serde",
"time",
]
@ -2767,6 +2779,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.36"
@ -3315,6 +3336,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
@ -3886,7 +3916,9 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.52.0",
@ -4186,6 +4218,30 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "upnp-serve"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"bstr",
"gethostname",
"http 1.1.0",
"httparse",
"librqbit-core",
"librqbit-sha1-wrapper",
"librqbit-upnp",
"mime_guess",
"parking_lot",
"quick-xml 0.36.1",
"reqwest",
"tokio",
"tokio-util",
"tracing",
"url",
"uuid",
]
[[package]]
name = "url"
version = "2.5.2"

View file

@ -20,6 +20,7 @@ librqbit = { path = "../../crates/librqbit", features = [
"tracing-subscriber-utils",
"http-api",
"webui",
"upnp-serve-adapter",
] }
tokio = { version = "1.34.0", features = ["rt-multi-thread"] }
anyhow = "1.0.75"

View file

@ -125,10 +125,21 @@ impl Default for RqbitDesktopConfigHttpApi {
}
}
#[derive(Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq, Debug)]
#[serde(default)]
pub struct RqbitDesktopConfigUpnp {
pub disable: bool,
// rename for backwards compat
#[serde(rename = "disable")]
pub disable_tcp_port_forward: bool,
#[serde(default)]
pub enable_server: bool,
#[serde(default)]
pub server_hostname: Option<String>,
#[serde(default)]
pub server_friendly_name: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -162,3 +173,23 @@ impl Default for RqbitDesktopConfig {
}
}
}
impl RqbitDesktopConfig {
pub fn validate(&self) -> anyhow::Result<()> {
if self.upnp.enable_server {
if self.http_api.disable {
anyhow::bail!("if UPnP server is enabled, you need to enable the HTTP API also.")
}
if self.http_api.listen_addr.ip().is_loopback() {
anyhow::bail!("if UPnP server is enabled, you need to set HTTP API IP to 0.0.0.0 or at least non-localhost address.")
}
match self.upnp.server_hostname.as_ref().map(|s| s.trim()) {
Some("") | None => {
anyhow::bail!("UPnP hostname must be set to non-empty string")
}
Some(_) => {}
}
}
Ok(())
}
}

View file

@ -69,6 +69,9 @@ async fn api_from_config(
init_logging: &InitLoggingResult,
config: &RqbitDesktopConfig,
) -> anyhow::Result<Api> {
config
.validate()
.context("error validating configuration")?;
let persistence = if config.persistence.disable {
None
} else {
@ -100,7 +103,7 @@ async fn api_from_config(
} else {
None
},
enable_upnp_port_forwarding: !config.upnp.disable,
enable_upnp_port_forwarding: !config.upnp.disable_tcp_port_forward,
fastresume: config.persistence.fastresume,
..Default::default()
},
@ -118,6 +121,35 @@ async fn api_from_config(
let listen_addr = config.http_api.listen_addr;
let api = api.clone();
let read_only = config.http_api.read_only;
let upnp_router = if config.upnp.enable_server {
let hostname = config
.upnp
.server_hostname
.as_ref()
.map(|h| h.trim())
.context("empty UPNP hostname")?
.to_owned();
let friendly_name = config
.upnp
.server_friendly_name
.as_ref()
.map(|f| f.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned())
.unwrap_or_else(|| format!("rqbit@{hostname}"));
let mut upnp_adapter = session
.make_upnp_adapter(friendly_name, hostname, config.http_api.listen_addr.port())
.await
.context("error starting UPnP server")?;
let router = upnp_adapter.take_router()?;
session.spawn(error_span!("ssdp"), async move {
upnp_adapter.run_ssdp_forever().await
});
Some(router)
} else {
None
};
let http_api_task = async move {
let listener = tokio::net::TcpListener::bind(listen_addr)
.await
@ -126,7 +158,7 @@ async fn api_from_config(
api.clone(),
Some(librqbit::http_api::HttpApiOptions { read_only }),
)
.make_http_api_and_run(listener, None)
.make_http_api_and_run(listener, upnp_router)
.await
};

View file

@ -34,6 +34,10 @@ interface RqbitDesktopConfigHttpApi {
interface RqbitDesktopConfigUpnp {
disable: boolean;
enable_server: boolean;
server_hostname: string;
server_friendly_name: string;
}
export interface RqbitDesktopConfig {

View file

@ -59,7 +59,8 @@ type TAB =
| "Session"
| "Peer options"
| "HTTP API"
| "TCP Listen";
| "TCP Listen"
| "UPnP Server";
const TABS: readonly TAB[] = [
"Home",
@ -68,6 +69,7 @@ const TABS: readonly TAB[] = [
"TCP Listen",
"Peer options",
"HTTP API",
"UPnP Server",
] as const;
const Tab: React.FC<{
@ -252,11 +254,11 @@ export const ConfigModal: React.FC<{
/>
<FormCheck
label="Advertise over UPnP"
label="Advertise TCP port over UPnP"
name="tcp_listen.disable"
checked={!config.tcp_listen.disable}
onChange={handleToggleChange}
help="Advertise your port over UPnP. This is required for peers to be able to connect to you from the internet. Will only work if your router has a static IP."
help="Advertise your port over UPnP to your router(s). This is required for peers to be able to connect to you from the internet. Will only work if your router has a static IP."
/>
<FormInput
@ -281,6 +283,38 @@ export const ConfigModal: React.FC<{
</Fieldset>
</Tab>
<Tab name="UPnP Server" currentTab={tab}>
<Fieldset>
<FormCheck
label="Enable UPnP media server"
name="upnp.enable_server"
checked={config.upnp.enable_server}
onChange={handleToggleChange}
help="If enabled, rqbit will advertise the media to supported LAN devices, e.g. TVs."
/>
<FormInput
inputType="text"
label="[Required] Hostname"
name="upnp.server_hostname"
value={config.upnp.server_hostname}
disabled={!config.upnp.enable_server}
onChange={handleInputChange}
help="Set this to your LAN IP or hostname resolvable from LAN."
/>
<FormInput
inputType="text"
label="Friendly name"
name="upnp.server_friendly_name"
value={config.upnp.server_friendly_name}
disabled={!config.upnp.enable_server}
onChange={handleInputChange}
help="The name displayed on supported devices. If not set will be generated, will look smth like <rqbit at HOSTNAME>."
/>
</Fieldset>
</Tab>
<Tab name="Session" currentTab={tab}>
<Fieldset>
<FormCheck