345 lines
14 KiB
Python
345 lines
14 KiB
Python
from __future__ import annotations
|
|
from Cryptodome.Cipher import AES
|
|
from Cryptodome.Hash import HMAC, SHA1
|
|
from Cryptodome.Util import Counter
|
|
from librespot import util, Version
|
|
from librespot.core import Session
|
|
from librespot.crypto import DiffieHellman
|
|
from librespot.proto import Connect_pb2 as Connect
|
|
from librespot.structure import Closeable, Runnable, SessionListener
|
|
import base64
|
|
import concurrent.futures
|
|
import copy
|
|
import io
|
|
import json
|
|
import logging
|
|
import random
|
|
import socket
|
|
import threading
|
|
import typing
|
|
import urllib.parse
|
|
import zeroconf
|
|
|
|
|
|
class ZeroconfServer(Closeable):
|
|
logger = logging.getLogger("Librespot:ZeroconfServer")
|
|
service = "_spotify-connect._tcp.local."
|
|
__connecting_username: typing.Union[str, None] = None
|
|
__connection_lock = threading.Condition()
|
|
__default_get_info_fields = {
|
|
"status": 101,
|
|
"statusString": "OK",
|
|
"spotifyError": 0,
|
|
"version": "2.7.1",
|
|
"libraryVersion": Version.version_name,
|
|
"accountReq": "PREMIUM",
|
|
"brandDisplayName": "kokarare1212",
|
|
"modelDisplayName": "librespot-python",
|
|
"voiceSupport": "NO",
|
|
"availability": "",
|
|
"productID": 0,
|
|
"tokenType": "default",
|
|
"groupStatus": "NONE",
|
|
"resolverVersion": "0",
|
|
"scope": "streaming,client-authorization-universal",
|
|
}
|
|
__default_successful_add_user = {
|
|
"status": 101,
|
|
"spotifyError": 0,
|
|
"statusString": "OK",
|
|
}
|
|
__eol = b"\r\n"
|
|
__max_port = 65536
|
|
__min_port = 1024
|
|
__runner: HttpRunner
|
|
__service_info: zeroconf.ServiceInfo
|
|
__session: typing.Union[Session, None] = None
|
|
__session_listeners: typing.List[SessionListener] = []
|
|
__zeroconf: zeroconf.Zeroconf
|
|
|
|
def __init__(self, inner: Inner, listen_port):
|
|
self.__inner = inner
|
|
self.__keys = DiffieHellman()
|
|
if listen_port == -1:
|
|
listen_port = random.randint(self.__min_port + 1, self.__max_port)
|
|
self.__runner = ZeroconfServer.HttpRunner(self, listen_port)
|
|
threading.Thread(target=self.__runner.run,
|
|
name="zeroconf-http-server").start()
|
|
self.__zeroconf = zeroconf.Zeroconf()
|
|
self.__service_info = zeroconf.ServiceInfo(
|
|
ZeroconfServer.service,
|
|
inner.device_name + "." + ZeroconfServer.service,
|
|
listen_port,
|
|
0,
|
|
0, {
|
|
"CPath": "/",
|
|
"VERSION": "1.0",
|
|
"STACK": "SP",
|
|
},
|
|
self.get_useful_hostname() + ".",
|
|
addresses=[
|
|
socket.inet_aton(
|
|
socket.gethostbyname(self.get_useful_hostname()))
|
|
])
|
|
self.__zeroconf.register_service(self.__service_info)
|
|
threading.Thread(target=self.__zeroconf.start,
|
|
name="zeroconf-multicast-dns-server").start()
|
|
|
|
def add_session_listener(self, listener: ZeroconfServer):
|
|
self.__session_listeners.append(listener)
|
|
|
|
def close(self) -> None:
|
|
self.__zeroconf.close()
|
|
self.__runner.close()
|
|
|
|
def close_session(self) -> None:
|
|
if self.__session is None:
|
|
return
|
|
for session_listener in self.__session_listeners:
|
|
session_listener.session_closing(self.__session)
|
|
self.__session.close()
|
|
self.__session = None
|
|
|
|
def get_useful_hostname(self) -> str:
|
|
host = socket.gethostname()
|
|
if host == "localhost":
|
|
pass
|
|
else:
|
|
return host
|
|
|
|
def handle_add_user(self, __socket: socket.socket, params: dict[str, str],
|
|
http_version: str) -> None:
|
|
username = params.get("userName")
|
|
if not username:
|
|
self.logger.error("Missing userName!")
|
|
return
|
|
blob_str = params.get("blob")
|
|
if not blob_str:
|
|
self.logger.error("Missing blob!")
|
|
return
|
|
client_key_str = params.get("clientKey")
|
|
if not client_key_str:
|
|
self.logger.error("Missing clientKey!")
|
|
with self.__connection_lock:
|
|
if username == self.__connecting_username:
|
|
self.logger.info(
|
|
"{} is already trying to connect.".format(username))
|
|
__socket.send(http_version.encode())
|
|
__socket.send(b" 403 Forbidden")
|
|
__socket.send(self.__eol)
|
|
__socket.send(self.__eol)
|
|
return
|
|
shared_key = util.int_to_bytes(
|
|
self.__keys.compute_shared_key(
|
|
base64.b64decode(client_key_str.encode())))
|
|
blob_bytes = base64.b64decode(blob_str)
|
|
iv = blob_bytes[:16]
|
|
encrypted = blob_bytes[16:len(blob_bytes) - 20]
|
|
checksum = blob_bytes[len(blob_bytes) - 20:]
|
|
sha1 = SHA1.new()
|
|
sha1.update(shared_key)
|
|
base_key = sha1.digest()[:16]
|
|
hmac = HMAC.new(base_key, digestmod=SHA1)
|
|
hmac.update(b"checksum")
|
|
checksum_key = hmac.digest()
|
|
hmac = HMAC.new(base_key, digestmod=SHA1)
|
|
hmac.update(b"encryption")
|
|
encryption_key = hmac.digest()
|
|
hmac = HMAC.new(checksum_key, digestmod=SHA1)
|
|
hmac.update(encrypted)
|
|
mac = hmac.digest()
|
|
if mac != checksum:
|
|
self.logger.error("Mac and checksum don't match!")
|
|
__socket.send(http_version.encode())
|
|
__socket.send(b" 400 Bad Request")
|
|
__socket.send(self.__eol)
|
|
__socket.send(self.__eol)
|
|
return
|
|
aes = AES.new(encryption_key[:16],
|
|
AES.MODE_CTR,
|
|
counter=Counter.new(128,
|
|
initial_value=int.from_bytes(
|
|
iv, "big")))
|
|
decrypted = aes.decrypt(encrypted)
|
|
self.close_session()
|
|
with self.__connection_lock:
|
|
self.__connecting_username = username
|
|
self.logger.info("Accepted new user from {}. [deviceId: {}]".format(
|
|
params.get("deviceName"), self.__inner.device_id))
|
|
response = json.dumps(self.__default_successful_add_user)
|
|
__socket.send(http_version.encode())
|
|
__socket.send(b" 200 OK")
|
|
__socket.send(self.__eol)
|
|
__socket.send(b"Content-Length: ")
|
|
__socket.send(str(len(response)).encode())
|
|
__socket.send(self.__eol)
|
|
__socket.send(self.__eol)
|
|
__socket.send(response.encode())
|
|
self.__session = Session.Builder(self.__inner.conf) \
|
|
.set_device_id(self.__inner.device_id) \
|
|
.set_device_name(self.__inner.device_name) \
|
|
.set_device_type(self.__inner.device_type) \
|
|
.set_preferred_locale(self.__inner.preferred_locale) \
|
|
.blob(username, decrypted) \
|
|
.create()
|
|
with self.__connection_lock:
|
|
self.__connecting_username = None
|
|
for session_listener in self.__session_listeners:
|
|
session_listener.session_changed(self.__session)
|
|
|
|
def handle_get_info(self, __socket: socket.socket,
|
|
http_version: str) -> None:
|
|
info = copy.deepcopy(self.__default_get_info_fields)
|
|
info["deviceID"] = self.__inner.device_id
|
|
info["remoteName"] = self.__inner.device_name
|
|
info["publicKey"] = base64.b64encode(
|
|
self.__keys.public_key_bytes()).decode()
|
|
info["deviceType"] = Connect.DeviceType.Name(self.__inner.device_type)
|
|
with self.__connection_lock:
|
|
info[
|
|
"activeUser"] = self.__connecting_username if self.__connecting_username is not None else self.__session.username(
|
|
) if self.has_valid_session() else ""
|
|
__socket.send(http_version.encode())
|
|
__socket.send(b" 200 OK")
|
|
__socket.send(self.__eol)
|
|
__socket.send(b"Content-Type: application/json")
|
|
__socket.send(self.__eol)
|
|
__socket.send(self.__eol)
|
|
__socket.send(json.dumps(info).encode())
|
|
|
|
def has_valid_session(self) -> bool:
|
|
valid = self.__session and self.__session.is_valid()
|
|
if not valid:
|
|
self.__session = None
|
|
return valid
|
|
|
|
def parse_path(self, path: str) -> dict[str, str]:
|
|
url = "https://host" + path
|
|
parsed = {}
|
|
params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
for key, values in params.items():
|
|
for value in values:
|
|
parsed[key] = value
|
|
return parsed
|
|
|
|
def remove_session_listener(self, listener: SessionListener):
|
|
self.__session_listeners.remove(listener)
|
|
|
|
class Builder(Session.Builder):
|
|
listen_port: int = -1
|
|
|
|
def set_listen_port(self, listen_port: int):
|
|
self.listen_port = listen_port
|
|
return self
|
|
|
|
def create(self) -> ZeroconfServer:
|
|
return ZeroconfServer(
|
|
ZeroconfServer.Inner(self.device_type, self.device_name,
|
|
self.device_id, self.preferred_locale,
|
|
self.conf), self.listen_port)
|
|
|
|
class HttpRunner(Closeable, Runnable):
|
|
__should_stop = False
|
|
__socket: socket.socket
|
|
__worker = concurrent.futures.ThreadPoolExecutor()
|
|
__zeroconf_server: ZeroconfServer
|
|
|
|
def __init__(self, zeroconf_server: ZeroconfServer, port: int):
|
|
self.__socket = socket.socket()
|
|
self.__socket.bind((".".join(["0"] * 4), port))
|
|
self.__socket.listen(5)
|
|
self.__zeroconf_server = zeroconf_server
|
|
self.__zeroconf_server.logger.info(
|
|
"Zeroconf HTTP server started successfully on port {}!".format(
|
|
port))
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
def run(self):
|
|
while not self.__should_stop:
|
|
__socket, address = self.__socket.accept()
|
|
|
|
def anonymous():
|
|
self.__handle(__socket)
|
|
__socket.close()
|
|
|
|
self.__worker.submit(anonymous)
|
|
|
|
def __handle(self, __socket: socket.socket) -> None:
|
|
request = io.BytesIO(__socket.recv(1024 * 1024))
|
|
request_line = request.readline().strip().split(b" ")
|
|
if len(request_line) != 3:
|
|
self.__zeroconf_server.logger.warning(
|
|
"Unexpected request line: {}".format(request_line))
|
|
method = request_line[0].decode()
|
|
path = request_line[1].decode()
|
|
http_version = request_line[2].decode()
|
|
headers = {}
|
|
while True:
|
|
header = request.readline().strip()
|
|
if not header:
|
|
break
|
|
split = header.split(b":")
|
|
headers[split[0].decode()] = split[1].strip().decode()
|
|
if not self.__zeroconf_server.has_valid_session():
|
|
self.__zeroconf_server.logger.debug(
|
|
"Handling request: {}, {}, {}, headers: {}".format(
|
|
method, path, http_version, headers))
|
|
params = {}
|
|
if method == "POST":
|
|
content_type = headers.get("Content-Type")
|
|
if content_type != "application/x-www-form-urlencoded":
|
|
self.__zeroconf_server.logger.error(
|
|
"Bad Content-Type: {}".format(content_type))
|
|
return
|
|
content_length_str = headers.get("Content-Length")
|
|
if content_length_str is None:
|
|
self.__zeroconf_server.logger.error(
|
|
"Missing Content-Length header!")
|
|
return
|
|
content_length = int(content_length_str)
|
|
body = request.read(content_length).decode()
|
|
pairs = body.split("&")
|
|
for pair in pairs:
|
|
split = pair.split("=")
|
|
params[urllib.parse.unquote(
|
|
split[0])] = urllib.parse.unquote(split[1])
|
|
else:
|
|
params = self.__zeroconf_server.parse_path(path)
|
|
action = params.get("action")
|
|
if action is None:
|
|
self.__zeroconf_server.logger.debug(
|
|
"Request is missing action.")
|
|
return
|
|
self.handle_request(__socket, http_version, action, params)
|
|
|
|
def handle_request(self, __socket: socket.socket, http_version: str,
|
|
action: str, params: dict[str, str]) -> None:
|
|
if action == "addUser":
|
|
if params is None:
|
|
raise RuntimeError
|
|
self.__zeroconf_server.handle_add_user(__socket, params,
|
|
http_version)
|
|
elif action == "getInfo":
|
|
self.__zeroconf_server.handle_get_info(__socket, http_version)
|
|
else:
|
|
self.__zeroconf_server.logger.warning(
|
|
"Unknown action: {}".format(action))
|
|
|
|
class Inner:
|
|
conf: typing.Final[Session.Configuration]
|
|
device_name: typing.Final[str]
|
|
device_id: typing.Final[str]
|
|
device_type: typing.Final[Connect.DeviceType]
|
|
preferred_locale: typing.Final[str]
|
|
|
|
def __init__(self, device_type: Connect.DeviceType, device_name: str,
|
|
device_id: str, preferred_locale: str,
|
|
conf: Session.Configuration):
|
|
self.conf = conf
|
|
self.device_name = device_name
|
|
self.device_id = util.random_hex_string(
|
|
40).lower() if not device_id else device_id
|
|
self.device_type = device_type
|
|
self.preferred_locale = preferred_locale
|