From fb9a5c0099f76a26f3d89f92d0d8566710047541 Mon Sep 17 00:00:00 2001 From: Miguel Angel Nubla Date: Fri, 21 Nov 2025 13:52:52 +0100 Subject: [PATCH] Add TLS options with fingerprint support to Fully Kiosk provider (#2649) * feat(fully-kiosk): add TLS options with fingerprint support * fix(fully_kiosk): satisfy linters --------- Co-authored-by: Marcel van der Veldt --- music_assistant/constants.py | 3 + .../providers/fully_kiosk/__init__.py | 35 ++++++++- .../providers/fully_kiosk/provider.py | 78 ++++++++++++++++++- 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 7aed50f4..85f34f77 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -94,6 +94,9 @@ CONF_MUTE_CONTROL: Final[str] = "mute_control" CONF_OUTPUT_CODEC: Final[str] = "output_codec" CONF_ALLOW_AUDIO_CACHE: Final[str] = "allow_audio_cache" CONF_SMART_FADES_MODE: Final[str] = "smart_fades_mode" +CONF_USE_SSL: Final[str] = "use_ssl" +CONF_VERIFY_SSL: Final[str] = "verify_ssl" +CONF_SSL_FINGERPRINT: Final[str] = "ssl_fingerprint" # config default values diff --git a/music_assistant/providers/fully_kiosk/__init__.py b/music_assistant/providers/fully_kiosk/__init__.py index afad31d1..d45da7e0 100644 --- a/music_assistant/providers/fully_kiosk/__init__.py +++ b/music_assistant/providers/fully_kiosk/__init__.py @@ -7,7 +7,14 @@ from typing import TYPE_CHECKING from music_assistant_models.config_entries import ConfigEntry, ConfigValueType from music_assistant_models.enums import ConfigEntryType, ProviderFeature -from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT +from music_assistant.constants import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL_FINGERPRINT, + CONF_USE_SSL, + CONF_VERIFY_SSL, +) from .provider import FullyKioskProvider @@ -65,4 +72,30 @@ async def get_config_entries( required=True, category="advanced", ), + ConfigEntry( + key=CONF_USE_SSL, + type=ConfigEntryType.BOOLEAN, + label="Use HTTPS when connecting to the Fully Kiosk API.", + default_value=False, + category="advanced", + ), + ConfigEntry( + key=CONF_VERIFY_SSL, + type=ConfigEntryType.BOOLEAN, + label="Verify HTTPS certificates (recommended).", + default_value=True, + description="Disabling verification trusts any certificate (no validation).", + category="advanced", + ), + ConfigEntry( + key=CONF_SSL_FINGERPRINT, + type=ConfigEntryType.STRING, + label="TLS certificate fingerprint", + description=( + "Optional SHA-256 hex fingerprint. When provided it must " + "match the device certificate and overrides the verify setting." + ), + required=False, + category="advanced", + ), ) diff --git a/music_assistant/providers/fully_kiosk/provider.py b/music_assistant/providers/fully_kiosk/provider.py index 881823f8..7e3c4261 100644 --- a/music_assistant/providers/fully_kiosk/provider.py +++ b/music_assistant/providers/fully_kiosk/provider.py @@ -4,16 +4,58 @@ from __future__ import annotations import asyncio import logging +import re +from dataclasses import dataclass +from typing import Any +from aiohttp import ClientSession, Fingerprint from fullykiosk import FullyKiosk from music_assistant_models.errors import SetupFailedError -from music_assistant.constants import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, VERBOSE_LOG_LEVEL +from music_assistant.constants import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL_FINGERPRINT, + CONF_USE_SSL, + CONF_VERIFY_SSL, + VERBOSE_LOG_LEVEL, +) from music_assistant.models.player_provider import PlayerProvider from .player import FullyKioskPlayer +@dataclass +class _FingerprintSessionWrapper: + """Proxy ClientSession that enforces a TLS fingerprint.""" + + session: ClientSession + fingerprint: Fingerprint + + def get(self, *args: Any, **kwargs: Any) -> Any: + """Call the wrapped session.get while injecting the fingerprint.""" + kwargs.setdefault("ssl", self.fingerprint) + return self.session.get(*args, **kwargs) + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to the wrapped session.""" + return getattr(self.session, name) + + +def _build_fingerprint(value: str) -> Fingerprint: + """Parse a fingerprint string (sha256 hex) into an aiohttp Fingerprint.""" + normalized = re.sub(r"[^0-9a-fA-F]", "", value).lower() + if not normalized: + msg = "Empty fingerprint provided." + raise ValueError(msg) + if len(normalized) % 2 != 0: + msg = "Fingerprint must contain an even number of hex characters." + raise ValueError(msg) + digest = bytes.fromhex(normalized) + return Fingerprint(digest) + + class FullyKioskProvider(PlayerProvider): """Player provider for FullyKiosk based players.""" @@ -24,11 +66,39 @@ class FullyKioskProvider(PlayerProvider): logging.getLogger("fullykiosk").setLevel(logging.DEBUG) else: logging.getLogger("fullykiosk").setLevel(self.logger.level + 10) + + use_ssl = bool(self.config.get_value(CONF_USE_SSL)) + fingerprint_value = self.config.get_value(CONF_SSL_FINGERPRINT) + fingerprint_raw = fingerprint_value.strip() if isinstance(fingerprint_value, str) else "" + if fingerprint_raw and not use_ssl: + msg = "Fingerprint validation requires HTTPS to be enabled." + raise SetupFailedError(msg) + + verify_ssl = bool(self.config.get_value(CONF_VERIFY_SSL)) if use_ssl else False + http_session: ClientSession | _FingerprintSessionWrapper + if use_ssl: + if fingerprint_raw: + try: + fingerprint = _build_fingerprint(fingerprint_raw) + except ValueError as err: + msg = f"Invalid TLS fingerprint configured: {err}" + raise SetupFailedError(msg) from err + http_session = _FingerprintSessionWrapper(self.mass.http_session, fingerprint) + verify_ssl = True + else: + http_session = ( + self.mass.http_session if verify_ssl else self.mass.http_session_no_ssl + ) + else: + http_session = self.mass.http_session_no_ssl + fully_kiosk = FullyKiosk( - self.mass.http_session_no_ssl, + http_session, self.config.get_value(CONF_IP_ADDRESS), self.config.get_value(CONF_PORT), self.config.get_value(CONF_PASSWORD), + use_ssl=use_ssl, + verify_ssl=verify_ssl, ) try: async with asyncio.timeout(15): @@ -37,8 +107,10 @@ class FullyKioskProvider(PlayerProvider): msg = f"Unable to start the FullyKiosk connection ({err!s}" raise SetupFailedError(msg) from err player_id = fully_kiosk.deviceInfo["deviceID"] + scheme = "https" if use_ssl else "http" address = ( - f"http://{self.config.get_value(CONF_IP_ADDRESS)}:{self.config.get_value(CONF_PORT)}" + f"{scheme}://{self.config.get_value(CONF_IP_ADDRESS)}:" + f"{self.config.get_value(CONF_PORT)}" ) player = FullyKioskPlayer(self, player_id, fully_kiosk, address) player.set_attributes() -- 2.34.1