Add support for SSL
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 28 Nov 2025 22:21:36 +0000 (23:21 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 28 Nov 2025 22:21:36 +0000 (23:21 +0100)
music_assistant/controllers/webserver/controller.py
music_assistant/helpers/webserver.py

index b595dd0c991cc40145f8509cc5d8fb36d082270a..e8f09b695450e1a931765f4e1b5023907e95d227 100644 (file)
@@ -12,10 +12,13 @@ import hashlib
 import html
 import json
 import os
+import ssl
+import tempfile
 import urllib.parse
 from collections.abc import Awaitable, Callable
 from concurrent import futures
 from functools import partial
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, Final, cast
 from urllib.parse import quote
 
@@ -63,6 +66,9 @@ if TYPE_CHECKING:
 DEFAULT_SERVER_PORT = 8095
 INGRESS_SERVER_PORT = 8094
 CONF_BASE_URL = "base_url"
+CONF_ENABLE_SSL = "enable_ssl"
+CONF_SSL_CERTIFICATE = "ssl_certificate"
+CONF_SSL_PRIVATE_KEY = "ssl_private_key"
 MAX_PENDING_MSG = 512
 CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
 
@@ -100,68 +106,112 @@ class WebserverController(CoreController):
         """Return all Config Entries for this core module (if any)."""
         ip_addresses = await get_ip_addresses()
         default_publish_ip = ip_addresses[0]
-        default_base_url = f"http://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
-        return (
-            ConfigEntry(
-                key="webserver_warn",
-                type=ConfigEntryType.ALERT,
-                label="Please note that the webserver is unencrypted. "
-                "Never ever expose the webserver directly to the internet! \n\n"
-                "Use a reverse proxy or VPN to secure access.",
-                required=False,
-            ),
-            ConfigEntry(
-                key=CONF_BASE_URL,
-                type=ConfigEntryType.STRING,
-                default_value=default_base_url,
-                label="Base URL",
-                description="The (base) URL to reach this webserver in the network. \n"
-                "Override this in advanced scenarios where for example you're running "
-                "the webserver behind a reverse proxy.",
-            ),
-            ConfigEntry(
-                key=CONF_BIND_PORT,
-                type=ConfigEntryType.INTEGER,
-                default_value=DEFAULT_SERVER_PORT,
-                label="TCP Port",
-                description="The TCP port to run the webserver.",
-            ),
-            ConfigEntry(
-                key=CONF_BIND_IP,
-                type=ConfigEntryType.STRING,
-                default_value="0.0.0.0",
-                options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
-                label="Bind to IP/interface",
-                description="Bind the (web)server to this specific interface. \n"
-                "Use 0.0.0.0 to bind to all interfaces. \n"
-                "Set this address for example to a docker-internal network, "
-                "when you are running a reverse proxy to enhance security and "
-                "protect outside access to the webinterface and API. \n\n"
-                "This is an advanced setting that should normally "
-                "not be adjusted in regular setups.",
-                category="advanced",
-            ),
-            ConfigEntry(
-                key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
-                type=ConfigEntryType.BOOLEAN,
-                default_value=True,
-                label="Allow Self-Registration",
-                description="Allow users to create accounts via Home Assistant OAuth. \n"
-                "New users will have USER role by default.",
-                category="advanced",
-                hidden=not any(provider.domain == "hass" for provider in self.mass.providers),
-            ),
-            ConfigEntry(
-                key="remote_id",
-                type=ConfigEntryType.STRING,
-                label="Remote ID",
-                description="Unique identifier for WebRTC remote access. "
-                "Generated automatically and should not be changed.",
-                required=False,
-                hidden=True,
-            ),
+
+        # Determine if SSL is enabled from values
+        ssl_enabled = values.get(CONF_ENABLE_SSL, False) if values else False
+        protocol = "https" if ssl_enabled else "http"
+        default_base_url = f"{protocol}://{default_publish_ip}:{DEFAULT_SERVER_PORT}"
+
+        # Show warning only if SSL is not enabled
+        entries = []
+        if not ssl_enabled:
+            entries.append(
+                ConfigEntry(
+                    key="webserver_warn",
+                    type=ConfigEntryType.ALERT,
+                    label="Please note that the webserver is unencrypted. "
+                    "Never ever expose the webserver directly to the internet! \n\n"
+                    "Use a reverse proxy or VPN to secure access, or enable SSL below.",
+                    required=False,
+                )
+            )
+
+        entries.extend(
+            [
+                ConfigEntry(
+                    key=CONF_BASE_URL,
+                    type=ConfigEntryType.STRING,
+                    default_value=default_base_url,
+                    label="Base URL",
+                    description="The (base) URL to reach this webserver in the network. \n"
+                    "Override this in advanced scenarios where for example you're running "
+                    "the webserver behind a reverse proxy.",
+                ),
+                ConfigEntry(
+                    key=CONF_BIND_PORT,
+                    type=ConfigEntryType.INTEGER,
+                    default_value=DEFAULT_SERVER_PORT,
+                    label="TCP Port",
+                    description="The TCP port to run the webserver.",
+                ),
+                ConfigEntry(
+                    key=CONF_BIND_IP,
+                    type=ConfigEntryType.STRING,
+                    default_value="0.0.0.0",
+                    options=[ConfigValueOption(x, x) for x in {"0.0.0.0", *ip_addresses}],
+                    label="Bind to IP/interface",
+                    description="Bind the (web)server to this specific interface. \n"
+                    "Use 0.0.0.0 to bind to all interfaces. \n"
+                    "Set this address for example to a docker-internal network, "
+                    "when you are running a reverse proxy to enhance security and "
+                    "protect outside access to the webinterface and API. \n\n"
+                    "This is an advanced setting that should normally "
+                    "not be adjusted in regular setups.",
+                    category="advanced",
+                ),
+                ConfigEntry(
+                    key=CONF_ENABLE_SSL,
+                    type=ConfigEntryType.BOOLEAN,
+                    default_value=False,
+                    label="Enable SSL/TLS",
+                    description="Enable HTTPS by providing an SSL certificate and private key. \n"
+                    "This encrypts all communication with the webserver.",
+                    category="advanced",
+                ),
+                ConfigEntry(
+                    key=CONF_SSL_CERTIFICATE,
+                    type=ConfigEntryType.STRING,
+                    label="SSL Certificate",
+                    description="Paste the contents of your SSL certificate file (PEM format). \n"
+                    "This should include the full certificate chain if applicable.",
+                    category="advanced",
+                    required=False,
+                    hidden=not ssl_enabled,
+                ),
+                ConfigEntry(
+                    key=CONF_SSL_PRIVATE_KEY,
+                    type=ConfigEntryType.SECURE_STRING,
+                    label="SSL Private Key",
+                    description="Paste the contents of your SSL private key file (PEM format). \n"
+                    "This is securely encrypted and stored.",
+                    category="advanced",
+                    required=False,
+                    hidden=not ssl_enabled,
+                ),
+                ConfigEntry(
+                    key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
+                    type=ConfigEntryType.BOOLEAN,
+                    default_value=True,
+                    label="Allow Self-Registration",
+                    description="Allow users to create accounts via Home Assistant OAuth. \n"
+                    "New users will have USER role by default.",
+                    category="advanced",
+                    hidden=not any(provider.domain == "hass" for provider in self.mass.providers),
+                ),
+                ConfigEntry(
+                    key="remote_id",
+                    type=ConfigEntryType.STRING,
+                    label="Remote ID",
+                    description="Unique identifier for WebRTC remote access. "
+                    "Generated automatically and should not be changed.",
+                    required=False,
+                    hidden=True,
+                ),
+            ]
         )
 
+        return tuple(entries)
+
     async def setup(self, config: CoreConfig) -> None:  # noqa: PLR0915
         """Async initialize of module."""
         self.config = config
@@ -266,6 +316,58 @@ class WebserverController(CoreController):
                 self.publish_port,
                 base_url,
             )
+
+        # Create SSL context if SSL is enabled
+        ssl_context = None
+        ssl_enabled = config.get_value(CONF_ENABLE_SSL, False)
+        if ssl_enabled:
+            ssl_certificate = config.get_value(CONF_SSL_CERTIFICATE)
+            ssl_private_key = config.get_value(CONF_SSL_PRIVATE_KEY)
+
+            if not ssl_certificate or not ssl_private_key:
+                self.logger.error(
+                    "SSL is enabled but certificate or private key is missing. "
+                    "Webserver will start without SSL."
+                )
+            else:
+                try:
+                    # Create SSL context
+                    ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+
+                    # Write certificate and key to temporary files
+                    # This is necessary because ssl.SSLContext.load_cert_chain requires file paths
+                    with tempfile.NamedTemporaryFile(
+                        mode="w", suffix=".pem", delete=False
+                    ) as cert_file:
+                        cert_file.write(str(ssl_certificate))
+                        cert_path = cert_file.name
+
+                    with tempfile.NamedTemporaryFile(
+                        mode="w", suffix=".pem", delete=False
+                    ) as key_file:
+                        key_file.write(str(ssl_private_key))
+                        key_path = key_file.name
+
+                    try:
+                        # Load certificate and private key
+                        ssl_context.load_cert_chain(cert_path, key_path)
+                        self.logger.info("SSL/TLS enabled for webserver")
+                    finally:
+                        # Clean up temporary files
+                        try:
+                            Path(cert_path).unlink()
+                            Path(key_path).unlink()
+                        except Exception as cleanup_err:
+                            self.logger.debug(
+                                "Failed to cleanup temporary SSL files: %s", cleanup_err
+                            )
+
+                except Exception as e:
+                    self.logger.exception(
+                        "Failed to create SSL context: %s. Webserver will start without SSL.", e
+                    )
+                    ssl_context = None
+
         await self._server.setup(
             bind_ip=bind_ip,
             bind_port=self.publish_port,
@@ -276,6 +378,7 @@ class WebserverController(CoreController):
             ingress_tcp_site_params=ingress_tcp_site_params,
             # Add mass object to app for use in auth middleware
             app_state={"mass": self.mass},
+            ssl_context=ssl_context,
         )
         if self.mass.running_as_hass_addon:
             # announce to HA supervisor
index 6c8511ddc9e94706a1507c0decbbb91ce024f298..39bf87f52c0d4f8357d35899e6d22ac637a1397f 100644 (file)
@@ -52,6 +52,7 @@ class Webserver:
         static_content: tuple[str, str, str] | None = None,
         ingress_tcp_site_params: tuple[str, int] | None = None,
         app_state: dict[str, Any] | None = None,
+        ssl_context: Any | None = None,
     ) -> None:
         """Async initialize of module.
 
@@ -62,6 +63,7 @@ class Webserver:
         :param static_content: Tuple of (path, directory, name) for static content.
         :param ingress_tcp_site_params: Tuple of (host, port) for ingress TCP site.
         :param app_state: Optional dict of key-value pairs to set on app before starting.
+        :param ssl_context: Optional SSL context for HTTPS support.
         """
         self._base_url = base_url.removesuffix("/")
         self._bind_port = bind_port
@@ -94,7 +96,9 @@ class Webserver:
         # set host to None to bind to all addresses on both IPv4 and IPv6
         host = None if bind_ip == "0.0.0.0" else bind_ip
         try:
-            self._tcp_site = web.TCPSite(self._apprunner, host=host, port=bind_port)
+            self._tcp_site = web.TCPSite(
+                self._apprunner, host=host, port=bind_port, ssl_context=ssl_context
+            )
             await self._tcp_site.start()
         except OSError:
             if host is None:
@@ -103,7 +107,9 @@ class Webserver:
             self.logger.error(
                 "Could not bind to %s, will start on all interfaces as fallback!", host
             )
-            self._tcp_site = web.TCPSite(self._apprunner, host=None, port=bind_port)
+            self._tcp_site = web.TCPSite(
+                self._apprunner, host=None, port=bind_port, ssl_context=ssl_context
+            )
             await self._tcp_site.start()
         # start additional ingress TCP site if configured
         # this is only used if we're running in the context of an HA add-on