Add TLS options with fingerprint support to Fully Kiosk provider (#2649)
authorMiguel Angel Nubla <miguelangel.nubla@gmail.com>
Fri, 21 Nov 2025 12:52:52 +0000 (13:52 +0100)
committerGitHub <noreply@github.com>
Fri, 21 Nov 2025 12:52:52 +0000 (13:52 +0100)
* feat(fully-kiosk): add TLS options with fingerprint support

* fix(fully_kiosk): satisfy linters

---------

Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
music_assistant/constants.py
music_assistant/providers/fully_kiosk/__init__.py
music_assistant/providers/fully_kiosk/provider.py

index 7aed50f41d9ce45408013fc8ece73b671bc4452a..85f34f77c410e81386bb76d88392216c92cf3aed 100644 (file)
@@ -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
index afad31d17c725fb4090adaa036d8cd5ca27938f8..d45da7e03d9034223107eebbf90e52647f4e342b 100644 (file)
@@ -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",
+        ),
     )
index 881823f8c427cd38de3e8a11dafb426bddced47d..7e3c4261648ef60c72606a7bbac948f6e3834d88 100644 (file)
@@ -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()