mirror of
https://github.com/librespot-org/librespot.git
synced 2024-12-18 17:11:53 +00:00
Add librespot-discovery crate
This commit is contained in:
parent
68818758a2
commit
ebea5397b9
11 changed files with 473 additions and 314 deletions
31
Cargo.lock
generated
31
Cargo.lock
generated
|
@ -1143,6 +1143,7 @@ dependencies = [
|
|||
"librespot-audio",
|
||||
"librespot-connect",
|
||||
"librespot-core",
|
||||
"librespot-discovery",
|
||||
"librespot-metadata",
|
||||
"librespot-playback",
|
||||
"librespot-protocol",
|
||||
|
@ -1172,15 +1173,8 @@ dependencies = [
|
|||
name = "librespot-connect"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes-ctr",
|
||||
"base64",
|
||||
"dns-sd",
|
||||
"form_urlencoded",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"hyper",
|
||||
"libmdns",
|
||||
"librespot-core",
|
||||
"librespot-playback",
|
||||
"librespot-protocol",
|
||||
|
@ -1189,10 +1183,8 @@ dependencies = [
|
|||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha-1",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1235,6 +1227,27 @@ dependencies = [
|
|||
"vergen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librespot-discovery"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes-ctr",
|
||||
"base64",
|
||||
"cfg-if 1.0.0",
|
||||
"dns-sd",
|
||||
"form_urlencoded",
|
||||
"futures-core",
|
||||
"hmac",
|
||||
"hyper",
|
||||
"libmdns",
|
||||
"librespot-core",
|
||||
"log",
|
||||
"rand",
|
||||
"serde_json",
|
||||
"sha-1",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librespot-metadata"
|
||||
version = "0.2.0"
|
||||
|
|
|
@ -33,6 +33,10 @@ path = "core"
|
|||
version = "0.2.0"
|
||||
features = ["apresolve"]
|
||||
|
||||
[dependencies.librespot-discovery]
|
||||
path = "discovery"
|
||||
version = "0.2.0"
|
||||
|
||||
[dependencies.librespot-metadata]
|
||||
path = "metadata"
|
||||
version = "0.2.0"
|
||||
|
@ -72,7 +76,7 @@ gstreamer-backend = ["librespot-playback/gstreamer-backend"]
|
|||
with-tremor = ["librespot-playback/with-tremor"]
|
||||
with-vorbis = ["librespot-playback/with-vorbis"]
|
||||
|
||||
with-dns-sd = ["librespot-connect/with-dns-sd"]
|
||||
with-dns-sd = ["librespot-discovery/with-dns-sd"]
|
||||
|
||||
default = ["rodio-backend"]
|
||||
|
||||
|
|
|
@ -8,25 +8,15 @@ repository = "https://github.com/librespot-org/librespot"
|
|||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
aes-ctr = "0.6"
|
||||
base64 = "0.13"
|
||||
form_urlencoded = "1.0"
|
||||
futures-core = "0.3"
|
||||
futures-util = { version = "0.3.5", default_features = false }
|
||||
hmac = "0.11"
|
||||
hyper = { version = "0.14", features = ["server", "http1", "tcp"] }
|
||||
libmdns = "0.6"
|
||||
log = "0.4"
|
||||
protobuf = "2.14.0"
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0.25"
|
||||
sha-1 = "0.9"
|
||||
tokio = { version = "1.0", features = ["macros", "rt", "sync"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.0", features = ["macros", "sync"] }
|
||||
tokio-stream = "0.1.1"
|
||||
url = "2.1"
|
||||
|
||||
dns-sd = { version = "0.1.3", optional = true }
|
||||
|
||||
[dependencies.librespot-core]
|
||||
path = "../core"
|
||||
|
@ -39,7 +29,3 @@ version = "0.2.0"
|
|||
[dependencies.librespot-protocol]
|
||||
path = "../protocol"
|
||||
version = "0.2.0"
|
||||
|
||||
[features]
|
||||
with-dns-sd = ["dns-sd"]
|
||||
|
||||
|
|
|
@ -1,263 +0,0 @@
|
|||
use aes_ctr::cipher::generic_array::GenericArray;
|
||||
use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher};
|
||||
use aes_ctr::Aes128Ctr;
|
||||
use futures_core::Stream;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use hyper::{Body, Method, Request, Response, StatusCode};
|
||||
use serde_json::json;
|
||||
use sha1::{Digest, Sha1};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
#[cfg(feature = "with-dns-sd")]
|
||||
use dns_sd::DNSService;
|
||||
|
||||
use librespot_core::authentication::Credentials;
|
||||
use librespot_core::config::ConnectConfig;
|
||||
use librespot_core::diffie_hellman::DhLocalKeys;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Discovery(Arc<DiscoveryInner>);
|
||||
struct DiscoveryInner {
|
||||
config: ConnectConfig,
|
||||
device_id: String,
|
||||
keys: DhLocalKeys,
|
||||
tx: mpsc::UnboundedSender<Credentials>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn new(
|
||||
config: ConnectConfig,
|
||||
device_id: String,
|
||||
) -> (Discovery, mpsc::UnboundedReceiver<Credentials>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
let discovery = Discovery(Arc::new(DiscoveryInner {
|
||||
config,
|
||||
device_id,
|
||||
keys: DhLocalKeys::random(&mut rand::thread_rng()),
|
||||
tx,
|
||||
}));
|
||||
|
||||
(discovery, rx)
|
||||
}
|
||||
|
||||
fn handle_get_info(&self, _: BTreeMap<Cow<'_, str>, Cow<'_, str>>) -> Response<hyper::Body> {
|
||||
let public_key = base64::encode(&self.0.keys.public_key());
|
||||
|
||||
let result = json!({
|
||||
"status": 101,
|
||||
"statusString": "ERROR-OK",
|
||||
"spotifyError": 0,
|
||||
"version": "2.7.1",
|
||||
"deviceID": (self.0.device_id),
|
||||
"remoteName": (self.0.config.name),
|
||||
"activeUser": "",
|
||||
"publicKey": (public_key),
|
||||
"deviceType": (self.0.config.device_type.to_string().to_uppercase()),
|
||||
"libraryVersion": "0.1.0",
|
||||
"accountReq": "PREMIUM",
|
||||
"brandDisplayName": "librespot",
|
||||
"modelDisplayName": "librespot",
|
||||
"resolverVersion": "0",
|
||||
"groupStatus": "NONE",
|
||||
"voiceSupport": "NO",
|
||||
});
|
||||
|
||||
let body = result.to_string();
|
||||
Response::new(Body::from(body))
|
||||
}
|
||||
|
||||
fn handle_add_user(
|
||||
&self,
|
||||
params: BTreeMap<Cow<'_, str>, Cow<'_, str>>,
|
||||
) -> Response<hyper::Body> {
|
||||
let username = params.get("userName").unwrap().as_ref();
|
||||
let encrypted_blob = params.get("blob").unwrap();
|
||||
let client_key = params.get("clientKey").unwrap();
|
||||
|
||||
let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap();
|
||||
|
||||
let shared_key = self
|
||||
.0
|
||||
.keys
|
||||
.shared_secret(&base64::decode(client_key.as_bytes()).unwrap());
|
||||
|
||||
let iv = &encrypted_blob[0..16];
|
||||
let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20];
|
||||
let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()];
|
||||
|
||||
let base_key = Sha1::digest(&shared_key);
|
||||
let base_key = &base_key[..16];
|
||||
|
||||
let checksum_key = {
|
||||
let mut h = HmacSha1::new_from_slice(base_key).expect("HMAC can take key of any size");
|
||||
h.update(b"checksum");
|
||||
h.finalize().into_bytes()
|
||||
};
|
||||
|
||||
let encryption_key = {
|
||||
let mut h = HmacSha1::new_from_slice(&base_key).expect("HMAC can take key of any size");
|
||||
h.update(b"encryption");
|
||||
h.finalize().into_bytes()
|
||||
};
|
||||
|
||||
let mut h = HmacSha1::new_from_slice(&checksum_key).expect("HMAC can take key of any size");
|
||||
h.update(encrypted);
|
||||
if h.verify(cksum).is_err() {
|
||||
warn!("Login error for user {:?}: MAC mismatch", username);
|
||||
let result = json!({
|
||||
"status": 102,
|
||||
"spotifyError": 1,
|
||||
"statusString": "ERROR-MAC"
|
||||
});
|
||||
|
||||
let body = result.to_string();
|
||||
return Response::new(Body::from(body));
|
||||
}
|
||||
|
||||
let decrypted = {
|
||||
let mut data = encrypted.to_vec();
|
||||
let mut cipher = Aes128Ctr::new(
|
||||
&GenericArray::from_slice(&encryption_key[0..16]),
|
||||
&GenericArray::from_slice(iv),
|
||||
);
|
||||
cipher.apply_keystream(&mut data);
|
||||
String::from_utf8(data).unwrap()
|
||||
};
|
||||
|
||||
let credentials =
|
||||
Credentials::with_blob(username.to_string(), &decrypted, &self.0.device_id);
|
||||
|
||||
self.0.tx.send(credentials).unwrap();
|
||||
|
||||
let result = json!({
|
||||
"status": 101,
|
||||
"spotifyError": 0,
|
||||
"statusString": "ERROR-OK"
|
||||
});
|
||||
|
||||
let body = result.to_string();
|
||||
Response::new(Body::from(body))
|
||||
}
|
||||
|
||||
fn not_found(&self) -> Response<hyper::Body> {
|
||||
let mut res = Response::default();
|
||||
*res.status_mut() = StatusCode::NOT_FOUND;
|
||||
res
|
||||
}
|
||||
|
||||
async fn call(self, request: Request<Body>) -> hyper::Result<Response<Body>> {
|
||||
let mut params = BTreeMap::new();
|
||||
|
||||
let (parts, body) = request.into_parts();
|
||||
|
||||
if let Some(query) = parts.uri.query() {
|
||||
let query_params = url::form_urlencoded::parse(query.as_bytes());
|
||||
params.extend(query_params);
|
||||
}
|
||||
|
||||
if parts.method != Method::GET {
|
||||
debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params);
|
||||
}
|
||||
|
||||
let body = hyper::body::to_bytes(body).await?;
|
||||
|
||||
params.extend(url::form_urlencoded::parse(&body));
|
||||
|
||||
Ok(
|
||||
match (parts.method, params.get("action").map(AsRef::as_ref)) {
|
||||
(Method::GET, Some("getInfo")) => self.handle_get_info(params),
|
||||
(Method::POST, Some("addUser")) => self.handle_add_user(params),
|
||||
_ => self.not_found(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "with-dns-sd")]
|
||||
pub struct DiscoveryStream {
|
||||
credentials: mpsc::UnboundedReceiver<Credentials>,
|
||||
_svc: DNSService,
|
||||
_close_tx: oneshot::Sender<Infallible>,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "with-dns-sd"))]
|
||||
pub struct DiscoveryStream {
|
||||
credentials: mpsc::UnboundedReceiver<Credentials>,
|
||||
_svc: libmdns::Service,
|
||||
_close_tx: oneshot::Sender<Infallible>,
|
||||
}
|
||||
|
||||
pub fn discovery(
|
||||
config: ConnectConfig,
|
||||
device_id: String,
|
||||
port: u16,
|
||||
) -> io::Result<DiscoveryStream> {
|
||||
let (discovery, creds_rx) = Discovery::new(config.clone(), device_id);
|
||||
let (close_tx, close_rx) = oneshot::channel();
|
||||
|
||||
let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), port);
|
||||
|
||||
let make_service = make_service_fn(move |_| {
|
||||
let discovery = discovery.clone();
|
||||
async move { Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().call(request))) }
|
||||
});
|
||||
|
||||
let server = hyper::Server::bind(&address).serve(make_service);
|
||||
|
||||
let s_port = server.local_addr().port();
|
||||
debug!("Zeroconf server listening on 0.0.0.0:{}", s_port);
|
||||
|
||||
tokio::spawn(server.with_graceful_shutdown(async {
|
||||
close_rx.await.unwrap_err();
|
||||
debug!("Shutting down discovery server");
|
||||
}));
|
||||
|
||||
#[cfg(feature = "with-dns-sd")]
|
||||
let svc = DNSService::register(
|
||||
Some(&*config.name),
|
||||
"_spotify-connect._tcp",
|
||||
None,
|
||||
None,
|
||||
s_port,
|
||||
&["VERSION=1.0", "CPath=/"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
#[cfg(not(feature = "with-dns-sd"))]
|
||||
let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?;
|
||||
|
||||
#[cfg(not(feature = "with-dns-sd"))]
|
||||
let svc = responder.register(
|
||||
"_spotify-connect._tcp".to_owned(),
|
||||
config.name,
|
||||
s_port,
|
||||
&["VERSION=1.0", "CPath=/"],
|
||||
);
|
||||
|
||||
Ok(DiscoveryStream {
|
||||
credentials: creds_rx,
|
||||
_svc: svc,
|
||||
_close_tx: close_tx,
|
||||
})
|
||||
}
|
||||
|
||||
impl Stream for DiscoveryStream {
|
||||
type Item = Credentials;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
self.credentials.poll_recv(cx)
|
||||
}
|
||||
}
|
|
@ -6,5 +6,4 @@ use librespot_playback as playback;
|
|||
use librespot_protocol as protocol;
|
||||
|
||||
pub mod context;
|
||||
pub mod discovery;
|
||||
pub mod spirc;
|
||||
|
|
|
@ -71,30 +71,43 @@ impl FromStr for DeviceType {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<&DeviceType> for &str {
|
||||
fn from(d: &DeviceType) -> &'static str {
|
||||
use self::DeviceType::*;
|
||||
match d {
|
||||
Unknown => "Unknown",
|
||||
Computer => "Computer",
|
||||
Tablet => "Tablet",
|
||||
Smartphone => "Smartphone",
|
||||
Speaker => "Speaker",
|
||||
Tv => "TV",
|
||||
Avr => "AVR",
|
||||
Stb => "STB",
|
||||
AudioDongle => "AudioDongle",
|
||||
GameConsole => "GameConsole",
|
||||
CastAudio => "CastAudio",
|
||||
CastVideo => "CastVideo",
|
||||
Automobile => "Automobile",
|
||||
Smartwatch => "Smartwatch",
|
||||
Chromebook => "Chromebook",
|
||||
UnknownSpotify => "UnknownSpotify",
|
||||
CarThing => "CarThing",
|
||||
Observer => "Observer",
|
||||
HomeThing => "HomeThing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeviceType> for &str {
|
||||
fn from(d: DeviceType) -> &'static str {
|
||||
(&d).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DeviceType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::DeviceType::*;
|
||||
match *self {
|
||||
Unknown => f.write_str("Unknown"),
|
||||
Computer => f.write_str("Computer"),
|
||||
Tablet => f.write_str("Tablet"),
|
||||
Smartphone => f.write_str("Smartphone"),
|
||||
Speaker => f.write_str("Speaker"),
|
||||
Tv => f.write_str("TV"),
|
||||
Avr => f.write_str("AVR"),
|
||||
Stb => f.write_str("STB"),
|
||||
AudioDongle => f.write_str("AudioDongle"),
|
||||
GameConsole => f.write_str("GameConsole"),
|
||||
CastAudio => f.write_str("CastAudio"),
|
||||
CastVideo => f.write_str("CastVideo"),
|
||||
Automobile => f.write_str("Automobile"),
|
||||
Smartwatch => f.write_str("Smartwatch"),
|
||||
Chromebook => f.write_str("Chromebook"),
|
||||
UnknownSpotify => f.write_str("UnknownSpotify"),
|
||||
CarThing => f.write_str("CarThing"),
|
||||
Observer => f.write_str("Observer"),
|
||||
HomeThing => f.write_str("HomeThing"),
|
||||
}
|
||||
let str: &str = self.into();
|
||||
f.write_str(str)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
33
discovery/Cargo.toml
Normal file
33
discovery/Cargo.toml
Normal file
|
@ -0,0 +1,33 @@
|
|||
[package]
|
||||
name = "librespot-discovery"
|
||||
version = "0.2.0"
|
||||
authors = ["Paul Lietar <paul@lietar.net>"]
|
||||
description = "The discovery logic for librespot"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/librespot-org/librespot"
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
aes-ctr = "0.6"
|
||||
base64 = "0.13"
|
||||
cfg-if = "1.0"
|
||||
form_urlencoded = "1.0"
|
||||
futures-core = "0.3"
|
||||
hmac = "0.11"
|
||||
hyper = { version = "0.14", features = ["server", "http1", "tcp"] }
|
||||
libmdns = "0.6"
|
||||
log = "0.4"
|
||||
rand = "0.8"
|
||||
serde_json = "1.0.25"
|
||||
sha-1 = "0.9"
|
||||
tokio = { version = "1.0", features = ["sync", "rt"] }
|
||||
|
||||
dns-sd = { version = "0.1.3", optional = true }
|
||||
|
||||
[dependencies.librespot-core]
|
||||
path = "../core"
|
||||
default_features = false
|
||||
version = "0.2.0"
|
||||
|
||||
[features]
|
||||
with-dns-sd = ["dns-sd"]
|
134
discovery/src/lib.rs
Normal file
134
discovery/src/lib.rs
Normal file
|
@ -0,0 +1,134 @@
|
|||
//! 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`].
|
||||
|
||||
#![warn(clippy::all, missing_docs, rust_2018_idioms)]
|
||||
|
||||
mod server;
|
||||
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
use futures_core::Stream;
|
||||
use librespot_core as core;
|
||||
|
||||
use self::server::DiscoveryServer;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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,
|
||||
|
||||
#[cfg(not(feature = "with-dns-sd"))]
|
||||
_svc: libmdns::Service,
|
||||
#[cfg(feature = "with-dns-sd")]
|
||||
_svc: dns_sd::DNSService,
|
||||
}
|
||||
|
||||
/// A builder for [`Discovery`].
|
||||
pub struct Builder {
|
||||
server_config: server::Config,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Starts a new builder using the provided device id.
|
||||
pub fn new(device_id: String) -> Self {
|
||||
Self {
|
||||
server_config: server::Config {
|
||||
name: "Librespot".into(),
|
||||
device_type: DeviceType::default(),
|
||||
device_id,
|
||||
},
|
||||
port: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the name to be displayed. Default is `"Librespot"`.
|
||||
pub fn name(mut self, name: String) -> 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 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) -> io::Result<Discovery> {
|
||||
Discovery::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// Starts a [`Builder`] with the provided device id.
|
||||
pub fn builder(device_id: String) -> Builder {
|
||||
Builder::new(device_id)
|
||||
}
|
||||
|
||||
fn new(builder: Builder) -> io::Result<Self> {
|
||||
let name = builder.server_config.name.clone();
|
||||
let mut port = builder.port;
|
||||
let server = DiscoveryServer::new(builder.server_config, &mut port)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let svc;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "with-dns-sd")] {
|
||||
svc = dns_sd::DNSService::register(
|
||||
Some(name.as_ref()),
|
||||
"_spotify-connect._tcp",
|
||||
None,
|
||||
None,
|
||||
port,
|
||||
&["VERSION=1.0", "CPath=/"],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
} else {
|
||||
let responder = libmdns::Responder::spawn(&tokio::runtime::Handle::current())?;
|
||||
svc = responder.register(
|
||||
"_spotify-connect._tcp".to_owned(),
|
||||
name.into_owned(),
|
||||
port,
|
||||
&["VERSION=1.0", "CPath=/"],
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { server, _svc: svc })
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for Discovery {
|
||||
type Item = Credentials;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
Pin::new(&mut self.server).poll_next(cx)
|
||||
}
|
||||
}
|
236
discovery/src/server.rs
Normal file
236
discovery/src/server.rs
Normal file
|
@ -0,0 +1,236 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use aes_ctr::cipher::generic_array::GenericArray;
|
||||
use aes_ctr::cipher::{NewStreamCipher, SyncStreamCipher};
|
||||
use aes_ctr::Aes128Ctr;
|
||||
use futures_core::Stream;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use hyper::{Body, Method, Request, Response, StatusCode};
|
||||
use log::{debug, warn};
|
||||
use serde_json::json;
|
||||
use sha1::{Digest, Sha1};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::core::authentication::Credentials;
|
||||
use crate::core::config::DeviceType;
|
||||
use crate::core::diffie_hellman::DhLocalKeys;
|
||||
|
||||
type Params<'a> = BTreeMap<Cow<'a, str>, Cow<'a, str>>;
|
||||
|
||||
pub struct Config {
|
||||
pub name: Cow<'static, str>,
|
||||
pub device_type: DeviceType,
|
||||
pub device_id: String,
|
||||
}
|
||||
|
||||
struct RequestHandler {
|
||||
config: Config,
|
||||
keys: DhLocalKeys,
|
||||
tx: mpsc::UnboundedSender<Credentials>,
|
||||
}
|
||||
|
||||
impl RequestHandler {
|
||||
fn new(config: Config) -> (Self, mpsc::UnboundedReceiver<Credentials>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
let discovery = Self {
|
||||
config,
|
||||
keys: DhLocalKeys::random(&mut rand::thread_rng()),
|
||||
tx,
|
||||
};
|
||||
|
||||
(discovery, rx)
|
||||
}
|
||||
|
||||
fn handle_get_info(&self) -> Response<hyper::Body> {
|
||||
let public_key = base64::encode(&self.keys.public_key());
|
||||
let device_type: &str = self.config.device_type.into();
|
||||
|
||||
let body = json!({
|
||||
"status": 101,
|
||||
"statusString": "ERROR-OK",
|
||||
"spotifyError": 0,
|
||||
"version": "2.7.1",
|
||||
"deviceID": (self.config.device_id),
|
||||
"remoteName": (self.config.name),
|
||||
"activeUser": "",
|
||||
"publicKey": (public_key),
|
||||
"deviceType": (device_type),
|
||||
"libraryVersion": "0.1.0",
|
||||
"accountReq": "PREMIUM",
|
||||
"brandDisplayName": "librespot",
|
||||
"modelDisplayName": "librespot",
|
||||
"resolverVersion": "0",
|
||||
"groupStatus": "NONE",
|
||||
"voiceSupport": "NO",
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Response::new(Body::from(body))
|
||||
}
|
||||
|
||||
fn handle_add_user(&self, params: &Params<'_>) -> Response<hyper::Body> {
|
||||
let username = params.get("userName").unwrap().as_ref();
|
||||
let encrypted_blob = params.get("blob").unwrap();
|
||||
let client_key = params.get("clientKey").unwrap();
|
||||
|
||||
let encrypted_blob = base64::decode(encrypted_blob.as_bytes()).unwrap();
|
||||
|
||||
let client_key = base64::decode(client_key.as_bytes()).unwrap();
|
||||
let shared_key = self.keys.shared_secret(&client_key);
|
||||
|
||||
let iv = &encrypted_blob[0..16];
|
||||
let encrypted = &encrypted_blob[16..encrypted_blob.len() - 20];
|
||||
let cksum = &encrypted_blob[encrypted_blob.len() - 20..encrypted_blob.len()];
|
||||
|
||||
let base_key = Sha1::digest(&shared_key);
|
||||
let base_key = &base_key[..16];
|
||||
|
||||
let checksum_key = {
|
||||
let mut h =
|
||||
Hmac::<Sha1>::new_from_slice(base_key).expect("HMAC can take key of any size");
|
||||
h.update(b"checksum");
|
||||
h.finalize().into_bytes()
|
||||
};
|
||||
|
||||
let encryption_key = {
|
||||
let mut h =
|
||||
Hmac::<Sha1>::new_from_slice(base_key).expect("HMAC can take key of any size");
|
||||
h.update(b"encryption");
|
||||
h.finalize().into_bytes()
|
||||
};
|
||||
|
||||
let mut h =
|
||||
Hmac::<Sha1>::new_from_slice(&checksum_key).expect("HMAC can take key of any size");
|
||||
h.update(encrypted);
|
||||
if h.verify(cksum).is_err() {
|
||||
warn!("Login error for user {:?}: MAC mismatch", username);
|
||||
let result = json!({
|
||||
"status": 102,
|
||||
"spotifyError": 1,
|
||||
"statusString": "ERROR-MAC"
|
||||
});
|
||||
|
||||
let body = result.to_string();
|
||||
return Response::new(Body::from(body));
|
||||
}
|
||||
|
||||
let decrypted = {
|
||||
let mut data = encrypted.to_vec();
|
||||
let mut cipher = Aes128Ctr::new(
|
||||
GenericArray::from_slice(&encryption_key[0..16]),
|
||||
GenericArray::from_slice(iv),
|
||||
);
|
||||
cipher.apply_keystream(&mut data);
|
||||
String::from_utf8(data).unwrap()
|
||||
};
|
||||
|
||||
let credentials =
|
||||
Credentials::with_blob(username.to_string(), &decrypted, &self.config.device_id);
|
||||
|
||||
self.tx.send(credentials).unwrap();
|
||||
|
||||
let result = json!({
|
||||
"status": 101,
|
||||
"spotifyError": 0,
|
||||
"statusString": "ERROR-OK"
|
||||
});
|
||||
|
||||
let body = result.to_string();
|
||||
Response::new(Body::from(body))
|
||||
}
|
||||
|
||||
fn not_found(&self) -> Response<hyper::Body> {
|
||||
let mut res = Response::default();
|
||||
*res.status_mut() = StatusCode::NOT_FOUND;
|
||||
res
|
||||
}
|
||||
|
||||
async fn handle(self: Arc<Self>, request: Request<Body>) -> hyper::Result<Response<Body>> {
|
||||
let mut params = Params::new();
|
||||
|
||||
let (parts, body) = request.into_parts();
|
||||
|
||||
if let Some(query) = parts.uri.query() {
|
||||
let query_params = form_urlencoded::parse(query.as_bytes());
|
||||
params.extend(query_params);
|
||||
}
|
||||
|
||||
if parts.method != Method::GET {
|
||||
debug!("{:?} {:?} {:?}", parts.method, parts.uri.path(), params);
|
||||
}
|
||||
|
||||
let body = hyper::body::to_bytes(body).await?;
|
||||
|
||||
params.extend(form_urlencoded::parse(&body));
|
||||
|
||||
let action = params.get("action").map(Cow::as_ref);
|
||||
|
||||
Ok(match (parts.method, action) {
|
||||
(Method::GET, Some("getInfo")) => self.handle_get_info(),
|
||||
(Method::POST, Some("addUser")) => self.handle_add_user(¶ms),
|
||||
_ => self.not_found(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiscoveryServer {
|
||||
cred_rx: mpsc::UnboundedReceiver<Credentials>,
|
||||
_close_tx: oneshot::Sender<Infallible>,
|
||||
}
|
||||
|
||||
impl DiscoveryServer {
|
||||
pub fn new(config: Config, port: &mut u16) -> hyper::Result<Self> {
|
||||
let (discovery, cred_rx) = RequestHandler::new(config);
|
||||
let discovery = Arc::new(discovery);
|
||||
|
||||
let (close_tx, close_rx) = oneshot::channel();
|
||||
|
||||
let address = SocketAddr::new(Ipv4Addr::UNSPECIFIED.into(), *port);
|
||||
|
||||
let make_service = make_service_fn(move |_| {
|
||||
let discovery = discovery.clone();
|
||||
async move {
|
||||
Ok::<_, hyper::Error>(service_fn(move |request| discovery.clone().handle(request)))
|
||||
}
|
||||
});
|
||||
|
||||
let server = hyper::Server::try_bind(&address)?.serve(make_service);
|
||||
|
||||
*port = server.local_addr().port();
|
||||
debug!("Zeroconf server listening on 0.0.0.0:{}", *port);
|
||||
|
||||
tokio::spawn(async {
|
||||
let result = server
|
||||
.with_graceful_shutdown(async {
|
||||
close_rx.await.unwrap_err();
|
||||
debug!("Shutting down discovery server");
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("Discovery server failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
cred_rx,
|
||||
_close_tx: close_tx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for DiscoveryServer {
|
||||
type Item = Credentials;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Credentials>> {
|
||||
self.cred_rx.poll_recv(cx)
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
pub use librespot_audio as audio;
|
||||
pub use librespot_connect as connect;
|
||||
pub use librespot_core as core;
|
||||
pub use librespot_discovery as discovery;
|
||||
pub use librespot_metadata as metadata;
|
||||
pub use librespot_playback as playback;
|
||||
pub use librespot_protocol as protocol;
|
||||
|
|
|
@ -645,11 +645,14 @@ async fn main() {
|
|||
let mut connecting: Pin<Box<dyn future::FusedFuture<Output = _>>> = Box::pin(future::pending());
|
||||
|
||||
if setup.enable_discovery {
|
||||
let config = setup.connect_config.clone();
|
||||
let device_id = setup.session_config.device_id.clone();
|
||||
|
||||
discovery = Some(
|
||||
librespot_connect::discovery::discovery(config, device_id, setup.zeroconf_port)
|
||||
librespot::discovery::Discovery::builder(device_id)
|
||||
.name(setup.connect_config.name.clone())
|
||||
.device_type(setup.connect_config.device_type)
|
||||
.port(setup.zeroconf_port)
|
||||
.launch()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue