mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
94d174c33d
* discovery: use opaque error type for DnsSdError This helps to decouple discovery and core by not leaking implementation details of the zeroconf backend into Error conversion impls in core. * discovery: map all MDNS/DNS-SD errors to DiscoveryError::DnsSdError previously, libmdns errors would use a generic conversion from std::io::Error to core::Error * discovery: use an opaque type for the handle to the DNS-SD service * discovery: make features additive i.e. add with-libmdns instead of using not(with-dns-sd). The logic is such that enabling with-dns-sd in addition to the default with-libmdns will still end up using dns-sd, as before. If only with-libmdns is enabled, that will be the default. If none of the features is enabled, attempting to build a `Discovery` will yield an error. * discovery: add --zeroconf-backend CLI flag * discovery: Add minimal Avahi zeroconf backend * bump MSRV to 1.75 required by zbus >= 4 * discovery: ensure that server and dns-sd backend shutdown gracefully Previously, on drop the the shutdown_tx/close_tx, it wasn't guaranteed the corresponding tasks would continue to be polled until they actually completed their shutdown. Since dns_sd::Service is not Send and non-async, and because libmdns is non-async, put them on their own threads. * discovery: use a shared channel for server and zeroconf status messages * discovery: add Avahi reconnection logic This deals gracefully with the case where the Avahi daemon is restarted or not running initially. * discovery: allow running when compiled without zeroconf backend... ...but exit with an error if there's no way to authenticate * better error messages for invalid options with no short flag
543 lines
18 KiB
Rust
543 lines
18 KiB
Rust
//! Advertises this device to Spotify clients in the local network.
|
|
//!
|
|
//! This device will show up in the list of "available devices".
|
|
//! Once it is selected from the list, [`Credentials`] are received.
|
|
//! Those can be used to establish a new Session with [`librespot_core`].
|
|
//!
|
|
//! This library uses mDNS and DNS-SD so that other devices can find it,
|
|
//! and spawns an http server to answer requests of Spotify clients.
|
|
|
|
mod avahi;
|
|
mod server;
|
|
|
|
use std::{
|
|
borrow::Cow,
|
|
error::Error as StdError,
|
|
pin::Pin,
|
|
task::{Context, Poll},
|
|
};
|
|
|
|
use futures_core::Stream;
|
|
use thiserror::Error;
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
use self::server::DiscoveryServer;
|
|
|
|
pub use crate::core::Error;
|
|
use librespot_core as core;
|
|
|
|
/// Credentials to be used in [`librespot`](`librespot_core`).
|
|
pub use crate::core::authentication::Credentials;
|
|
|
|
/// Determining the icon in the list of available devices.
|
|
pub use crate::core::config::DeviceType;
|
|
|
|
pub enum DiscoveryEvent {
|
|
Credentials(Credentials),
|
|
ServerError(DiscoveryError),
|
|
ZeroconfError(DiscoveryError),
|
|
}
|
|
|
|
enum ZeroconfCmd {
|
|
Shutdown,
|
|
}
|
|
|
|
pub struct DnsSdHandle {
|
|
task_handle: tokio::task::JoinHandle<()>,
|
|
shutdown_tx: oneshot::Sender<ZeroconfCmd>,
|
|
}
|
|
|
|
impl DnsSdHandle {
|
|
async fn shutdown(self) {
|
|
log::debug!("Shutting down zeroconf responder");
|
|
let Self {
|
|
task_handle,
|
|
shutdown_tx,
|
|
} = self;
|
|
if shutdown_tx.send(ZeroconfCmd::Shutdown).is_err() {
|
|
log::warn!("Zeroconf responder unexpectedly disappeared");
|
|
} else {
|
|
let _ = task_handle.await;
|
|
log::debug!("Zeroconf responder stopped");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type DnsSdServiceBuilder = fn(
|
|
Cow<'static, str>,
|
|
Vec<std::net::IpAddr>,
|
|
u16,
|
|
mpsc::UnboundedSender<DiscoveryEvent>,
|
|
) -> Result<DnsSdHandle, Error>;
|
|
|
|
// Default goes first: This matches the behaviour when feature flags were exlusive, i.e. when there
|
|
// was only `feature = "with-dns-sd"` or `not(feature = "with-dns-sd")`
|
|
pub const BACKENDS: &[(
|
|
&str,
|
|
// If None, the backend is known but wasn't compiled.
|
|
Option<DnsSdServiceBuilder>,
|
|
)] = &[
|
|
#[cfg(feature = "with-avahi")]
|
|
("avahi", Some(launch_avahi)),
|
|
#[cfg(not(feature = "with-avahi"))]
|
|
("avahi", None),
|
|
#[cfg(feature = "with-dns-sd")]
|
|
("dns-sd", Some(launch_dns_sd)),
|
|
#[cfg(not(feature = "with-dns-sd"))]
|
|
("dns-sd", None),
|
|
#[cfg(feature = "with-libmdns")]
|
|
("libmdns", Some(launch_libmdns)),
|
|
#[cfg(not(feature = "with-libmdns"))]
|
|
("libmdns", None),
|
|
];
|
|
|
|
pub fn find(name: Option<&str>) -> Result<DnsSdServiceBuilder, Error> {
|
|
if let Some(ref name) = name {
|
|
match BACKENDS.iter().find(|(id, _)| name == id) {
|
|
Some((_id, Some(launch_svc))) => Ok(*launch_svc),
|
|
Some((_id, None)) => Err(Error::unavailable(format!(
|
|
"librespot built without '{}' support",
|
|
name
|
|
))),
|
|
None => Err(Error::not_found(format!(
|
|
"unknown zeroconf backend '{}'",
|
|
name
|
|
))),
|
|
}
|
|
} else {
|
|
BACKENDS
|
|
.iter()
|
|
.find_map(|(_, launch_svc)| *launch_svc)
|
|
.ok_or(Error::unavailable(
|
|
"librespot built without zeroconf backends",
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Makes this device visible to Spotify clients in the local network.
|
|
///
|
|
/// `Discovery` implements the [`Stream`] trait. Every time this device
|
|
/// is selected in the list of available devices, it yields [`Credentials`].
|
|
pub struct Discovery {
|
|
server: DiscoveryServer,
|
|
|
|
/// An opaque handle to the DNS-SD service. Dropping this will unregister the service.
|
|
#[allow(unused)]
|
|
svc: DnsSdHandle,
|
|
|
|
event_rx: mpsc::UnboundedReceiver<DiscoveryEvent>,
|
|
}
|
|
|
|
/// A builder for [`Discovery`].
|
|
pub struct Builder {
|
|
server_config: server::Config,
|
|
port: u16,
|
|
zeroconf_ip: Vec<std::net::IpAddr>,
|
|
zeroconf_backend: Option<DnsSdServiceBuilder>,
|
|
}
|
|
|
|
/// Errors that can occur while setting up a [`Discovery`] instance.
|
|
#[derive(Debug, Error)]
|
|
pub enum DiscoveryError {
|
|
#[error("Creating SHA1 block cipher failed")]
|
|
AesError(#[from] aes::cipher::InvalidLength),
|
|
|
|
#[error("Setting up dns-sd failed: {0}")]
|
|
DnsSdError(#[source] Box<dyn StdError + Send + Sync>),
|
|
|
|
#[error("Creating SHA1 HMAC failed for base key {0:?}")]
|
|
HmacError(Vec<u8>),
|
|
|
|
#[error("Setting up the HTTP server failed: {0}")]
|
|
HttpServerError(#[from] hyper::Error),
|
|
|
|
#[error("Missing params for key {0}")]
|
|
ParamsError(&'static str),
|
|
}
|
|
|
|
#[cfg(feature = "with-avahi")]
|
|
impl From<zbus::Error> for DiscoveryError {
|
|
fn from(error: zbus::Error) -> Self {
|
|
Self::DnsSdError(Box::new(error))
|
|
}
|
|
}
|
|
|
|
impl From<DiscoveryError> for Error {
|
|
fn from(err: DiscoveryError) -> Self {
|
|
match err {
|
|
DiscoveryError::AesError(_) => Error::unavailable(err),
|
|
DiscoveryError::DnsSdError(_) => Error::unavailable(err),
|
|
DiscoveryError::HmacError(_) => Error::invalid_argument(err),
|
|
DiscoveryError::HttpServerError(_) => Error::unavailable(err),
|
|
DiscoveryError::ParamsError(_) => Error::invalid_argument(err),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(unused)]
|
|
const DNS_SD_SERVICE_NAME: &str = "_spotify-connect._tcp";
|
|
#[allow(unused)]
|
|
const TXT_RECORD: [&str; 2] = ["VERSION=1.0", "CPath=/"];
|
|
|
|
#[cfg(feature = "with-avahi")]
|
|
async fn avahi_task(
|
|
name: Cow<'static, str>,
|
|
port: u16,
|
|
entry_group: &mut Option<avahi::EntryGroupProxy<'_>>,
|
|
) -> Result<(), DiscoveryError> {
|
|
use self::avahi::{EntryGroupState, ServerProxy};
|
|
use futures_util::StreamExt;
|
|
|
|
let conn = zbus::Connection::system().await?;
|
|
|
|
// Wait for the daemon to show up.
|
|
// On error: Failed to listen for NameOwnerChanged signal => Fatal DBus issue
|
|
let bus = zbus::fdo::DBusProxy::new(&conn).await?;
|
|
let mut stream = bus
|
|
.receive_name_owner_changed_with_args(&[(0, "org.freedesktop.Avahi")])
|
|
.await?;
|
|
|
|
loop {
|
|
// Wait for Avahi daemon to be started
|
|
'wait_avahi: {
|
|
while let Poll::Ready(Some(_)) = futures_util::poll!(stream.next()) {
|
|
// Drain queued name owner changes, since we're going to connect in a second
|
|
}
|
|
|
|
// Ping after we connected to the signal since it might have shown up in the meantime
|
|
if let Ok(avahi_peer) =
|
|
zbus::fdo::PeerProxy::new(&conn, "org.freedesktop.Avahi", "/").await
|
|
{
|
|
if avahi_peer.ping().await.is_ok() {
|
|
log::debug!("Pinged Avahi: Available");
|
|
break 'wait_avahi;
|
|
}
|
|
}
|
|
log::warn!("Failed to connect to Avahi, zeroconf discovery will not work until avahi-daemon is started. Check that it is installed and running");
|
|
|
|
// If it didn't, wait for the signal
|
|
match stream.next().await {
|
|
Some(_signal) => {
|
|
log::debug!("Avahi appeared");
|
|
break 'wait_avahi;
|
|
}
|
|
// The stream ended, but this should never happen
|
|
None => {
|
|
return Err(zbus::Error::Failure("DBus disappeared".to_owned()).into());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Connect to Avahi and publish the service
|
|
let avahi_server = ServerProxy::new(&conn).await?;
|
|
log::trace!("Connected to Avahi");
|
|
|
|
*entry_group = Some(avahi_server.entry_group_new().await?);
|
|
|
|
let mut entry_group_state_stream = entry_group
|
|
.as_mut()
|
|
.unwrap()
|
|
.receive_state_changed()
|
|
.await?;
|
|
|
|
entry_group
|
|
.as_mut()
|
|
.unwrap()
|
|
.add_service(
|
|
-1, // AVAHI_IF_UNSPEC
|
|
-1, // IPv4 and IPv6
|
|
0, // flags
|
|
&name,
|
|
DNS_SD_SERVICE_NAME, // type
|
|
"", // domain: let the server choose
|
|
"", // host: let the server choose
|
|
port,
|
|
&TXT_RECORD.map(|s| s.as_bytes()),
|
|
)
|
|
.await?;
|
|
|
|
entry_group.as_mut().unwrap().commit().await?;
|
|
log::debug!("Commited zeroconf service with name {}", &name);
|
|
|
|
'monitor_service: loop {
|
|
tokio::select! {
|
|
Some(state_changed) = entry_group_state_stream.next() => {
|
|
let (state, error) = match state_changed.args() {
|
|
Ok(sc) => (sc.state, sc.error),
|
|
Err(e) => {
|
|
log::warn!("Error on receiving EntryGroup state from Avahi: {}", e);
|
|
continue 'monitor_service;
|
|
}
|
|
};
|
|
match state {
|
|
EntryGroupState::Uncommited | EntryGroupState::Registering => {
|
|
// Not yet registered, ignore.
|
|
}
|
|
EntryGroupState::Established => {
|
|
log::info!("Published zeroconf service");
|
|
}
|
|
EntryGroupState::Collision => {
|
|
// This most likely means that librespot has unintentionally been started twice.
|
|
// Thus, don't retry with a new name, but abort.
|
|
//
|
|
// Note that the error would usually already be returned by
|
|
// entry_group.add_service above, so this state_changed handler
|
|
// won't be hit.
|
|
//
|
|
// EntryGroup has been withdrawn at this point already!
|
|
log::error!("zeroconf collision for name '{}'", &name);
|
|
return Err(zbus::Error::Failure(format!("zeroconf collision for name: {}", name)).into());
|
|
}
|
|
EntryGroupState::Failure => {
|
|
// TODO: Back off/treat as fatal?
|
|
// EntryGroup has been withdrawn at this point already!
|
|
// There seems to be no code in Avahi that actually sets this state.
|
|
log::error!("zeroconf failure: {}", error);
|
|
return Err(zbus::Error::Failure(format!("zeroconf failure: {}", error)).into());
|
|
}
|
|
}
|
|
}
|
|
_name_owner_change = stream.next() => {
|
|
break 'monitor_service;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Avahi disappeared (or the service was immediately taken over by a
|
|
// new daemon) => drop all handles, and reconnect
|
|
log::info!("Avahi disappeared, trying to reconnect");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "with-avahi")]
|
|
fn launch_avahi(
|
|
name: Cow<'static, str>,
|
|
_zeroconf_ip: Vec<std::net::IpAddr>,
|
|
port: u16,
|
|
status_tx: mpsc::UnboundedSender<DiscoveryEvent>,
|
|
) -> Result<DnsSdHandle, Error> {
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
|
|
let task_handle = tokio::spawn(async move {
|
|
let mut entry_group = None;
|
|
tokio::select! {
|
|
res = avahi_task(name, port, &mut entry_group) => {
|
|
if let Err(e) = res {
|
|
log::error!("Avahi error: {}", e);
|
|
let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));
|
|
}
|
|
},
|
|
_ = shutdown_rx => {
|
|
if let Some(entry_group) = entry_group.as_mut() {
|
|
if let Err(e) = entry_group.free().await {
|
|
log::warn!("Failed to un-publish zeroconf service: {}", e);
|
|
} else {
|
|
log::debug!("Un-published zeroconf service");
|
|
}
|
|
}
|
|
},
|
|
}
|
|
});
|
|
|
|
Ok(DnsSdHandle {
|
|
task_handle,
|
|
shutdown_tx,
|
|
})
|
|
}
|
|
|
|
#[cfg(feature = "with-dns-sd")]
|
|
fn launch_dns_sd(
|
|
name: Cow<'static, str>,
|
|
_zeroconf_ip: Vec<std::net::IpAddr>,
|
|
port: u16,
|
|
status_tx: mpsc::UnboundedSender<DiscoveryEvent>,
|
|
) -> Result<DnsSdHandle, Error> {
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
|
|
let task_handle = tokio::task::spawn_blocking(move || {
|
|
let inner = move || -> Result<(), DiscoveryError> {
|
|
let svc = dns_sd::DNSService::register(
|
|
Some(name.as_ref()),
|
|
DNS_SD_SERVICE_NAME,
|
|
None,
|
|
None,
|
|
port,
|
|
&TXT_RECORD,
|
|
)
|
|
.map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))?;
|
|
|
|
let _ = shutdown_rx.blocking_recv();
|
|
|
|
std::mem::drop(svc);
|
|
|
|
Ok(())
|
|
};
|
|
|
|
if let Err(e) = inner() {
|
|
log::error!("dns_sd error: {}", e);
|
|
let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));
|
|
}
|
|
});
|
|
|
|
Ok(DnsSdHandle {
|
|
shutdown_tx,
|
|
task_handle,
|
|
})
|
|
}
|
|
|
|
#[cfg(feature = "with-libmdns")]
|
|
fn launch_libmdns(
|
|
name: Cow<'static, str>,
|
|
zeroconf_ip: Vec<std::net::IpAddr>,
|
|
port: u16,
|
|
status_tx: mpsc::UnboundedSender<DiscoveryEvent>,
|
|
) -> Result<DnsSdHandle, Error> {
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
|
|
let task_handle = tokio::task::spawn_blocking(move || {
|
|
let inner = move || -> Result<(), DiscoveryError> {
|
|
let svc = if !zeroconf_ip.is_empty() {
|
|
libmdns::Responder::spawn_with_ip_list(
|
|
&tokio::runtime::Handle::current(),
|
|
zeroconf_ip,
|
|
)
|
|
} else {
|
|
libmdns::Responder::spawn(&tokio::runtime::Handle::current())
|
|
}
|
|
.map_err(|e| DiscoveryError::DnsSdError(Box::new(e)))
|
|
.unwrap()
|
|
.register(
|
|
DNS_SD_SERVICE_NAME.to_owned(),
|
|
name.into_owned(),
|
|
port,
|
|
&TXT_RECORD,
|
|
);
|
|
|
|
let _ = shutdown_rx.blocking_recv();
|
|
|
|
std::mem::drop(svc);
|
|
|
|
Ok(())
|
|
};
|
|
|
|
if let Err(e) = inner() {
|
|
log::error!("libmdns error: {}", e);
|
|
let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));
|
|
}
|
|
});
|
|
|
|
Ok(DnsSdHandle {
|
|
shutdown_tx,
|
|
task_handle,
|
|
})
|
|
}
|
|
|
|
impl Builder {
|
|
/// Starts a new builder using the provided device and client IDs.
|
|
pub fn new<T: Into<String>>(device_id: T, client_id: T) -> Self {
|
|
Self {
|
|
server_config: server::Config {
|
|
name: "Librespot".into(),
|
|
device_type: DeviceType::default(),
|
|
is_group: false,
|
|
device_id: device_id.into(),
|
|
client_id: client_id.into(),
|
|
},
|
|
port: 0,
|
|
zeroconf_ip: vec![],
|
|
zeroconf_backend: None,
|
|
}
|
|
}
|
|
|
|
/// Sets the name to be displayed. Default is `"Librespot"`.
|
|
pub fn name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
|
|
self.server_config.name = name.into();
|
|
self
|
|
}
|
|
|
|
/// Sets the device type which is visible as icon in other Spotify clients. Default is `Speaker`.
|
|
pub fn device_type(mut self, device_type: DeviceType) -> Self {
|
|
self.server_config.device_type = device_type;
|
|
self
|
|
}
|
|
|
|
/// Sets whether the device is a group. This affects the icon in Spotify clients. Default is `false`.
|
|
pub fn is_group(mut self, is_group: bool) -> Self {
|
|
self.server_config.is_group = is_group;
|
|
self
|
|
}
|
|
|
|
/// Set the ip addresses on which it should listen to incoming connections. The default is all interfaces.
|
|
pub fn zeroconf_ip(mut self, zeroconf_ip: Vec<std::net::IpAddr>) -> Self {
|
|
self.zeroconf_ip = zeroconf_ip;
|
|
self
|
|
}
|
|
|
|
/// Set the zeroconf (MDNS and DNS-SD) implementation to use.
|
|
pub fn zeroconf_backend(mut self, zeroconf_backend: DnsSdServiceBuilder) -> Self {
|
|
self.zeroconf_backend = Some(zeroconf_backend);
|
|
self
|
|
}
|
|
|
|
/// Sets the port on which it should listen to incoming connections.
|
|
/// The default value `0` means any port.
|
|
pub fn port(mut self, port: u16) -> Self {
|
|
self.port = port;
|
|
self
|
|
}
|
|
|
|
/// Sets up the [`Discovery`] instance.
|
|
///
|
|
/// # Errors
|
|
/// If setting up the mdns service or creating the server fails, this function returns an error.
|
|
pub fn launch(self) -> Result<Discovery, Error> {
|
|
let name = self.server_config.name.clone();
|
|
let zeroconf_ip = self.zeroconf_ip;
|
|
|
|
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
|
|
|
let mut port = self.port;
|
|
let server = DiscoveryServer::new(self.server_config, &mut port, event_tx.clone())?;
|
|
|
|
let launch_svc = self.zeroconf_backend.unwrap_or(find(None)?);
|
|
let svc = launch_svc(name, zeroconf_ip, port, event_tx)?;
|
|
Ok(Discovery {
|
|
server,
|
|
svc,
|
|
event_rx,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Discovery {
|
|
/// Starts a [`Builder`] with the provided device id.
|
|
pub fn builder<T: Into<String>>(device_id: T, client_id: T) -> Builder {
|
|
Builder::new(device_id, client_id)
|
|
}
|
|
|
|
/// Create a new instance with the specified device id and default paramaters.
|
|
pub fn new<T: Into<String>>(device_id: T, client_id: T) -> Result<Self, Error> {
|
|
Self::builder(device_id, client_id).launch()
|
|
}
|
|
|
|
pub async fn shutdown(self) {
|
|
tokio::join!(self.server.shutdown(), self.svc.shutdown(),);
|
|
}
|
|
}
|
|
|
|
impl Stream for Discovery {
|
|
type Item = Credentials;
|
|
|
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
match Pin::new(&mut self.event_rx).poll_recv(cx) {
|
|
// Yields credentials
|
|
Poll::Ready(Some(DiscoveryEvent::Credentials(creds))) => Poll::Ready(Some(creds)),
|
|
// Also terminate the stream on fatal server or MDNS/DNS-SD errors.
|
|
Poll::Ready(Some(
|
|
DiscoveryEvent::ServerError(_) | DiscoveryEvent::ZeroconfError(_),
|
|
)) => Poll::Ready(None),
|
|
Poll::Ready(None) => Poll::Ready(None),
|
|
Poll::Pending => Poll::Pending,
|
|
}
|
|
}
|
|
}
|