Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- [audio] Fixed integer overflow in throughput calculation
- [discovery] Respect `--zeroconf-ip` for the Avahi backend by restricting advertisements to the requested interfaces ([#486](https://github.com/librespot-org/librespot/issues/486), [#887](https://github.com/librespot-org/librespot/issues/887))
- [main] Fixed `--volume-ctrl fixed` not disabling volume control
- [core] Fix default permissions on credentials file and warn user if file is world readable
- [core] Try all resolved addresses for the dealer connection instead of failing after the first one.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion discovery/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ edition.workspace = true
default = ["with-libmdns", "native-tls"]

# Discovery backends
with-avahi = ["dep:serde", "dep:zbus", "futures-util/async-await-macro"]
with-avahi = ["dep:libc", "dep:serde", "dep:zbus", "futures-util/async-await-macro"]
with-dns-sd = ["dep:dns-sd"]
with-libmdns = ["dep:libmdns"]

Expand Down Expand Up @@ -42,6 +42,7 @@ hyper-util = { version = "0.1", features = [
"service",
] }
libmdns = { version = "0.10", optional = true }
libc = { version = "0.2", optional = true }
log = "0.4"
rand = { version = "0.9", default-features = false, features = ["thread_rng"] }
serde = { version = "1", default-features = false, features = [
Expand Down
187 changes: 187 additions & 0 deletions discovery/src/ifaddrs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
pub use imp::if_indices_for_ips;

#[cfg(unix)]
mod imp {
use std::{
collections::{BTreeMap, BTreeSet},
ffi::CStr,
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
ptr,
};

pub fn if_indices_for_ips(ips: &[IpAddr]) -> Result<Vec<i32>, io::Error> {
let ifaddrs = InterfaceAddresses::load()?;
let mut indices_by_ip = BTreeMap::<IpAddr, BTreeSet<i32>>::new();
let mut current = ifaddrs.head();

while let Some(interface) = InterfaceAddress::from_ptr(current) {
if let (Some(ip), Some(index)) = (interface.ip_addr(), interface.index()) {
indices_by_ip.entry(ip).or_default().insert(index);
}
current = interface.next();
}

let mut matched_indices = BTreeSet::new();
for ip in ips {
if let Some(indices) = indices_by_ip.get(ip) {
matched_indices.extend(indices.iter().copied());
} else {
log::warn!("Ignoring unrecognised zeroconf IP {}", ip);
}
}

Ok(matched_indices.into_iter().collect())
}

struct InterfaceAddresses(*mut libc::ifaddrs);

impl InterfaceAddresses {
fn load() -> Result<Self, io::Error> {
let mut ifaddrs = ptr::null_mut();
let result = unsafe {
// SAFETY: `getifaddrs` initializes `ifaddrs` on success and does not retain the pointer.
libc::getifaddrs(&mut ifaddrs)
};

if result == 0 {
Ok(Self(ifaddrs))
} else {
Err(io::Error::last_os_error())
}
}

fn head(&self) -> *mut libc::ifaddrs {
self.0
}
}

impl Drop for InterfaceAddresses {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
// SAFETY: `self.0` was returned by `getifaddrs` and is freed exactly once here.
libc::freeifaddrs(self.0);
}
}
}
}

struct InterfaceAddress<'a>(&'a libc::ifaddrs);

impl<'a> InterfaceAddress<'a> {
fn from_ptr(ptr: *mut libc::ifaddrs) -> Option<Self> {
unsafe {
// SAFETY: The `ifaddrs` list is valid for the lifetime of `InterfaceAddresses`.
ptr.as_ref().map(Self)
}
}

fn next(&self) -> *mut libc::ifaddrs {
self.0.ifa_next
}

fn ip_addr(&self) -> Option<IpAddr> {
sockaddr_to_ip_addr(self.0.ifa_addr)
}

fn index(&self) -> Option<i32> {
if self.0.ifa_name.is_null() {
return None;
}

let name = unsafe {
// SAFETY: `ifa_name` is a valid, null-terminated C string for a live `ifaddrs` entry.
CStr::from_ptr(self.0.ifa_name)
};
let raw_index = unsafe {
// SAFETY: `ifa_name` points to the current interface name for this `ifaddrs` entry.
libc::if_nametoindex(self.0.ifa_name)
};

if raw_index == 0 {
log::warn!(
"Failed to resolve interface index for {}: {}",
name.to_string_lossy(),
io::Error::last_os_error()
);
return None;
}

match i32::try_from(raw_index) {
Ok(index) => Some(index),
Err(_) => {
log::warn!(
"Ignoring interface {} because index {} does not fit in i32",
name.to_string_lossy(),
raw_index
);
None
}
}
}
}

fn sockaddr_to_ip_addr(sockaddr: *const libc::sockaddr) -> Option<IpAddr> {
if sockaddr.is_null() {
return None;
}

let family = unsafe {
// SAFETY: `sockaddr` points to a valid socket address owned by the `ifaddrs` list.
(*sockaddr).sa_family as libc::c_int
};

match family {
libc::AF_INET => {
let sockaddr = unsafe {
// SAFETY: The family check above guarantees the cast to `sockaddr_in`.
&*sockaddr.cast::<libc::sockaddr_in>()
};
Some(IpAddr::V4(Ipv4Addr::from(u32::from_be(
sockaddr.sin_addr.s_addr,
))))
}
libc::AF_INET6 => {
let sockaddr = unsafe {
// SAFETY: The family check above guarantees the cast to `sockaddr_in6`.
&*sockaddr.cast::<libc::sockaddr_in6>()
};
Some(IpAddr::V6(Ipv6Addr::from(sockaddr.sin6_addr.s6_addr)))
}
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::if_indices_for_ips;
use std::net::IpAddr;

#[test]
fn loopback_ip_resolves_to_positive_index() {
let indices = if_indices_for_ips(&["127.0.0.1".parse::<IpAddr>().unwrap()]).unwrap();

assert!(!indices.is_empty());
assert!(indices.iter().all(|index| *index > 0));
}

#[test]
fn unknown_ip_is_skipped_without_error() {
let indices = if_indices_for_ips(&["192.0.2.1".parse::<IpAddr>().unwrap()]).unwrap();

assert!(indices.is_empty());
}
}
}

#[cfg(not(unix))]
mod imp {
use std::{io, net::IpAddr};

pub fn if_indices_for_ips(_ips: &[IpAddr]) -> Result<Vec<i32>, io::Error> {
Err(io::Error::other(
"Avahi interface resolution is only supported on Unix platforms",
))
}
}
62 changes: 45 additions & 17 deletions discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
//! and spawns an http server to answer requests of Spotify clients.

mod avahi;
#[cfg(feature = "with-avahi")]
mod ifaddrs;
mod server;

use std::{
Expand Down Expand Up @@ -180,6 +182,7 @@ const TXT_RECORD: [&str; 2] = ["VERSION=1.0", "CPath=/"];
#[cfg(feature = "with-avahi")]
async fn avahi_task(
name: Cow<'static, str>,
zeroconf_ip: Vec<std::net::IpAddr>,
port: u16,
entry_group: &mut Option<avahi::EntryGroupProxy<'_>>,
) -> Result<(), DiscoveryError> {
Expand Down Expand Up @@ -240,21 +243,46 @@ async fn avahi_task(
.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?;
let interface_indices = if zeroconf_ip.is_empty() {
vec![-1] // AVAHI_IF_UNSPEC
} else {
match ifaddrs::if_indices_for_ips(&zeroconf_ip) {
Ok(indices) if !indices.is_empty() => indices,
Ok(_) => {
log::warn!(
"No Avahi interfaces matched configured zeroconf IP(s) {:?}; advertising on all interfaces instead",
zeroconf_ip
);
vec![-1]
}
Err(error) => {
log::warn!(
"Failed to resolve Avahi interfaces for configured zeroconf IP(s) {:?}: {}; advertising on all interfaces instead",
zeroconf_ip,
error
);
vec![-1]
}
}
};

for interface_index in interface_indices {
entry_group
.as_mut()
.unwrap()
.add_service(
interface_index,
-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(str::as_bytes),
)
.await?;
}

entry_group.as_mut().unwrap().commit().await?;
log::debug!("Commited zeroconf service with name {}", &name);
Expand Down Expand Up @@ -312,7 +340,7 @@ async fn avahi_task(
#[cfg(feature = "with-avahi")]
fn launch_avahi(
name: Cow<'static, str>,
_zeroconf_ip: Vec<std::net::IpAddr>,
zeroconf_ip: Vec<std::net::IpAddr>,
port: u16,
status_tx: mpsc::UnboundedSender<DiscoveryEvent>,
) -> Result<DnsSdHandle, Error> {
Expand All @@ -321,7 +349,7 @@ fn launch_avahi(
let task_handle = tokio::spawn(async move {
let mut entry_group = None;
tokio::select! {
res = avahi_task(name, port, &mut entry_group) => {
res = avahi_task(name, zeroconf_ip, port, &mut entry_group) => {
if let Err(e) = res {
log::error!("Avahi error: {}", e);
let _ = status_tx.send(DiscoveryEvent::ZeroconfError(e));
Expand Down
Loading