/
/
/
1"""
2Controller that manages the builtin webserver that hosts the api and frontend.
3
4Unlike the streamserver (which is as simple and unprotected as possible),
5this webserver allows for more fine grained configuration to better secure it.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import hashlib
12import html
13import inspect
14import os
15import urllib.parse
16from collections.abc import Awaitable, Callable
17from concurrent import futures
18from contextlib import aclosing
19from functools import partial
20from typing import TYPE_CHECKING, Any, Final, cast
21
22import aiofiles
23from aiohttp import web
24from mashumaro.exceptions import MissingField
25from music_assistant_frontend import where as locate_frontend
26from music_assistant_models.api import CommandMessage
27from music_assistant_models.auth import UserRole
28from music_assistant_models.config_entries import (
29 ConfigActionResult,
30 ConfigEntry,
31 ConfigValueOption,
32)
33from music_assistant_models.enums import ConfigEntryType
34from music_assistant_models.errors import (
35 InsufficientPermissions,
36 InvalidDataError,
37 UserNotFoundError,
38)
39from music_assistant_models.media_items.metadata import IMAGE_PROXY_ID_RESOLVER
40from music_assistant_models.translations import TRANSLATION_RESOLVER
41
42from music_assistant.constants import (
43 CONF_AUTH_ALLOW_SELF_REGISTRATION,
44 CONF_BIND_IP,
45 CONF_BIND_PORT,
46 CONF_VALUE_AUTO,
47 DEFAULT_HOST,
48 INGRESS_SERVER_PORT,
49 RESOURCES_DIR,
50 SENDSPIN_SERVER_PORT,
51 VERBOSE_LOG_LEVEL,
52 WILDCARD_BIND_IPS,
53)
54from music_assistant.controllers.webserver.helpers.ssl import (
55 create_server_ssl_context,
56 format_certificate_info,
57 verify_ssl_certificate,
58)
59from music_assistant.helpers.api import parse_arguments
60from music_assistant.helpers.json import json_dumps, json_loads
61from music_assistant.helpers.redirect_validation import (
62 build_code_redirect_url,
63 is_allowed_redirect_url,
64)
65from music_assistant.helpers.util import (
66 format_ip_for_url,
67 get_ip_addresses,
68 get_publish_ip_candidates,
69)
70from music_assistant.helpers.webserver import Webserver
71from music_assistant.models.core_controller import CoreController
72
73from .api_docs import generate_commands_json, generate_openapi_spec, generate_schemas_json
74from .auth import AuthenticationManager
75from .helpers.auth_middleware import (
76 get_authenticated_user,
77 has_scope,
78 is_request_from_ingress,
79 resolve_command_impersonation,
80 set_current_peer_address,
81 set_current_token,
82 set_current_user,
83 set_impersonated_user,
84)
85from .helpers.auth_providers import BuiltinLoginProvider, get_ha_user_role
86from .remote_access import RemoteAccessManager
87from .sendspin_proxy import SendspinProxyHandler
88from .websocket_client import WebsocketClientHandler
89
90if TYPE_CHECKING:
91 from music_assistant_models.config_entries import CoreConfig
92
93 from music_assistant import MusicAssistant
94 from music_assistant.helpers.api import APICommandHandler
95
96DEFAULT_SERVER_PORT = 8095
97CONF_BASE_URL = "base_url"
98CONF_ENABLE_SSL = "enable_ssl"
99CONF_SSL_CERTIFICATE = "ssl_certificate"
100CONF_SSL_PRIVATE_KEY = "ssl_private_key"
101CONF_ACTION_VERIFY_SSL = "verify_ssl"
102MAX_PENDING_MSG = 512
103CANCELLATION_ERRORS: Final = (asyncio.CancelledError, futures.CancelledError)
104
105
106def _get_publish_addresses(
107 bind_ip: str | None, publish_ip: str, publish_candidates: tuple[str, ...]
108) -> list[str]:
109 """
110 Return the IP addresses the webserver should publish/advertise.
111
112 :param bind_ip: The configured bind IP (None or a wildcard means all interfaces).
113 :param publish_ip: The resolved primary publish IP.
114 :param publish_candidates: Host addresses reachable from the local network, ranked.
115 """
116 addresses = [publish_ip]
117 if bind_ip and bind_ip not in WILDCARD_BIND_IPS:
118 return addresses
119 # bound to all interfaces: also publish the primary address of the other
120 # IP family (if any) so both IPv4-only and IPv6-only clients can connect
121 publish_is_ipv6 = ":" in publish_ip
122 for ip in publish_candidates:
123 if (":" in ip) != publish_is_ipv6:
124 addresses.append(ip)
125 break
126 return addresses
127
128
129def _get_internal_connect_ip(bind_ip: str | None, publish_ip: str) -> str:
130 """
131 Return the IP address to reach a server running on this host.
132
133 :param bind_ip: The server's configured bind IP (None or a wildcard means all interfaces).
134 :param publish_ip: The server's resolved publish IP.
135 """
136 if bind_ip and bind_ip not in WILDCARD_BIND_IPS:
137 # bound to one specific interface, so loopback would not reach the server
138 return bind_ip
139 # Use IPv6 loopback if publish_ip is IPv6 (indicates IPv6-only host)
140 return "::1" if ":" in publish_ip else "127.0.0.1"
141
142
143def _locale_from_request(request: web.Request) -> str | None:
144 """
145 Determine the UI locale for an HTTP request from the standard ``Accept-Language`` header.
146
147 Returns None when the header is absent, so the server falls back to the English source.
148
149 :param request: The aiohttp request.
150 """
151 header = request.headers.get("Accept-Language")
152 if not header:
153 return None
154 # take the first/highest-priority tag, dropping any quality factor ("nl-NL,nl;q=0.9" -> "nl-NL")
155 locale = header.split(",", 1)[0].split(";", 1)[0].strip()
156 return locale or None
157
158
159class WebserverController(CoreController):
160 """Core Controller that manages the builtin webserver that hosts the api and frontend."""
161
162 domain: str = "webserver"
163
164 def __init__(self, mass: MusicAssistant) -> None:
165 """Initialize instance."""
166 super().__init__(mass)
167 self._server = Webserver(self.logger, enable_dynamic_routes=True)
168 self.register_dynamic_route = self._server.register_dynamic_route
169 self.unregister_dynamic_route = self._server.unregister_dynamic_route
170 self.clients: set[WebsocketClientHandler] = set()
171 # the URL that the "auto" base_url setting resolves to, detected at setup
172 self._auto_base_url: str = ""
173 # whether SSL is switched on in the config, resolved at setup
174 self._ssl_configured: bool = False
175 # whether the webserver actually serves TLS, resolved at setup
176 self._ssl_active: bool = False
177 self.bind_ip: str | None = None
178 self.publish_addresses: list[str] = []
179 self.manifest.name = "Web Server (frontend and api)"
180 self.manifest.description = (
181 "The built-in webserver that hosts the Music Assistant Websockets API and frontend"
182 )
183 self.manifest.icon = "web-box"
184 self.auth = AuthenticationManager(self)
185 self.remote_access = RemoteAccessManager(self)
186 self._sendspin_proxy = SendspinProxyHandler(self)
187
188 @property
189 def base_url(self) -> str:
190 """Return the base_url for the webserver."""
191 config = getattr(self, "config", None)
192 if config is None:
193 return ""
194 base_url = str(config.get_value(CONF_BASE_URL) or CONF_VALUE_AUTO)
195 if base_url == CONF_VALUE_AUTO:
196 return self._auto_base_url
197 return base_url.removesuffix("/")
198
199 @property
200 def internal_base_url(self) -> str:
201 """Return the URL to reach this webserver's own API from this host."""
202 # the advertised address is not necessarily dialable here: a configured base URL
203 # routes out through DNS and a reverse proxy just to come back in, and a published
204 # IP need not exist on this host at all (e.g. a container or NAT setup), so derive
205 # the address from what the webserver actually binds to
206 connect_ip = _get_internal_connect_ip(self.bind_ip, self.publish_ip)
207 protocol = "https" if self._ssl_active else "http"
208 return f"{protocol}://{format_ip_for_url(connect_ip)}:{self.publish_port}"
209
210 @property
211 def internal_sendspin_url(self) -> str:
212 """Return the URL to reach the in-process Sendspin server from this host."""
213 # the advertised address is not necessarily dialable here (e.g. a container or
214 # NAT setup), so derive the address from what the Sendspin server actually binds to
215 connect_ip = _get_internal_connect_ip(
216 self.mass.streams.bind_ip, str(self.mass.streams.publish_ip)
217 )
218 return f"ws://{format_ip_for_url(connect_ip)}:{SENDSPIN_SERVER_PORT}/sendspin"
219
220 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
221 """Return all Config Entries for this core module (if any)."""
222 return await self._build_config_entries()
223
224 async def handle_config_action(
225 self, action: str
226 ) -> tuple[ConfigEntry, ...] | ConfigActionResult | None:
227 """Handle a one-shot action button press and report its outcome."""
228 if action == CONF_ACTION_VERIFY_SSL:
229 # the certificate/key are read from the stored config, so they must be saved
230 # before verifying - the action no longer receives the (unsaved) form values
231 cert_info = await verify_ssl_certificate(
232 str(self.get_config_value(CONF_SSL_CERTIFICATE, "")),
233 str(self.get_config_value(CONF_SSL_PRIVATE_KEY, "")),
234 )
235 if not cert_info.is_valid:
236 # a result only ever reports success, so an unusable certificate must raise
237 raise InvalidDataError(
238 f"Certificate verification failed: {cert_info.error_message}",
239 translation_key="ssl_verification_failed",
240 translation_args=[cert_info.error_message or ""],
241 translation_owner=self.translation_owner,
242 )
243 return ConfigActionResult(message=format_certificate_info(cert_info))
244 return await super().handle_config_action(action)
245
246 async def setup(self, config: CoreConfig) -> None: # noqa: PLR0915
247 """Async initialize of module."""
248 self.config = config
249 # work out all routes
250 routes: list[tuple[str, str, Callable[[web.Request], Awaitable[web.StreamResponse]]]] = []
251 # frontend routes
252 frontend_dir = locate_frontend()
253 for filename in next(os.walk(frontend_dir))[2]:
254 if filename.endswith(".py"):
255 continue
256 filepath = os.path.join(frontend_dir, filename)
257 handler = partial(self._server.serve_static, filepath)
258 routes.append(("GET", f"/{filename}", handler))
259 # add index (with onboarding check)
260 self._index_path = os.path.join(frontend_dir, "index.html")
261 routes.append(("GET", "/", self._handle_index))
262 routes.append(("HEAD", "/", self._handle_index))
263 # add logo
264 logo_path = str(RESOURCES_DIR.joinpath("logo.png"))
265 handler = partial(self._server.serve_static, logo_path)
266 routes.append(("GET", "/logo.png", handler))
267 # add common CSS for HTML resources
268 common_css_path = str(RESOURCES_DIR.joinpath("common.css"))
269 handler = partial(self._server.serve_static, common_css_path)
270 routes.append(("GET", "/resources/common.css", handler))
271 # add info
272 routes.append(("GET", "/info", self._handle_server_info))
273 routes.append(("OPTIONS", "/info", self._handle_cors_preflight))
274 # add websocket api
275 routes.append(("GET", "/ws", self._handle_ws_client))
276 # the canonical /imageproxy/<image_id> form is registered as a dynamic
277 # route on the webserver by MetaDataController.post_setup()
278 # also host the audio preview service
279 routes.append(("GET", "/preview", self.serve_preview_stream))
280 # add jsonrpc api
281 routes.append(("POST", "/api", self._handle_jsonrpc_api_command))
282 # add api documentation
283 routes.append(("GET", "/api-docs", self._handle_api_intro))
284 routes.append(("GET", "/api-docs/", self._handle_api_intro))
285 routes.append(("GET", "/api-docs/commands", self._handle_commands_reference))
286 routes.append(("GET", "/api-docs/commands/", self._handle_commands_reference))
287 routes.append(("GET", "/api-docs/commands.json", self._handle_commands_json))
288 routes.append(("GET", "/api-docs/schemas", self._handle_schemas_reference))
289 routes.append(("GET", "/api-docs/schemas/", self._handle_schemas_reference))
290 routes.append(("GET", "/api-docs/schemas.json", self._handle_schemas_json))
291 routes.append(("GET", "/api-docs/openapi.json", self._handle_openapi_spec))
292 routes.append(("GET", "/api-docs/swagger", self._handle_swagger_ui))
293 routes.append(("GET", "/api-docs/swagger/", self._handle_swagger_ui))
294 # add authentication routes
295 routes.append(("GET", "/login", self._handle_login_page))
296 routes.append(("POST", "/auth/login", self._handle_auth_login))
297 routes.append(("OPTIONS", "/auth/login", self._handle_cors_preflight))
298 routes.append(("POST", "/auth/logout", self._handle_auth_logout))
299 routes.append(("GET", "/auth/me", self._handle_auth_me))
300 routes.append(("PATCH", "/auth/me", self._handle_auth_me_update))
301 routes.append(("GET", "/auth/providers", self._handle_auth_providers))
302 routes.append(("GET", "/auth/authorize", self._handle_auth_authorize))
303 routes.append(("GET", "/auth/callback", self._handle_auth_callback))
304 # add first-time setup routes
305 routes.append(("GET", "/setup", self._handle_setup_page))
306 routes.append(("POST", "/setup", self._handle_setup))
307 # add sendspin proxy route (authenticated WebSocket proxy to internal sendspin server)
308 routes.append(("GET", "/sendspin", self._sendspin_proxy.handle_sendspin_proxy))
309 await self.auth.setup()
310 # start the webserver
311 if self.mass.running_as_hass_addon:
312 # if we're running on the HA supervisor we start an additional TCP site
313 # on the internal ("172.30.32.") IP for the HA ingress proxy - that address
314 # lives on a docker bridge, so it needs the unfiltered adapter list
315 all_ip_addresses = await get_ip_addresses(include_ipv6=True)
316 ingress_host = next(
317 (x for x in all_ip_addresses if x.startswith("172.30.32.")), all_ip_addresses[0]
318 )
319 ingress_tcp_site_params = (ingress_host, INGRESS_SERVER_PORT)
320 else:
321 ingress_tcp_site_params = None
322 port_value = config.get_value(CONF_BIND_PORT)
323 assert isinstance(port_value, int)
324 self.publish_port = port_value
325 bind_ip = cast("str | None", config.get_value(CONF_BIND_IP))
326 # Create SSL context if SSL is enabled
327 ssl_context = None
328 self._ssl_configured = bool(config.get_value(CONF_ENABLE_SSL, False))
329 if self._ssl_configured:
330 ssl_context = await create_server_ssl_context(
331 str(config.get_value(CONF_SSL_CERTIFICATE) or ""),
332 str(config.get_value(CONF_SSL_PRIVATE_KEY) or ""),
333 logger=self.logger,
334 )
335 # a missing or invalid certificate falls back to plain HTTP, so every URL we hand
336 # out must follow the context that was actually created, not the configured value
337 self._ssl_active = ssl_context is not None
338 protocol = "https" if self._ssl_active else "http"
339 publish_candidates = await get_publish_ip_candidates(include_ipv6=True)
340 self._resolve_publish_state(bind_ip, publish_candidates, protocol)
341
342 await self._server.setup(
343 bind_ip=bind_ip,
344 bind_port=self.publish_port,
345 static_routes=routes,
346 # add assets subdir as static_content
347 static_content=("/assets", os.path.join(frontend_dir, "assets"), "assets"),
348 ingress_tcp_site_params=ingress_tcp_site_params,
349 # Add mass object to app for use by the auth helpers
350 app_state={"mass": self.mass},
351 ssl_context=ssl_context,
352 )
353 # adopt what the server actually bound to: a configured port of 0 is only resolved
354 # by the OS at bind time and an unavailable bind IP falls back to all interfaces
355 self.publish_port = cast("int", self._server.port)
356 self._resolve_publish_state(self._server.bind_ip, publish_candidates, protocol)
357 base_url = self.base_url
358 # print a big fat message in the log where the webserver is running
359 # because this is a common source of issues for people with more complex setups
360 if not self.auth.has_users:
361 self.logger.warning(
362 "\n\n################################################################################\n"
363 "### SETUP REQUIRED ###\n"
364 "################################################################################\n"
365 "\n"
366 "Music Assistant is running in setup mode.\n"
367 "Please complete the setup by visiting:\n"
368 "\n"
369 " %s/setup\n"
370 "\n"
371 "################################################################################\n",
372 base_url,
373 )
374 else:
375 self.logger.info(
376 "\n"
377 "################################################################################\n"
378 "\n"
379 "Webserver available on: %s\n"
380 "\n"
381 "If this address is incorrect, see the documentation on how to configure\n"
382 "the Webserver in Settings --> System --> Webserver\n"
383 "\n"
384 "################################################################################\n",
385 base_url,
386 )
387
388 # Setup remote access after webserver is running
389 await self.remote_access.setup()
390
391 async def close(self) -> None:
392 """Cleanup on exit."""
393 await self.remote_access.close()
394 for client in set(self.clients):
395 await client.disconnect()
396 await self._server.close()
397 await self.auth.close()
398
399 def register_websocket_client(self, client: WebsocketClientHandler) -> None:
400 """Register a WebSocket client for tracking."""
401 self.clients.add(client)
402
403 def unregister_websocket_client(self, client: WebsocketClientHandler) -> None:
404 """Unregister a WebSocket client."""
405 self.clients.discard(client)
406
407 def disconnect_websockets_for_token(self, token_id: str) -> None:
408 """Disconnect all WebSocket clients using a specific token."""
409 for client in list(self.clients):
410 if hasattr(client, "_token_id") and client._token_id == token_id:
411 username = (
412 client._authenticated_user.username if client._authenticated_user else "unknown"
413 )
414 self.logger.warning(
415 "Disconnecting WebSocket client due to token revocation: %s",
416 username,
417 )
418 client._cancel()
419
420 def disconnect_websockets_for_user(self, user_id: str) -> None:
421 """Disconnect all WebSocket clients for a specific user."""
422 for client in list(self.clients):
423 if (
424 hasattr(client, "_authenticated_user")
425 and client._authenticated_user
426 and client._authenticated_user.user_id == user_id
427 ):
428 self.logger.warning(
429 "Disconnecting WebSocket client due to user action: %s",
430 client._authenticated_user.username,
431 )
432 client._cancel()
433
434 def set_sendspin_player_for_user(self, user_id: str, player_id: str) -> None:
435 """
436 Set the sendspin player_id on websocket clients for a specific user.
437
438 This is called by the sendspin proxy when a client connects, allowing
439 the player controller to auto-whitelist the player for that user's session.
440
441 :param user_id: The user ID to set the sendspin player for.
442 :param player_id: The sendspin player ID to set.
443 """
444 for client in list(self.clients):
445 if client._authenticated_user and client._authenticated_user.user_id == user_id:
446 client._sendspin_player_id = player_id
447 self.logger.debug(
448 "Set sendspin player %s for websocket client of user %s",
449 player_id,
450 client._authenticated_user.username,
451 )
452
453 def set_sendspin_player_for_webrtc_session(self, session_id: str, player_id: str) -> None:
454 """
455 Set the sendspin player_id on a websocket client for a WebRTC session.
456
457 This is called by the WebRTC gateway when it extracts the client_id from
458 the sendspin auth message, allowing auto-whitelisting of the player.
459
460 :param session_id: The WebRTC session ID.
461 :param player_id: The sendspin player ID to set.
462 """
463 for client in list(self.clients):
464 if client._webrtc_session_id == session_id:
465 client._sendspin_player_id = player_id
466 username = (
467 client._authenticated_user.username
468 if client._authenticated_user
469 else "unauthenticated"
470 )
471 self.logger.debug(
472 "Set sendspin player %s for WebRTC session %s (user: %s)",
473 player_id,
474 session_id,
475 username,
476 )
477 return
478
479 async def serve_preview_stream(self, request: web.Request) -> web.StreamResponse:
480 """Serve short preview sample."""
481 provider_instance_id_or_domain = request.query["provider"]
482 item_id = urllib.parse.unquote(request.query["item_id"])
483 resp = web.StreamResponse(status=200, reason="OK", headers={"Content-Type": "audio/aac"})
484 await resp.prepare(request)
485 preview_stream = self.mass.streams.get_preview_stream(
486 provider_instance_id_or_domain, item_id
487 )
488 # aclosing guarantees the preview stream (and the ffmpeg process behind it)
489 # is torn down immediately when the client disconnects, instead of lingering
490 # until garbage collection finalizes the abandoned generator.
491 async with aclosing(preview_stream):
492 async for chunk in preview_stream:
493 await resp.write(chunk)
494 return resp
495
496 def _resolve_publish_state(
497 self, bind_ip: str | None, publish_candidates: tuple[str, ...], protocol: str
498 ) -> None:
499 """
500 Resolve the addresses and base URL to advertise for the given bind address.
501
502 Reads ``self.publish_port``, so set that first.
503
504 :param bind_ip: Address the webserver binds to (None or a wildcard means all interfaces).
505 :param publish_candidates: Host addresses reachable from the local network, ranked.
506 :param protocol: URL scheme the webserver serves.
507 """
508 self.bind_ip = bind_ip
509 if bind_ip and bind_ip not in WILDCARD_BIND_IPS:
510 self.publish_ip = bind_ip
511 else:
512 self.publish_ip = publish_candidates[0]
513 self.publish_addresses = _get_publish_addresses(
514 bind_ip, self.publish_ip, publish_candidates
515 )
516 self._auto_base_url = (
517 f"{protocol}://{format_ip_for_url(self.publish_ip)}:{self.publish_port}"
518 )
519
520 async def _build_config_entries(self) -> tuple[ConfigEntry, ...]:
521 """Build this module's config entries."""
522 ip_addresses = await get_ip_addresses(include_ipv6=True)
523 return (
524 ConfigEntry(
525 key=CONF_AUTH_ALLOW_SELF_REGISTRATION,
526 type=ConfigEntryType.BOOLEAN,
527 default_value=True,
528 hidden=not any(provider.domain == "hass" for provider in self.mass.providers),
529 requires_reload=False,
530 ),
531 ConfigEntry(
532 key=CONF_BASE_URL,
533 type=ConfigEntryType.STRING,
534 default_value=CONF_VALUE_AUTO,
535 requires_reload=False,
536 ),
537 ConfigEntry(
538 key=CONF_BIND_PORT,
539 type=ConfigEntryType.INTEGER,
540 default_value=DEFAULT_SERVER_PORT,
541 requires_reload=True,
542 ),
543 # the two alerts are mutually exclusive: the generic one while SSL is switched off,
544 # and the SSL specific one when a certificate failed to load and left the webserver
545 # on plain HTTP
546 ConfigEntry(
547 key="webserver_warn",
548 type=ConfigEntryType.ALERT,
549 required=False,
550 hidden=self._ssl_configured,
551 depends_on=CONF_ENABLE_SSL,
552 depends_on_value=False,
553 ),
554 ConfigEntry(
555 key="ssl_inactive_warn",
556 type=ConfigEntryType.ALERT,
557 required=False,
558 hidden=not self._ssl_configured or self._ssl_active,
559 depends_on=CONF_ENABLE_SSL,
560 ),
561 ConfigEntry(
562 key=CONF_ENABLE_SSL,
563 type=ConfigEntryType.BOOLEAN,
564 default_value=False,
565 requires_reload=True,
566 ),
567 ConfigEntry(
568 key=CONF_SSL_CERTIFICATE,
569 type=ConfigEntryType.STRING,
570 required=False,
571 depends_on=CONF_ENABLE_SSL,
572 requires_reload=True,
573 ),
574 ConfigEntry(
575 key=CONF_SSL_PRIVATE_KEY,
576 type=ConfigEntryType.SECURE_STRING,
577 required=False,
578 depends_on=CONF_ENABLE_SSL,
579 requires_reload=True,
580 ),
581 ConfigEntry(
582 key=CONF_ACTION_VERIFY_SSL,
583 type=ConfigEntryType.ACTION,
584 action=CONF_ACTION_VERIFY_SSL,
585 depends_on=CONF_ENABLE_SSL,
586 required=False,
587 ),
588 ConfigEntry(
589 key=CONF_BIND_IP,
590 type=ConfigEntryType.STRING,
591 default_value=DEFAULT_HOST,
592 options=[ConfigValueOption(x, title=x) for x in {DEFAULT_HOST, *ip_addresses}],
593 category="generic",
594 advanced=True,
595 requires_reload=True,
596 ),
597 )
598
599 async def _handle_cors_preflight(self, request: web.Request) -> web.Response:
600 """Handle CORS preflight OPTIONS request."""
601 return web.Response(
602 status=200,
603 headers={
604 "Access-Control-Allow-Origin": "*",
605 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
606 "Access-Control-Allow-Headers": "Content-Type, Authorization",
607 "Access-Control-Max-Age": "86400", # Cache preflight for 24 hours
608 },
609 )
610
611 async def _handle_server_info(self, request: web.Request) -> web.Response:
612 """Handle request for server info."""
613 server_info = self.mass.get_server_info()
614 # Add CORS headers to allow frontend to call from any origin
615 return web.json_response(
616 server_info.to_dict(),
617 headers={
618 "Access-Control-Allow-Origin": "*",
619 "Access-Control-Allow-Methods": "GET, OPTIONS",
620 "Access-Control-Allow-Headers": "Content-Type, Authorization",
621 },
622 )
623
624 async def _handle_ws_client(self, request: web.Request) -> web.WebSocketResponse:
625 connection = WebsocketClientHandler(self, request)
626 if lang := request.headers.get("Accept-Language"):
627 self.mass.metadata.set_default_preferred_language(lang.split(",")[0])
628 try:
629 self.clients.add(connection)
630 return await connection.handle_client()
631 finally:
632 self.clients.discard(connection)
633
634 async def _handle_jsonrpc_api_command(self, request: web.Request) -> web.Response:
635 """Handle incoming JSON RPC API command."""
636 # These requests carry no connection identity, so the peer address is all an
637 # unauthenticated handler has to tell one caller apart from another.
638 set_current_peer_address(request.remote)
639 # Fail early if we don't have any users yet
640 if not self.auth.has_users:
641 return web.Response(status=503, text="Setup required")
642 if not request.can_read_body:
643 return web.Response(status=400, text="Body required")
644 cmd_data = await request.read()
645 self.logger.log(VERBOSE_LOG_LEVEL, "Received on JSONRPC API: %s", cmd_data)
646 try:
647 command_msg = CommandMessage.from_json(cmd_data)
648 except ValueError:
649 error = f"Invalid JSON: {cmd_data.decode()}"
650 self.logger.error("Unhandled JSONRPC API error: %s", error)
651 return web.Response(status=400, text=error)
652 except MissingField as e:
653 # be forgiving if message_id is missing
654 cmd_data_dict = json_loads(cmd_data)
655 if e.field_name == "message_id" and "command" in cmd_data_dict:
656 cmd_data_dict["message_id"] = "unknown"
657 command_msg = CommandMessage.from_dict(cmd_data_dict)
658 else:
659 error = f"Missing field in JSON: {e.field_name}"
660 self.logger.error("Unhandled JSONRPC API error: %s", error)
661 return web.Response(status=400, text="Invalid JSON: missing required field")
662
663 # work out handler for the given path/command
664 handler = self.mass.command_handlers.get(command_msg.command)
665 if handler is None:
666 error = f"Invalid Command: {command_msg.command}"
667 self.logger.error("Unhandled JSONRPC API error: %s", error)
668 return web.Response(status=400, text=error)
669
670 # Check authentication if required
671 if error_response := await self._authenticate_api_command(request, handler):
672 return error_response
673
674 try:
675 # handle the optional impersonation argument for impersonation-enabled commands
676 if handler.allow_impersonation and command_msg.args:
677 if impersonation_user := await resolve_command_impersonation(
678 self.mass, command_msg.args
679 ):
680 set_impersonated_user(impersonation_user)
681 args = parse_arguments(handler.signature, handler.type_hints, command_msg.args)
682 result: Any = handler.target(**args)
683 if hasattr(result, "__anext__"):
684 # handle async generator (for really large listings)
685 result = [item async for item in result]
686 elif inspect.iscoroutine(result):
687 result = await result
688 # Determine the UI locale for this request from the HTTP headers and warm it up
689 # so localized strings can be injected during dict serialization without disk I/O.
690 locale = _locale_from_request(request)
691 await self.mass.translations.ensure_locale_loaded(locale)
692 return self._localized_json_response(result, locale)
693 except InsufficientPermissions as e:
694 return web.Response(status=403, text=str(e))
695 except (InvalidDataError, UserNotFoundError) as e:
696 return web.Response(status=400, text=str(e))
697 except Exception as e:
698 # Return clean error message without stacktrace
699 error_type = type(e).__name__
700 error_msg = str(e)
701 error = f"{error_type}: {error_msg}"
702 self.logger.exception("Error executing command %s: %s", command_msg.command, error)
703 return web.Response(status=500, text="Internal server error")
704
705 async def _authenticate_api_command(
706 self, request: web.Request, handler: APICommandHandler
707 ) -> web.Response | None:
708 """
709 Authenticate the request and check the handler's required scope.
710
711 Sets the authenticated user in context and returns an error response
712 if authentication or the scope check failed, None otherwise.
713 """
714 if not (handler.authenticated or handler.required_scope):
715 return None
716 try:
717 user = await get_authenticated_user(request)
718 except Exception as e:
719 self.logger.exception("Authentication error: %s", e)
720 return web.Response(
721 status=401,
722 text="Authentication failed",
723 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
724 )
725
726 if not user:
727 return web.Response(
728 status=401,
729 text="Authentication required",
730 headers={"WWW-Authenticate": 'Bearer realm="Music Assistant"'},
731 )
732
733 # Set user and token in context and check the required scope
734 set_current_user(user)
735 auth_header = request.headers.get("Authorization", "")
736 if auth_header.lower().startswith("bearer "):
737 set_current_token(auth_header[7:])
738 if handler.required_scope and not has_scope(user, handler.required_scope):
739 return web.Response(
740 status=403,
741 text=f"This command requires the {handler.required_scope} scope",
742 )
743 return None
744
745 def _localized_json_response(self, result: Any, locale: str | None) -> web.Response:
746 """
747 Serialize a command result to a JSON response with the per-request resolvers bound.
748
749 Sets the image-proxy resolver (for ``proxy_id`` injection) and the translation
750 resolver (to localize human-readable fields) for the given locale during dict
751 serialization, then resets them.
752 """
753 token = IMAGE_PROXY_ID_RESOLVER.set(self.mass.metadata.compute_image_id)
754 token_loc = TRANSLATION_RESOLVER.set(
755 partial(self.mass.translations.get_translation, locale=locale)
756 )
757 try:
758 return web.json_response(result, dumps=json_dumps)
759 finally:
760 IMAGE_PROXY_ID_RESOLVER.reset(token)
761 TRANSLATION_RESOLVER.reset(token_loc)
762
763 async def _handle_api_intro(self, request: web.Request) -> web.Response:
764 """Handle request for API introduction/documentation page."""
765 intro_html_path = str(RESOURCES_DIR.joinpath("api_docs.html"))
766 # Read the template
767 async with aiofiles.open(intro_html_path) as f:
768 html_content = await f.read()
769
770 # Replace placeholders (escape values to prevent XSS)
771 html_content = html_content.replace("{VERSION}", html.escape(self.mass.version))
772 html_content = html_content.replace("{BASE_URL}", html.escape(self.base_url))
773 html_content = html_content.replace("{SERVER_HOST}", html.escape(request.host))
774
775 return web.Response(text=html_content, content_type="text/html")
776
777 async def _handle_openapi_spec(self, request: web.Request) -> web.Response:
778 """Handle request for OpenAPI specification (generated on-the-fly)."""
779 spec = generate_openapi_spec(
780 self.mass.command_handlers, server_url=self.base_url, version=self.mass.version
781 )
782 return web.json_response(spec)
783
784 async def _handle_commands_reference(self, request: web.Request) -> web.FileResponse:
785 """Handle request for commands reference page."""
786 commands_html_path = str(RESOURCES_DIR.joinpath("commands_reference.html"))
787 return await self._server.serve_static(commands_html_path, request)
788
789 async def _handle_commands_json(self, request: web.Request) -> web.Response:
790 """Handle request for commands JSON data (generated on-the-fly)."""
791 commands_data = generate_commands_json(self.mass.command_handlers)
792 return web.json_response(commands_data)
793
794 async def _handle_schemas_reference(self, request: web.Request) -> web.FileResponse:
795 """Handle request for schemas reference page."""
796 schemas_html_path = str(RESOURCES_DIR.joinpath("schemas_reference.html"))
797 return await self._server.serve_static(schemas_html_path, request)
798
799 async def _handle_schemas_json(self, request: web.Request) -> web.Response:
800 """Handle request for schemas JSON data (generated on-the-fly)."""
801 schemas_data = generate_schemas_json(self.mass.command_handlers)
802 return web.json_response(schemas_data)
803
804 async def _handle_swagger_ui(self, request: web.Request) -> web.FileResponse:
805 """Handle request for Swagger UI."""
806 swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html"))
807 return await self._server.serve_static(swagger_html_path, request)
808
809 async def _render_error_page(self, error_message: str, status: int = 403) -> web.Response:
810 """
811 Render a user-friendly error page with the given message.
812
813 :param error_message: The error message to display to the user.
814 :param status: HTTP status code for the response.
815 """
816 error_html_path = str(RESOURCES_DIR.joinpath("error.html"))
817 async with aiofiles.open(error_html_path) as f:
818 html_content = await f.read()
819 # Replace placeholder with the actual error message (escape to prevent XSS)
820 html_content = html_content.replace("{{ERROR_MESSAGE}}", html.escape(error_message))
821 return web.Response(text=html_content, content_type="text/html", status=status)
822
823 async def _handle_index(self, request: web.Request) -> web.StreamResponse:
824 """Handle request for index page (Vue frontend)."""
825 is_ingress_request = is_request_from_ingress(request)
826
827 if (not self.auth.has_users or not self.mass.config.onboard_done) and is_ingress_request:
828 # a non-admin user tries to access the index via HA ingress
829 # while we're not yet onboarded, prevent that as it leads to a bad UX
830 ingress_user_id = request.headers.get("X-Remote-User-ID", "")
831 role = await get_ha_user_role(self.mass, ingress_user_id)
832 if role != UserRole.ADMIN:
833 return await self._render_error_page(
834 "Administrator permissions are required to complete the initial setup. "
835 "Please ask a Home Assistant administrator to complete the setup first."
836 )
837 # NOTE: For ingress admin user,
838 # we allow access to index, user will be auto created and then forwarded to the
839 # frontend (which will take care of onboarding)
840
841 if not self.auth.has_users and not is_ingress_request:
842 # non ingress request and no users yet, redirect to setup
843 return web.Response(status=302, headers={"Location": "setup"})
844
845 # Serve the Vue frontend index.html
846 return await self._server.serve_static(self._index_path, request)
847
848 async def _handle_login_page(self, request: web.Request) -> web.Response:
849 """Handle request for login page (external client OAuth callback scenario)."""
850 if not self.auth.has_users:
851 # not yet onboarded (no first admin user exists), redirect to setup
852 return_url = request.query.get("return_url", "")
853 device_name = request.query.get("device_name", "")
854 setup_url = (
855 f"/setup?return_url={return_url}&device_name={device_name}"
856 if return_url
857 else "/setup"
858 )
859 return web.Response(status=302, headers={"Location": setup_url})
860 # Serve login page for external clients
861 login_html_path = str(RESOURCES_DIR.joinpath("login.html"))
862 async with aiofiles.open(login_html_path) as f:
863 html_content = await f.read()
864 return web.Response(text=html_content, content_type="text/html")
865
866 async def _handle_auth_login(self, request: web.Request) -> web.Response:
867 """Handle login request."""
868 # Block until onboarding is complete
869 if not self.auth.has_users:
870 return web.json_response(
871 {"success": False, "error": "Setup required"},
872 status=403,
873 headers={
874 "Access-Control-Allow-Origin": "*",
875 "Access-Control-Allow-Methods": "POST, OPTIONS",
876 "Access-Control-Allow-Headers": "Content-Type, Authorization",
877 },
878 )
879
880 try:
881 if not request.can_read_body:
882 return web.Response(status=400, text="Body required")
883
884 body = await request.json()
885 provider_id = body.get("provider_id", "builtin") # Default to built-in provider
886 credentials = body.get("credentials", {})
887 return_url = body.get("return_url") # Optional return URL for redirect after login
888
889 # Authenticate with provider
890 auth_result = await self.auth.authenticate_with_credentials(provider_id, credentials)
891
892 if not auth_result.success or not auth_result.user:
893 return web.json_response(
894 {"success": False, "error": auth_result.error},
895 status=401,
896 headers={
897 "Access-Control-Allow-Origin": "*",
898 "Access-Control-Allow-Methods": "POST, OPTIONS",
899 "Access-Control-Allow-Headers": "Content-Type, Authorization",
900 },
901 )
902
903 # Create token for user
904 device_name = body.get(
905 "device_name", f"{request.headers.get('User-Agent', 'Unknown')[:50]}"
906 )
907 token = await self.auth.create_token(auth_result.user, device_name)
908
909 # Prepare response data
910 response_data = {
911 "success": True,
912 "token": token,
913 "user": auth_result.user.to_dict(),
914 }
915
916 # If return_url provided, append code parameter and return as redirect_to
917 if return_url:
918 # SECURITY FIX (GHSA-j369-4c4w-7qmq): only forward the token to trusted
919 # destinations. is_allowed_redirect_url returns (True, "external") for any
920 # unknown external URL, so checking is_valid alone would still leak the JWT.
921 # Unlike _handle_auth_authorize/_handle_auth_callback, this endpoint appends
922 # the token immediately with no consent step, so "external" must be rejected.
923 _, category = is_allowed_redirect_url(return_url, request, self.base_url)
924 if category != "trusted":
925 return web.Response(status=400, text="Invalid return_url")
926
927 redirect_url = build_code_redirect_url(return_url, token)
928
929 response_data["redirect_to"] = redirect_url
930 self.logger.debug(
931 "Login successful, returning redirect_to: %s",
932 redirect_url.replace(token, "***TOKEN***"),
933 )
934
935 # Add CORS headers to allow login from any origin
936 return web.json_response(
937 response_data,
938 headers={
939 "Access-Control-Allow-Origin": "*",
940 "Access-Control-Allow-Methods": "POST, OPTIONS",
941 "Access-Control-Allow-Headers": "Content-Type, Authorization",
942 },
943 )
944 except Exception:
945 self.logger.exception("Error during login")
946 return web.json_response(
947 {"success": False, "error": "Login failed"},
948 status=500,
949 headers={
950 "Access-Control-Allow-Origin": "*",
951 "Access-Control-Allow-Methods": "POST, OPTIONS",
952 "Access-Control-Allow-Headers": "Content-Type, Authorization",
953 },
954 )
955
956 async def _handle_auth_logout(self, request: web.Request) -> web.Response:
957 """Handle logout request."""
958 user = await get_authenticated_user(request)
959 if not user:
960 return web.Response(status=401, text="Not authenticated")
961
962 # Get token from request
963 auth_header = request.headers.get("Authorization", "")
964 if auth_header.startswith("Bearer "):
965 token = auth_header[7:]
966 # Find and revoke the token
967 token_hash = hashlib.sha256(token.encode()).hexdigest()
968 token_row = await self.auth.database.get_row("auth_tokens", {"token_hash": token_hash})
969 if token_row:
970 await self.auth.database.delete("auth_tokens", {"token_id": token_row["token_id"]})
971
972 return web.json_response({"success": True})
973
974 async def _handle_auth_me(self, request: web.Request) -> web.Response:
975 """Handle request for current user information."""
976 user = await get_authenticated_user(request)
977 if not user:
978 return web.Response(status=401, text="Not authenticated")
979
980 return web.json_response(user.to_dict())
981
982 async def _handle_auth_me_update(self, request: web.Request) -> web.Response:
983 """Handle request to update current user's profile."""
984 user = await get_authenticated_user(request)
985 if not user:
986 return web.Response(status=401, text="Not authenticated")
987
988 try:
989 if not request.can_read_body:
990 return web.Response(status=400, text="Body required")
991
992 body = await request.json()
993 username = body.get("username")
994 display_name = body.get("display_name")
995 avatar_url = body.get("avatar_url")
996
997 # Update user
998 updated_user = await self.auth.update_user(
999 user,
1000 username=username,
1001 display_name=display_name,
1002 avatar_url=avatar_url,
1003 )
1004
1005 return web.json_response({"success": True, "user": updated_user.to_dict()})
1006 except Exception:
1007 self.logger.exception("Error updating user profile")
1008 return web.json_response(
1009 {"success": False, "error": "Failed to update profile"}, status=500
1010 )
1011
1012 async def _handle_auth_providers(self, request: web.Request) -> web.Response:
1013 """Handle request for available login providers."""
1014 try:
1015 providers = await self.auth.get_login_providers()
1016 return web.json_response(providers)
1017 except Exception:
1018 self.logger.exception("Error getting auth providers")
1019 return web.json_response({"error": "Failed to get auth providers"}, status=500)
1020
1021 async def _handle_auth_authorize(self, request: web.Request) -> web.Response:
1022 """Handle OAuth authorization request."""
1023 try:
1024 provider_id = request.query.get("provider_id")
1025 return_url = request.query.get("return_url")
1026
1027 self.logger.debug(
1028 "OAuth authorize request: provider_id=%s, return_url=%s", provider_id, return_url
1029 )
1030
1031 if not provider_id:
1032 return web.Response(status=400, text="provider_id required")
1033
1034 # Validate return_url if provided
1035 if return_url:
1036 is_valid, _ = is_allowed_redirect_url(return_url, request, self.base_url)
1037 if not is_valid:
1038 return web.Response(status=400, text="Invalid return_url")
1039
1040 auth_url = await self.auth.get_authorization_url(provider_id, return_url)
1041 if not auth_url:
1042 return web.Response(
1043 status=400, text="Provider does not support OAuth or is not configured"
1044 )
1045
1046 return web.json_response({"authorization_url": auth_url})
1047 except Exception:
1048 self.logger.exception("Error during OAuth authorization")
1049 return web.json_response({"error": "Authorization failed"}, status=500)
1050
1051 async def _handle_auth_callback(self, request: web.Request) -> web.Response:
1052 """Handle OAuth callback."""
1053 try:
1054 code = request.query.get("code")
1055 state = request.query.get("state")
1056 provider_id = request.query.get("provider_id")
1057
1058 if not code or not state or not provider_id:
1059 return web.Response(status=400, text="code, state, and provider_id required")
1060
1061 redirect_uri = f"{self.base_url}/auth/callback?provider_id={provider_id}"
1062 auth_result = await self.auth.handle_oauth_callback(
1063 provider_id, code, state, redirect_uri
1064 )
1065
1066 if not auth_result.success or not auth_result.user:
1067 # Return error page
1068 error_html = f"""
1069 <html>
1070 <body>
1071 <h1>Authentication Failed</h1>
1072 <p>{html.escape(auth_result.error or "Unknown error")}</p>
1073 <a href="/login">Back to Login</a>
1074 </body>
1075 </html>
1076 """
1077 return web.Response(text=error_html, content_type="text/html", status=400)
1078
1079 # Create token
1080 device_name = f"OAuth ({provider_id})"
1081 token = await self.auth.create_token(auth_result.user, device_name)
1082
1083 # Determine redirect URL (use return_url from OAuth flow or default to root)
1084 final_redirect_url = auth_result.return_url or "/"
1085 requires_consent = False
1086
1087 # Validate redirect URL for security
1088 if auth_result.return_url:
1089 is_valid, category = is_allowed_redirect_url(
1090 auth_result.return_url, request, self.base_url
1091 )
1092 if not is_valid:
1093 self.logger.warning("Invalid return_url blocked: %s", auth_result.return_url)
1094 final_redirect_url = "/"
1095 elif category == "external":
1096 # External domain - require user consent
1097 requires_consent = True
1098 final_redirect_url = build_code_redirect_url(final_redirect_url, token)
1099
1100 # Load OAuth callback success page template and inject token and redirect URL
1101 oauth_callback_html_path = str(RESOURCES_DIR.joinpath("oauth_callback.html"))
1102 async with aiofiles.open(oauth_callback_html_path) as f:
1103 success_html = await f.read()
1104
1105 # Replace the redirect last so its untrusted contents cannot match another placeholder.
1106 success_html = success_html.replace(
1107 "{REQUIRES_CONSENT}", "true" if requires_consent else "false"
1108 )
1109 success_html = success_html.replace("{TOKEN}", _serialize_script_value(token))
1110 success_html = success_html.replace(
1111 "{REDIRECT_URL}", _serialize_script_value(final_redirect_url)
1112 )
1113
1114 return web.Response(text=success_html, content_type="text/html")
1115 except Exception:
1116 self.logger.exception("Error during OAuth callback")
1117 error_html = """
1118 <html>
1119 <body>
1120 <h1>Authentication Failed</h1>
1121 <p>An error occurred during authentication</p>
1122 <a href="/login">Back to Login</a>
1123 </body>
1124 </html>
1125 """
1126 return web.Response(text=error_html, content_type="text/html", status=500)
1127
1128 async def _handle_setup_page(self, request: web.Request) -> web.Response:
1129 """Handle request for first-time setup page."""
1130 # Setup forwards the admin token here with no consent step, so require a trusted destination.
1131 return_url = request.query.get("return_url")
1132 if return_url:
1133 _, category = is_allowed_redirect_url(return_url, request, self.base_url)
1134 if category != "trusted":
1135 return web.Response(status=400, text="Invalid return_url")
1136
1137 if self.auth.has_users:
1138 # this should not happen, but guard anyways
1139 return await self._render_error_page("Setup has already been completed.")
1140
1141 setup_html_path = str(RESOURCES_DIR.joinpath("setup.html"))
1142 async with aiofiles.open(setup_html_path) as f:
1143 html_content = await f.read()
1144
1145 return web.Response(text=html_content, content_type="text/html")
1146
1147 async def _handle_setup(self, request: web.Request) -> web.Response:
1148 """Handle first-time setup request to create admin user (non-ingress only)."""
1149 if self.auth.has_users:
1150 return web.json_response(
1151 {"success": False, "error": "Setup already completed"}, status=400
1152 )
1153
1154 if not request.can_read_body:
1155 return web.Response(status=400, text="Body required")
1156
1157 body = await request.json()
1158 username = body.get("username", "").strip()
1159 password = body.get("password", "")
1160
1161 # Validation
1162 if not username or len(username) < 2:
1163 return web.json_response(
1164 {"success": False, "error": "Username must be at least 2 characters"}, status=400
1165 )
1166
1167 if not password or len(password) < 8:
1168 return web.json_response(
1169 {"success": False, "error": "Password must be at least 8 characters"}, status=400
1170 )
1171
1172 try:
1173 builtin_provider = self.auth.login_providers.get("builtin")
1174 if not builtin_provider:
1175 return web.json_response(
1176 {"success": False, "error": "Built-in auth provider not available"},
1177 status=500,
1178 )
1179
1180 if not isinstance(builtin_provider, BuiltinLoginProvider):
1181 return web.json_response(
1182 {"success": False, "error": "Built-in provider configuration error"},
1183 status=500,
1184 )
1185
1186 # Create admin user with password
1187 user = await builtin_provider.create_user_with_password(
1188 username, password, role=UserRole.ADMIN
1189 )
1190
1191 # Create token for the new admin
1192 device_name = body.get(
1193 "device_name", f"Setup ({request.headers.get('User-Agent', 'Unknown')[:50]})"
1194 )
1195 token = await self.auth.create_token(user, device_name)
1196
1197 self.logger.info("First admin user created: %s", username)
1198
1199 # Return token - frontend will complete onboarding via config/onboard_complete
1200 response_data: dict[str, Any] = {
1201 "success": True,
1202 "token": token,
1203 "user": user.to_dict(),
1204 }
1205
1206 # Only forward the token to a trusted destination (no consent step here).
1207 return_url = body.get("return_url")
1208 if return_url and isinstance(return_url, str):
1209 _, category = is_allowed_redirect_url(return_url, request, self.base_url)
1210 if category == "trusted":
1211 response_data["redirect_to"] = build_code_redirect_url(
1212 return_url, token, {"onboard": "true"}
1213 )
1214 else:
1215 self.logger.warning("Ignoring untrusted setup return_url: %s", return_url)
1216
1217 return web.json_response(response_data)
1218
1219 except Exception as e:
1220 self.logger.exception("Error during setup")
1221 return web.json_response(
1222 {"success": False, "error": f"Setup failed: {e!s}"}, status=500
1223 )
1224
1225
1226def _serialize_script_value(value: str) -> str:
1227 """Serialize a string for use inside an HTML script element."""
1228 return (
1229 json_dumps(value)
1230 .replace("&", "\\u0026")
1231 .replace("<", "\\u003c")
1232 .replace(">", "\\u003e")
1233 .replace("\u2028", "\\u2028")
1234 .replace("\u2029", "\\u2029")
1235 )
1236