From: Marcel van der Veldt Date: Fri, 28 Nov 2025 21:08:05 +0000 (+0100) Subject: Prepare remote connect feature (#2710) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=b62c1e5f307814b6663d6a491263b03e7aff247a;p=music-assistant-server.git Prepare remote connect feature (#2710) --- diff --git a/music_assistant/controllers/webserver/README.md b/music_assistant/controllers/webserver/README.md new file mode 100644 index 00000000..fde5f1a5 --- /dev/null +++ b/music_assistant/controllers/webserver/README.md @@ -0,0 +1,448 @@ +# Webserver and Authentication Architecture + +This document provides a comprehensive overview of the Music Assistant webserver architecture, authentication system, and remote access capabilities. + +## Table of Contents + +- [Overview](#overview) +- [Core Components](#core-components) +- [Authentication System](#authentication-system) +- [Remote Access (WebRTC)](#remote-access-webrtc) +- [Request Flow](#request-flow) +- [Security Considerations](#security-considerations) +- [Development Guide](#development-guide) + +## Overview + +The Music Assistant webserver is a core controller that provides: +- WebSocket-based real-time API for bidirectional communication +- HTTP/JSON-RPC API for simple request-response interactions +- User authentication and authorization system +- Frontend hosting (Vue-based PWA) +- Remote access via WebRTC for external connectivity +- Home Assistant integration via Ingress + +The webserver runs on port `8095` by default and can be configured via the webserver controller settings. + +## Core Components + +### 1. WebserverController ([controller.py](controller.py)) + +The main orchestrator that manages: +- HTTP server setup and lifecycle +- Route registration (static files, API endpoints, auth endpoints) +- WebSocket client management +- Authentication manager initialization +- Remote access manager initialization +- Home Assistant Supervisor announcement (when running as add-on) + +**Key responsibilities:** +- Serves the frontend application (PWA) +- Hosts the WebSocket API endpoint (`/ws`) +- Provides HTTP/JSON-RPC API endpoint (`/api`) +- Manages authentication routes (`/login`, `/auth/*`, `/setup`) +- Serves API documentation (`/api-docs`) +- Handles image proxy and audio preview endpoints + +### 2. AuthenticationManager ([auth.py](auth.py)) + +Handles all authentication and user management: + +**Database Schema:** +- `users` - User accounts with roles (admin/user) +- `user_auth_providers` - Links users to authentication providers (many-to-many) +- `auth_tokens` - Access tokens with expiration tracking +- `settings` - Schema version and configuration + +**Authentication Providers:** +- **Built-in Provider** - Username/password authentication with bcrypt hashing +- **Home Assistant OAuth** - OAuth2 flow for Home Assistant users (auto-enabled when HA provider is configured) + +**Token Types:** +- **Short-lived tokens**: Auto-renewing on use, 30-day sliding expiration window (for user sessions) +- **Long-lived tokens**: No auto-renewal, 10-year expiration (for integrations/API access) + +**Security Features:** +- Rate limiting on login attempts (progressive delays) +- Password hashing with bcrypt and user- and server specific salts +- Secure token generation with secrets.token_urlsafe() +- WebSocket disconnect on token revocation +- Session management and cleanup + +**User Roles:** +- `ADMIN` - Full access to all commands and settings +- `USER` - Standard access (configurable via player/provider filters) + +### 3. RemoteAccessManager ([remote_access/](remote_access/)) + +Manages WebRTC-based remote access for external connectivity: + +**Architecture:** +- **Signaling Server**: Cloud-based WebSocket server for WebRTC signaling (hosted at `wss://signaling.music-assistant.io/ws`) +- **WebRTC Gateway**: Local component that bridges WebRTC data channels to the WebSocket API +- **Remote ID**: Unique identifier (format: `MA-XXXX-XXXX`) for connecting to specific instances + +**How it works:** +1. Remote access is automatically enabled when Home Assistant Cloud subscription is detected +2. A unique Remote ID is generated and stored in config +3. The gateway connects to the signaling server and registers with the Remote ID +4. Remote clients (PWA or mobile apps) connect via WebRTC using the Remote ID +5. Data channel messages are bridged to/from the local WebSocket API +6. ICE servers (STUN/TURN) are provided by Home Assistant Cloud + +**Key features:** +- Automatic reconnection on signaling server disconnect +- Multiple concurrent WebRTC sessions supported +- No port forwarding required +- End-to-end encryption via WebRTC + +**Availability:** +Currently, remote access is only offered to users with an active Home Assistant Cloud subscription due to reliance on cloud-based STUN/TURN servers. + +### 4. WebSocket Client Handler ([websocket_client.py](websocket_client.py)) + +Manages individual WebSocket connections: +- Authentication enforcement (auth or login command must be first) +- Command routing and response handling +- Event subscription and broadcasting +- Connection lifecycle management +- Token validation and user context + +### 5. Authentication Helpers + +**Middleware ([helpers/auth_middleware.py](helpers/auth_middleware.py)):** +- Request authentication for HTTP endpoints +- User context management (thread-local storage) +- Ingress detection (Home Assistant add-on) +- Token extraction from Authorization header + +**Providers ([helpers/auth_providers.py](helpers/auth_providers.py)):** +- Base classes for authentication providers +- Built-in username/password provider +- Home Assistant OAuth provider +- Rate limiting implementation + +## Authentication System + +### First-Time Setup Flow + +1. **Initial State**: No users exist, `onboard_done = false` +2. **Setup Required**: User is redirected to `/setup` +3. **Admin Creation**: User creates the first admin account with username/password +4. **Onboarding Complete**: `onboard_done` is set to `true` + +### Login Flow (Standard) + +1. **Client Request**: POST to `/auth/login` with credentials +2. **Provider Authentication**: Credentials validated by authentication provider +3. **Token Generation**: Short-lived token created for the user +4. **Response**: Token and user info returned to frontend +5. **Subsequent Requests**: Token included in Authorization header or WebSocket auth command + +### Login Flow (Home Assistant OAuth) + +1. **Initiate OAuth**: GET `/auth/authorize?provider_id=homeassistant&return_url=...` +2. **Redirect to HA**: User is redirected to Home Assistant OAuth consent page +3. **OAuth Callback**: HA redirects back to `/auth/callback` with code and state +4. **Token Exchange**: Code exchanged for HA access token +5. **User Lookup/Creation**: User found or created with HA provider link +6. **Token Generation**: MA token created and returned via redirect with `code` parameter +7. **Client Handling**: Client extracts token from URL and stores it + +### Remote Client OAuth Flow + +For remote clients (PWA over WebRTC), OAuth requires special handling since redirect URLs can't point to localhost: + +1. **Request Session**: Remote client calls `auth/authorization_url` with `for_remote_client=true` +2. **Session Created**: Server creates a pending OAuth session and returns session_id and auth URL +3. **User Opens Browser**: Client opens auth URL in system browser +4. **OAuth Flow**: User completes OAuth in browser +5. **Token Stored**: Server stores token in pending session (using special return URL format) +6. **Polling**: Client polls `auth/oauth_status` with session_id +7. **Token Retrieved**: Once complete, client receives token and can authenticate + +### Ingress Authentication (Home Assistant Add-on) + +When running as a Home Assistant add-on: +- A dedicated webserver TCP site is hosted (on port 8094) bound to the internal HA docker network only +- Ingress requests include HA user headers (`X-Remote-User-ID`, `X-Remote-User-Name`) +- Users are auto-created on first access +- No password required (authentication handled by HA) +- System user created for HA integration communication + +### WebSocket Authentication + +1. **Connection Established**: Client connects to `/ws` +2. **Auth Command Required**: First command must be `auth` with token +3. **Token Validation**: Token validated and user context set +4. **Authenticated Session**: All subsequent commands executed in user context +5. **Auto-Disconnect**: Connection closed on token revocation or user disable + +## Remote Access (WebRTC) + +### Architecture Overview + +Remote access enables users to connect to their Music Assistant instance from anywhere without port forwarding or VPN: + +``` +[Remote Client (PWA or app)] + | + | WebRTC Data Channel + v +[Signaling Server] ←→ [WebRTC Gateway] + | + | WebSocket + v + [Local WebSocket API] +``` + +### Components + +**Signaling Server** (`wss://signaling.music-assistant.io/ws`): +- Cloud-based WebSocket server for WebRTC signaling +- Handles SDP offer/answer exchange +- Routes ICE candidates between peers +- Maintains Remote ID registry + +**WebRTC Gateway** ([remote_access/gateway.py](remote_access/gateway.py)): +- Runs locally as part of the webserver controller +- Connects to signaling server and registers Remote ID +- Accepts incoming WebRTC connections from remote clients +- Bridges WebRTC data channel messages to local WebSocket API +- Handles multiple concurrent sessions + +**Remote ID**: +- Format: `MA-XXXX-XXXX` (e.g., `MA-K7G3-P2M4`) +- Uniquely identifies a Music Assistant instance +- Generated once and stored in controller config +- Used by remote clients to connect to specific instance + +### Connection Flow + +1. **Initialization**: + - HA Cloud subscription detected + - Remote ID generated/retrieved from config + - Gateway connects to signaling server + - Remote ID registered with signaling server + +2. **Remote Client Connection**: + - User opens PWA (https://app.music-assistant.io) and enters Remote ID + - PWA creates WebRTC peer connection + - PWA sends SDP offer via signaling server + - Gateway receives offer and creates peer connection + - Gateway sends SDP answer via signaling server + - ICE candidates exchanged for NAT traversal + - WebRTC data channel established + +3. **Message Bridging**: + - Remote client sends WebSocket-format messages over data channel + - Gateway forwards messages to local WebSocket API + - Responses and events sent back through data channel + - Authentication and authorization work identically to local WebSocket + +### ICE Servers (STUN/TURN) + +NAT traversal is critical for WebRTC connections. Music Assistant uses: + +- **STUN servers**: Public servers for discovering public IP (Google, Cloudflare) +- **TURN servers**: Relay servers for cases where direct connection fails (provided by HA Cloud) + +**Current Implementation:** +- Default STUN servers are used (Google, Cloudflare) +- HA Cloud STUN servers are planned but not yet implemented +- HA Cloud TURN servers are planned but not yet implemented +- Most connections succeed with STUN alone, but TURN improves reliability + +### Availability Requirements + +Remote access is currently **only available** to users with: +1. Home Assistant integration configured +2. Active Home Assistant Cloud subscription + +This limitation exists because: +- TURN servers are expensive to host +- HA Cloud provides TURN infrastructure +- Ensures reliable connections for paying subscribers + +### API Endpoints + +**`remote_access/info`** (WebSocket command): +Returns remote access status: +```json +{ + "enabled": true, + "connected": true, + "remote_id": "MA-K7G3-P2M4", + "signaling_url": "wss://signaling.music-assistant.io/ws" +} +``` + +## Request Flow + +### HTTP Request Flow + +``` +HTTP Request → Webserver → Auth Middleware → Command Handler → Response + | + ├─ Ingress? → Auto-authenticate with HA headers + └─ Regular? → Validate Bearer token +``` + +### WebSocket Request Flow + +``` +WebSocket Connect → WebsocketClientHandler + | + ├─ First command: auth → Validate token → Set user context + └─ Subsequent commands → Check auth/role → Execute → Respond +``` + +### Remote WebRTC Request Flow + +``` +Remote Client → WebRTC Data Channel → Gateway → Local WebSocket API + | + └─ Message forwarding (bidirectional) +``` + +## Security Considerations + +### Authentication + +- **Mandatory authentication**: All API access requires authentication (except Ingress) +- **Secure token generation**: Uses `secrets.token_urlsafe(48)` for cryptographically secure tokens +- **Password hashing**: bcrypt with user-specific salts +- **Rate limiting**: Progressive delays on failed login attempts +- **Token expiration**: Both short-lived (30 days sliding) and long-lived (10 years) tokens supported + +### Authorization + +- **Role-based access**: Admin vs User roles +- **Command-level enforcement**: API commands can require specific roles +- **Player/Provider filtering**: Users can be restricted to specific players/providers +- **Token revocation**: Immediate WebSocket disconnect on token revocation + +### Network Security + +**Local Network:** +- Webserver is unencrypted (HTTP) by design (runs on local network) +- Users should use reverse proxy or VPN for external access +- Never expose webserver directly to internet + +**Remote Access:** +- End-to-end encryption via WebRTC (DTLS/SRTP) +- Authentication required (same as local access) +- Signaling server only routes encrypted signaling messages +- Cannot decrypt or inspect user data + +### Data Protection + +- **Token storage**: Only hashed tokens stored in database +- **Password storage**: bcrypt with user-specific salts +- **Session cleanup**: Expired tokens automatically deleted +- **User disable**: Immediate disconnect of all user sessions + +## Development Guide + +### Adding New Authentication Providers + +1. Create provider class inheriting from `LoginProvider` in [helpers/auth_providers.py](helpers/auth_providers.py) +2. Implement required methods: `authenticate()`, `get_authorization_url()` (if OAuth), `handle_oauth_callback()` (if OAuth) +3. Register provider in `AuthenticationManager._setup_login_providers()` +4. Add provider configuration to webserver config entries if needed + +### Adding New API Endpoints + +1. Define route handler in [controller.py](controller.py) (for HTTP endpoints) +2. Use `@api_command()` decorator for WebSocket commands (in respective controllers) +3. Specify authentication requirements: `authenticated=True` or `required_role="admin"` + +### Testing Authentication + +1. **Local Testing**: Use `/setup` to create admin user, then `/auth/login` to get token +2. **HTTP API Testing**: Use curl with `Authorization: Bearer ` header +3. **WebSocket Testing**: Connect to `/ws` and send auth command with token +4. **Role Testing**: Create users with different roles and test access restrictions + +### Common Patterns + +**Getting current user in command handler:** +```python +from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_user + +@api_command("my_command") +async def my_command(): + user = get_current_user() + if not user: + raise AuthenticationRequired("Not authenticated") + # ... use user ... +``` + +**Getting current token (for revocation):** +```python +from music_assistant.controllers.webserver.helpers.auth_middleware import get_current_token + +@api_command("my_command") +async def my_command(): + token = get_current_token() + # ... use token ... +``` + +**Requiring admin role:** +```python +@api_command("admin_only_command", required_role="admin") +async def admin_command(): + # Only admins can call this + pass +``` + +### Database Migrations + +When modifying the auth database schema: +1. Increment `DB_SCHEMA_VERSION` in [auth.py](auth.py) +2. Add migration logic to `_migrate_database()` method +3. Test migration from previous version +4. Consider backwards compatibility + +### Testing Remote Access + +1. **Enable HA Cloud**: Configure Home Assistant provider with Cloud subscription +2. **Verify Remote ID**: Check webserver config for generated Remote ID +3. **Test Gateway**: Check logs for "WebRTC Remote Access enabled" message +4. **Test Connection**: Use PWA with Remote ID to connect externally +5. **Monitor Sessions**: Check `remote_access/info` command for status + +## File Structure + +``` +webserver/ +├── __init__.py # Module exports +├── controller.py # Main webserver controller +├── auth.py # Authentication manager +├── websocket_client.py # WebSocket client handler +├── api_docs.py # API documentation generator +├── README.md # This file +├── helpers/ +│ ├── auth_middleware.py # HTTP auth middleware +│ └── auth_providers.py # Authentication providers +└── remote_access/ + ├── __init__.py # Remote access manager + └── gateway.py # WebRTC gateway implementation +``` + +## Additional Resources + +- [API Documentation](http://localhost:8095/api-docs) - Auto-generated API docs +- [Commands Reference](http://localhost:8095/api-docs/commands) - List of all API commands +- [Schemas Reference](http://localhost:8095/api-docs/schemas) - Data model documentation +- [Swagger UI](http://localhost:8095/api-docs/swagger) - Interactive API explorer + +## Contributing + +When contributing to the webserver/auth system: +1. Follow the existing patterns for consistency +2. Add comprehensive docstrings with Sphinx-style parameter documentation +3. Update this README if adding significant new features +4. Test authentication flows thoroughly +5. Consider security implications of all changes +6. Update API documentation if adding new commands diff --git a/music_assistant/controllers/webserver/auth.py b/music_assistant/controllers/webserver/auth.py index a386a80f..900b8ea7 100644 --- a/music_assistant/controllers/webserver/auth.py +++ b/music_assistant/controllers/webserver/auth.py @@ -73,6 +73,8 @@ class AuthenticationManager: self.database: DatabaseConnection = None # type: ignore[assignment] self.login_providers: dict[str, LoginProvider] = {} self.logger = LOGGER + # Pending OAuth sessions for remote clients (session_id -> token) + self._pending_oauth_sessions: dict[str, str | None] = {} async def setup(self) -> None: """Initialize the authentication manager.""" @@ -894,6 +896,139 @@ class AuthenticationManager: ) return providers + @api_command("auth/login", authenticated=False) + async def login( + self, + username: str | None = None, + password: str | None = None, + provider_id: str = "builtin", + **extra_credentials: Any, + ) -> dict[str, Any]: + """Authenticate user with credentials via WebSocket. + + This command allows clients to authenticate over the WebSocket connection + using username/password or other provider-specific credentials. + + :param username: Username for authentication (for builtin provider). + :param password: Password for authentication (for builtin provider). + :param provider_id: The login provider ID (defaults to "builtin"). + :param extra_credentials: Additional provider-specific credentials. + :return: Authentication result with access token if successful. + """ + # Build credentials dict from parameters + credentials: dict[str, Any] = {} + if username is not None: + credentials["username"] = username + if password is not None: + credentials["password"] = password + credentials.update(extra_credentials) + + auth_result = await self.authenticate_with_credentials(provider_id, credentials) + + if not auth_result.success: + return { + "success": False, + "error": auth_result.error or "Authentication failed", + } + + if not auth_result.user: + return { + "success": False, + "error": "Authentication failed: no user returned", + } + + # Create short-lived access token + token = await self.create_token( + auth_result.user, + is_long_lived=False, + name=f"WebSocket Session - {auth_result.user.username}", + ) + + return { + "success": True, + "access_token": token, + "user": { + "user_id": auth_result.user.user_id, + "username": auth_result.user.username, + "display_name": auth_result.user.display_name, + "role": auth_result.user.role.value, + }, + } + + @api_command("auth/providers", authenticated=False) + async def get_providers(self) -> list[dict[str, Any]]: + """Get list of available authentication providers. + + Returns information about all available login providers including + whether they require OAuth redirect flow. + """ + return await self.get_login_providers() + + @api_command("auth/authorization_url", authenticated=False) + async def get_auth_url( + self, provider_id: str, for_remote_client: bool = False + ) -> dict[str, str | None]: + """Get OAuth authorization URL for remote authentication. + + For OAuth providers (like Home Assistant), this returns the URL that + the user should visit in their browser to authorize the application. + + :param provider_id: The provider ID (e.g., "hass"). + :param for_remote_client: If True, creates a pending session for remote OAuth flow. + :return: Dictionary with authorization_url and session_id (if remote). + """ + # Generate session ID for remote clients + session_id = None + return_url = None + + if for_remote_client: + session_id = secrets.token_urlsafe(32) + # Mark session as pending + self._pending_oauth_sessions[session_id] = None + # Use special return URL that will capture the token + return_url = f"urn:ietf:wg:oauth:2.0:oob:auto:{session_id}" + + auth_url = await self.get_authorization_url(provider_id, return_url) + if not auth_url: + return { + "authorization_url": None, + "error": "Provider does not support OAuth or does not exist", + } + + return { + "authorization_url": auth_url, + "session_id": session_id, # Only set for remote clients + } + + @api_command("auth/oauth_status", authenticated=False) + async def check_oauth_status(self, session_id: str) -> dict[str, Any]: + """Check status of pending OAuth authentication. + + Remote clients use this to poll for completion of the OAuth flow. + + :param session_id: The session ID from get_auth_url. + :return: Status and token if authentication completed. + """ + if session_id not in self._pending_oauth_sessions: + return { + "status": "invalid", + "error": "Invalid or expired session ID", + } + + token = self._pending_oauth_sessions.get(session_id) + if token is None: + return { + "status": "pending", + "message": "Waiting for user to complete authentication", + } + + # Authentication completed, return token and clean up + del self._pending_oauth_sessions[session_id] + return { + "status": "completed", + "access_token": token, + } + async def get_authorization_url( self, provider_id: str, return_url: str | None = None ) -> str | None: diff --git a/music_assistant/controllers/webserver/controller.py b/music_assistant/controllers/webserver/controller.py index 03d8386d..b595dd0c 100644 --- a/music_assistant/controllers/webserver/controller.py +++ b/music_assistant/controllers/webserver/controller.py @@ -52,6 +52,7 @@ from .helpers.auth_middleware import ( set_current_user, ) from .helpers.auth_providers import BuiltinLoginProvider +from .remote_access import RemoteAccessManager from .websocket_client import WebsocketClientHandler if TYPE_CHECKING: @@ -84,6 +85,7 @@ class WebserverController(CoreController): ) self.manifest.icon = "web-box" self.auth = AuthenticationManager(self) + self.remote_access = RemoteAccessManager(self) @property def base_url(self) -> str: @@ -103,7 +105,7 @@ class WebserverController(CoreController): ConfigEntry( key="webserver_warn", type=ConfigEntryType.ALERT, - label="Please note that the webserver is unprotected. " + 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, @@ -149,6 +151,15 @@ class WebserverController(CoreController): 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, + ), ) async def setup(self, config: CoreConfig) -> None: # noqa: PLR0915 @@ -164,10 +175,9 @@ class WebserverController(CoreController): filepath = os.path.join(frontend_dir, filename) handler = partial(self._server.serve_static, filepath) routes.append(("GET", f"/{filename}", handler)) - # add index - index_path = os.path.join(frontend_dir, "index.html") - handler = partial(self._server.serve_static, index_path) - routes.append(("GET", "/", handler)) + # add index (with onboarding check) + self._index_path = os.path.join(frontend_dir, "index.html") + routes.append(("GET", "/", self._handle_index)) # add logo logo_path = str(RESOURCES_DIR.joinpath("logo.png")) handler = partial(self._server.serve_static, logo_path) @@ -216,6 +226,8 @@ class WebserverController(CoreController): routes.append(("POST", "/setup", self._handle_setup)) # Initialize authentication manager await self.auth.setup() + # Initialize remote access manager + await self.remote_access.setup() # start the webserver all_ip_addresses = await get_ip_addresses() default_publish_ip = all_ip_addresses[0] @@ -275,6 +287,7 @@ class WebserverController(CoreController): await client.disconnect() await self._server.close() await self.auth.close() + await self.remote_access.close() def register_websocket_client(self, client: WebsocketClientHandler) -> None: """Register a WebSocket client for tracking.""" @@ -492,8 +505,25 @@ class WebserverController(CoreController): swagger_html_path = str(RESOURCES_DIR.joinpath("swagger_ui.html")) return await self._server.serve_static(swagger_html_path, request) + async def _handle_index(self, request: web.Request) -> web.StreamResponse: + """Handle request for index page with onboarding check.""" + # If not yet onboarded, redirect to setup + if not self.mass.config.onboard_done or not await self.auth.has_users(): + # Preserve return_url parameter if present (will be passed back after setup) + # The setup page will add onboard=true when redirecting back + return_url = request.query.get("return_url") + if return_url: + setup_url = f"/setup?return_url={urllib.parse.quote(return_url, safe='')}" + else: + # Default: redirect back to root (index) after setup with onboard=true + setup_url = f"/setup?return_url={urllib.parse.quote('/', safe='')}" + return web.Response(status=302, headers={"Location": setup_url}) + + # Serve the Vue frontend index.html + return await self._server.serve_static(self._index_path, request) + async def _handle_login_page(self, request: web.Request) -> web.Response: - """Handle request for login page.""" + """Handle request for login page (external client OAuth callback scenario).""" # If not yet onboarded, redirect to setup if not self.mass.config.onboard_done or not await self.auth.has_users(): return_url = request.query.get("return_url", "") @@ -505,42 +535,7 @@ class WebserverController(CoreController): ) return web.Response(status=302, headers={"Location": setup_url}) - # Check if this is an ingress request - if so, auto-authenticate and redirect with token - if is_request_from_ingress(request): - ingress_user_id = request.headers.get("X-Remote-User-ID") - ingress_username = request.headers.get("X-Remote-User-Name") - - if ingress_user_id and ingress_username: - # Try to find existing user linked to this HA user ID - user = await self.auth.get_user_by_provider_link( - AuthProviderType.HOME_ASSISTANT, ingress_user_id - ) - - if user: - # User exists, create token and redirect - device_name = request.query.get( - "device_name", f"Home Assistant Ingress ({ingress_username})" - ) - token = await self.auth.create_token(user, device_name) - - return_url = request.query.get("return_url", "/") - - # Insert code parameter before any hash fragment - code_param = f"code={quote(token, safe='')}" - if "#" in return_url: - url_parts = return_url.split("#", 1) - base_part = url_parts[0] - hash_part = url_parts[1] - separator = "&" if "?" in base_part else "?" - redirect_url = f"{base_part}{separator}{code_param}#{hash_part}" - elif "?" in return_url: - redirect_url = f"{return_url}&{code_param}" - else: - redirect_url = f"{return_url}?{code_param}" - - return web.Response(status=302, headers={"Location": redirect_url}) - - # Not ingress or user doesn't exist - serve login page + # Serve login page for external clients login_html_path = str(RESOURCES_DIR.joinpath("login.html")) async with aiofiles.open(login_html_path) as f: html_content = await f.read() @@ -753,6 +748,29 @@ class WebserverController(CoreController): device_name = f"OAuth ({provider_id})" token = await self.auth.create_token(auth_result.user, device_name) + # Check if this is a remote client OAuth flow + if auth_result.return_url and auth_result.return_url.startswith( + "urn:ietf:wg:oauth:2.0:oob:auto:" + ): + # Extract session ID from return URL + session_id = auth_result.return_url.split(":")[-1] + # Store token in pending sessions + if session_id in self.auth._pending_oauth_sessions: + self.auth._pending_oauth_sessions[session_id] = token + # Show success page for remote auth + success_html = """ + + Authentication Successful + +

✓ Authentication Successful

+

You have successfully authenticated with Music Assistant.

+

You can now close this window and return to your application.

+ + + """ + return web.Response(text=success_html, content_type="text/html") + # Determine redirect URL (use return_url from OAuth flow or default to root) final_redirect_url = auth_result.return_url or "/" requires_consent = False @@ -902,7 +920,6 @@ class WebserverController(CoreController): user = await self.auth.get_user_by_provider_link( AuthProviderType.HOME_ASSISTANT, ha_user_id ) - if user: # User already exists (auto-created from Ingress), update and add password updates = {} diff --git a/music_assistant/controllers/webserver/remote_access/__init__.py b/music_assistant/controllers/webserver/remote_access/__init__.py new file mode 100644 index 00000000..5948906e --- /dev/null +++ b/music_assistant/controllers/webserver/remote_access/__init__.py @@ -0,0 +1,254 @@ +"""Remote Access manager for Music Assistant webserver.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from music_assistant_models.enums import EventType + +from music_assistant.constants import MASS_LOGGER_NAME +from music_assistant.controllers.webserver.remote_access.gateway import ( + WebRTCGateway, + generate_remote_id, +) +from music_assistant.helpers.api import api_command + +if TYPE_CHECKING: + from music_assistant_models.event import MassEvent + + from music_assistant.controllers.webserver import WebserverController + +LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.remote_access") + +# Signaling server URL +SIGNALING_SERVER_URL = "wss://signaling.music-assistant.io/ws" + +# Internal config key for storing the remote ID +_CONF_REMOTE_ID = "remote_id" + + +class RemoteAccessManager: + """Manager for WebRTC-based remote access (part of webserver controller).""" + + def __init__(self, webserver: WebserverController) -> None: + """Initialize the remote access manager. + + :param webserver: WebserverController instance. + """ + self.webserver = webserver + self.mass = webserver.mass + self.logger = LOGGER + self.gateway: WebRTCGateway | None = None + self._remote_id: str | None = None + self._setup_done = False + + async def setup(self) -> None: + """Initialize the remote access manager.""" + self.logger.debug("RemoteAccessManager.setup() called") + + # Register API commands immediately + self._register_api_commands() + + # Subscribe to provider updates to check for Home Assistant Cloud + self.mass.subscribe(self._on_providers_updated, EventType.PROVIDERS_UPDATED) + + # Try initial setup (providers might already be loaded) + await self._try_enable_remote_access() + + async def close(self) -> None: + """Cleanup on exit.""" + if self.gateway: + await self.gateway.stop() + self.gateway = None + self.logger.info("WebRTC Remote Access stopped") + + def _on_remote_id_ready(self, remote_id: str) -> None: + """Handle Remote ID registration with signaling server. + + :param remote_id: The registered Remote ID. + """ + self.logger.info("Remote ID registered with signaling server: %s", remote_id) + self._remote_id = remote_id + + def _on_providers_updated(self, event: MassEvent) -> None: + """Handle providers updated event. + + :param event: The providers updated event. + """ + if self._setup_done: + # Already set up, no need to check again + return + # Try to enable remote access when providers are updated + self.mass.create_task(self._try_enable_remote_access()) + + async def _try_enable_remote_access(self) -> None: + """Try to enable remote access if Home Assistant Cloud is available.""" + if self._setup_done: + # Already set up + return + + # Check if Home Assistant Cloud is available and active + cloud_status = await self._check_ha_cloud_status() + if not cloud_status: + self.logger.debug("Home Assistant Cloud not available yet") + return + + # Mark as done to prevent multiple attempts + self._setup_done = True + self.logger.info("Home Assistant Cloud subscription detected, enabling remote access") + + # Get or generate Remote ID + remote_id_value = self.webserver.config.get_value(_CONF_REMOTE_ID) + if not remote_id_value: + # Generate new Remote ID and save it + remote_id_value = generate_remote_id() + # Save the Remote ID to config + self.mass.config.set_raw_core_config_value( + "webserver", _CONF_REMOTE_ID, remote_id_value + ) + self.mass.config.save(immediate=True) + self.logger.info("Generated new Remote ID: %s", remote_id_value) + + # Ensure remote_id is a string + if isinstance(remote_id_value, str): + self._remote_id = remote_id_value + else: + self.logger.error("Invalid remote_id type: %s", type(remote_id_value)) + return + + # Determine local WebSocket URL + bind_port_value = self.webserver.config.get_value("bind_port", 8095) + bind_port = int(bind_port_value) if isinstance(bind_port_value, int) else 8095 + local_ws_url = f"ws://localhost:{bind_port}/ws" + + # Get ICE servers from HA Cloud if available + ice_servers = await self._get_ha_cloud_ice_servers() + + # Initialize and start the WebRTC gateway + self.gateway = WebRTCGateway( + signaling_url=SIGNALING_SERVER_URL, + local_ws_url=local_ws_url, + remote_id=self._remote_id, + on_remote_id_ready=self._on_remote_id_ready, + ice_servers=ice_servers, + ) + + await self.gateway.start() + self.logger.info("WebRTC Remote Access enabled - Remote ID: %s", self._remote_id) + + async def _check_ha_cloud_status(self) -> bool: + """Check if Home Assistant Cloud subscription is active. + + :return: True if HA Cloud is logged in and has active subscription. + """ + # Find the Home Assistant provider + ha_provider = None + for provider in self.mass.providers: + if provider.domain == "hass" and provider.available: + ha_provider = provider + break + + if not ha_provider: + self.logger.debug("Home Assistant provider not found or not available") + return False + + try: + # Access the hass client from the provider + if not hasattr(ha_provider, "hass"): + self.logger.debug("Provider does not have hass attribute") + return False + hass_client = ha_provider.hass # type: ignore[union-attr] + if not hass_client or not hass_client.connected: + self.logger.debug("Home Assistant client not connected") + return False + + # Call cloud/status command to check subscription + result = await hass_client.send_command("cloud/status") + + # Check for logged_in and active_subscription + logged_in = result.get("logged_in", False) + active_subscription = result.get("active_subscription", False) + + if logged_in and active_subscription: + self.logger.info("Home Assistant Cloud subscription is active") + return True + + self.logger.info( + "Home Assistant Cloud not active (logged_in=%s, active_subscription=%s)", + logged_in, + active_subscription, + ) + return False + + except Exception: + self.logger.exception("Error checking Home Assistant Cloud status") + return False + + async def _get_ha_cloud_ice_servers(self) -> list[dict[str, str]] | None: + """Get ICE servers from Home Assistant Cloud. + + :return: List of ICE server configurations or None if unavailable. + """ + # Find the Home Assistant provider + ha_provider = None + for provider in self.mass.providers: + if provider.domain == "hass" and provider.available: + ha_provider = provider + break + + if not ha_provider: + return None + + try: + # Access the hass client from the provider + if not hasattr(ha_provider, "hass"): + return None + hass_client = ha_provider.hass # type: ignore[union-attr] + if not hass_client or not hass_client.connected: + return None + + # Try to get ICE servers from HA Cloud + # This might be available via a cloud API endpoint + # For now, return None and use default STUN servers + # TODO: Research if HA Cloud exposes ICE/TURN server endpoints + self.logger.debug( + "Using default STUN servers (HA Cloud ICE servers not yet implemented)" + ) + return None + + except Exception: + self.logger.exception("Error getting Home Assistant Cloud ICE servers") + return None + + @property + def is_enabled(self) -> bool: + """Return whether WebRTC remote access is enabled.""" + return self.gateway is not None and self.gateway.is_running + + @property + def is_connected(self) -> bool: + """Return whether the gateway is connected to the signaling server.""" + return self.gateway is not None and self.gateway.is_connected + + @property + def remote_id(self) -> str | None: + """Return the current Remote ID.""" + return self._remote_id + + def _register_api_commands(self) -> None: + """Register API commands for remote access.""" + + @api_command("remote_access/info") + def get_remote_access_info() -> dict[str, str | bool]: + """Get remote access information. + + Returns information about the remote access configuration including + whether it's enabled, connected status, and the Remote ID for connecting. + """ + return { + "enabled": self.is_enabled, + "connected": self.is_connected, + "remote_id": self._remote_id or "", + "signaling_url": SIGNALING_SERVER_URL, + } diff --git a/music_assistant/controllers/webserver/remote_access/gateway.py b/music_assistant/controllers/webserver/remote_access/gateway.py new file mode 100644 index 00000000..fe516fef --- /dev/null +++ b/music_assistant/controllers/webserver/remote_access/gateway.py @@ -0,0 +1,535 @@ +"""Music Assistant WebRTC Gateway. + +This module provides WebRTC-based remote access to Music Assistant instances. +It connects to a signaling server and handles incoming WebRTC connections, +bridging them to the local WebSocket API. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import secrets +import string +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import aiohttp +from aiortc import ( + RTCConfiguration, + RTCIceCandidate, + RTCIceServer, + RTCPeerConnection, + RTCSessionDescription, +) + +from music_assistant.constants import MASS_LOGGER_NAME + +if TYPE_CHECKING: + from collections.abc import Callable + +LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.remote_access") + +# Reduce verbose logging from aiortc/aioice +logging.getLogger("aioice").setLevel(logging.WARNING) +logging.getLogger("aiortc").setLevel(logging.WARNING) + + +def generate_remote_id() -> str: + """Generate a unique Remote ID in the format MA-XXXX-XXXX.""" + chars = string.ascii_uppercase + string.digits + part1 = "".join(secrets.choice(chars) for _ in range(4)) + part2 = "".join(secrets.choice(chars) for _ in range(4)) + return f"MA-{part1}-{part2}" + + +@dataclass +class WebRTCSession: + """Represents an active WebRTC session with a remote client.""" + + session_id: str + peer_connection: RTCPeerConnection + data_channel: Any = None + local_ws: Any = None + message_queue: asyncio.Queue[str] = field(default_factory=asyncio.Queue) + + +class WebRTCGateway: + """WebRTC Gateway for Music Assistant Remote Access. + + This gateway: + 1. Connects to a signaling server + 2. Registers with a unique Remote ID + 3. Handles incoming WebRTC connections from remote PWA clients + 4. Bridges WebRTC DataChannel messages to the local WebSocket API + """ + + def __init__( + self, + signaling_url: str = "wss://signaling.music-assistant.io/ws", + local_ws_url: str = "ws://localhost:8095/ws", + ice_servers: list[dict[str, Any]] | None = None, + remote_id: str | None = None, + on_remote_id_ready: Callable[[str], None] | None = None, + ) -> None: + """Initialize the WebRTC Gateway. + + :param signaling_url: WebSocket URL of the signaling server. + :param local_ws_url: Local WebSocket URL to bridge to. + :param ice_servers: List of ICE server configurations. + :param remote_id: Optional Remote ID to use (generated if not provided). + :param on_remote_id_ready: Callback when Remote ID is registered. + """ + self.signaling_url = signaling_url + self.local_ws_url = local_ws_url + self.remote_id = remote_id or generate_remote_id() + self.on_remote_id_ready = on_remote_id_ready + self.logger = LOGGER + + self.ice_servers = ice_servers or [ + {"urls": "stun:stun.l.google.com:19302"}, + {"urls": "stun:stun1.l.google.com:19302"}, + {"urls": "stun:stun.cloudflare.com:3478"}, + ] + + self.sessions: dict[str, WebRTCSession] = {} + self._signaling_ws: aiohttp.ClientWebSocketResponse | None = None + self._signaling_session: aiohttp.ClientSession | None = None + self._running = False + self._reconnect_delay = 5 + self._max_reconnect_delay = 60 + self._current_reconnect_delay = 5 + self._run_task: asyncio.Task[None] | None = None + self._is_connected = False + + @property + def is_running(self) -> bool: + """Return whether the gateway is running.""" + return self._running + + @property + def is_connected(self) -> bool: + """Return whether the gateway is connected to the signaling server.""" + return self._is_connected + + async def start(self) -> None: + """Start the WebRTC Gateway.""" + self.logger.info("Starting WebRTC Gateway with Remote ID: %s", self.remote_id) + self.logger.debug("Signaling URL: %s", self.signaling_url) + self.logger.debug("Local WS URL: %s", self.local_ws_url) + self._running = True + self._run_task = asyncio.create_task(self._run()) + self.logger.debug("WebRTC Gateway start task created") + + async def stop(self) -> None: + """Stop the WebRTC Gateway.""" + self.logger.info("Stopping WebRTC Gateway") + self._running = False + + # Close all sessions + for session_id in list(self.sessions.keys()): + await self._close_session(session_id) + + # Close signaling connection + if self._signaling_ws: + await self._signaling_ws.close() + if self._signaling_session: + await self._signaling_session.close() + + # Cancel run task + if self._run_task and not self._run_task.done(): + self._run_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._run_task + + async def _run(self) -> None: + """Run the main loop with reconnection logic.""" + self.logger.debug("WebRTC Gateway _run() loop starting") + while self._running: + try: + await self._connect_to_signaling() + # Connection closed gracefully or with error + self._is_connected = False + if self._running: + self.logger.warning( + "Signaling server connection lost. Reconnecting in %ss...", + self._current_reconnect_delay, + ) + except Exception: + self._is_connected = False + self.logger.exception("Signaling connection error") + if self._running: + self.logger.info( + "Reconnecting to signaling server in %ss", + self._current_reconnect_delay, + ) + + if self._running: + await asyncio.sleep(self._current_reconnect_delay) + # Exponential backoff with max limit + self._current_reconnect_delay = min( + self._current_reconnect_delay * 2, self._max_reconnect_delay + ) + + async def _connect_to_signaling(self) -> None: + """Connect to the signaling server.""" + self.logger.info("Connecting to signaling server: %s", self.signaling_url) + # Create session with increased timeout for WebSocket connection + timeout = aiohttp.ClientTimeout( + total=None, # No total timeout for WebSocket connections + connect=30, # 30 seconds to establish connection + sock_connect=30, # 30 seconds for socket connection + sock_read=None, # No timeout for reading (we handle ping/pong) + ) + self._signaling_session = aiohttp.ClientSession(timeout=timeout) + try: + self._signaling_ws = await self._signaling_session.ws_connect( + self.signaling_url, + heartbeat=45, # Send WebSocket ping every 45 seconds + autoping=True, # Automatically respond to pings + ) + await self._register() + self._is_connected = True + # Reset reconnect delay on successful connection + self._current_reconnect_delay = self._reconnect_delay + self.logger.info("Connected to signaling server") + + # Message loop + async for msg in self._signaling_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + try: + await self._handle_signaling_message(json.loads(msg.data)) + except Exception: + self.logger.exception("Error handling signaling message") + elif msg.type == aiohttp.WSMsgType.CLOSED: + self.logger.warning("Signaling server closed connection: %s", msg.extra) + break + elif msg.type == aiohttp.WSMsgType.ERROR: + self.logger.error("WebSocket error: %s", msg.extra) + break + + self.logger.info("Message loop exited normally") + except TimeoutError: + self.logger.error("Timeout connecting to signaling server") + except aiohttp.ClientError as err: + self.logger.error("Failed to connect to signaling server: %s", err) + finally: + self._is_connected = False + if self._signaling_session: + await self._signaling_session.close() + self._signaling_session = None + self._signaling_ws = None + + async def _register(self) -> None: + """Register with the signaling server.""" + if self._signaling_ws: + registration_msg = { + "type": "register-server", + "remoteId": self.remote_id, + } + self.logger.info( + "Sending registration to signaling server with Remote ID: %s", + self.remote_id, + ) + self.logger.debug("Registration message: %s", registration_msg) + await self._signaling_ws.send_json(registration_msg) + self.logger.debug("Registration message sent successfully") + else: + self.logger.warning("Cannot register: signaling websocket is not connected") + + async def _handle_signaling_message(self, message: dict[str, Any]) -> None: + """Handle incoming signaling messages. + + :param message: The signaling message. + """ + msg_type = message.get("type") + self.logger.debug("Received signaling message: %s - Full message: %s", msg_type, message) + + if msg_type == "ping": + # Respond to ping with pong + if self._signaling_ws: + await self._signaling_ws.send_json({"type": "pong"}) + elif msg_type == "pong": + # Server responded to our ping, connection is alive + pass + elif msg_type == "registered": + self.logger.info("Registered with signaling server as: %s", message.get("remoteId")) + if self.on_remote_id_ready: + self.on_remote_id_ready(self.remote_id) + elif msg_type == "error": + self.logger.error( + "Signaling server error: %s", + message.get("message", "Unknown error"), + ) + elif msg_type == "client-connected": + session_id = message.get("sessionId") + if session_id: + await self._create_session(session_id) + elif msg_type == "client-disconnected": + session_id = message.get("sessionId") + if session_id: + await self._close_session(session_id) + elif msg_type == "offer": + session_id = message.get("sessionId") + offer_data = message.get("data") + if session_id and offer_data: + await self._handle_offer(session_id, offer_data) + elif msg_type == "ice-candidate": + session_id = message.get("sessionId") + candidate_data = message.get("data") + if session_id and candidate_data: + await self._handle_ice_candidate(session_id, candidate_data) + + async def _create_session(self, session_id: str) -> None: + """Create a new WebRTC session. + + :param session_id: The session ID. + """ + config = RTCConfiguration( + iceServers=[RTCIceServer(**server) for server in self.ice_servers] + ) + pc = RTCPeerConnection(configuration=config) + session = WebRTCSession(session_id=session_id, peer_connection=pc) + self.sessions[session_id] = session + + @pc.on("datachannel") + def on_datachannel(channel: Any) -> None: + session.data_channel = channel + asyncio.create_task(self._setup_data_channel(session)) + + @pc.on("icecandidate") + async def on_icecandidate(candidate: Any) -> None: + if candidate and self._signaling_ws: + await self._signaling_ws.send_json( + { + "type": "ice-candidate", + "sessionId": session_id, + "data": { + "candidate": candidate.candidate, + "sdpMid": candidate.sdpMid, + "sdpMLineIndex": candidate.sdpMLineIndex, + }, + } + ) + + @pc.on("connectionstatechange") + async def on_connectionstatechange() -> None: + if pc.connectionState == "failed": + await self._close_session(session_id) + + async def _handle_offer(self, session_id: str, offer: dict[str, Any]) -> None: + """Handle incoming WebRTC offer. + + :param session_id: The session ID. + :param offer: The offer data. + """ + session = self.sessions.get(session_id) + if not session: + return + pc = session.peer_connection + sdp = offer.get("sdp") + sdp_type = offer.get("type") + if not sdp or not sdp_type: + self.logger.error("Invalid offer data: missing sdp or type") + return + await pc.setRemoteDescription( + RTCSessionDescription( + sdp=str(sdp), + type=str(sdp_type), + ) + ) + answer = await pc.createAnswer() + await pc.setLocalDescription(answer) + if self._signaling_ws: + await self._signaling_ws.send_json( + { + "type": "answer", + "sessionId": session_id, + "data": { + "sdp": pc.localDescription.sdp, + "type": pc.localDescription.type, + }, + } + ) + + async def _handle_ice_candidate(self, session_id: str, candidate: dict[str, Any]) -> None: + """Handle incoming ICE candidate. + + :param session_id: The session ID. + :param candidate: The ICE candidate data. + """ + session = self.sessions.get(session_id) + if not session or not candidate: + return + + candidate_str = candidate.get("candidate") + sdp_mid = candidate.get("sdpMid") + sdp_mline_index = candidate.get("sdpMLineIndex") + + if not candidate_str: + return + + # Create RTCIceCandidate from the SDP string + try: + ice_candidate = RTCIceCandidate( + component=1, + foundation="", + ip="", + port=0, + priority=0, + protocol="udp", + type="host", + sdpMid=str(sdp_mid) if sdp_mid else None, + sdpMLineIndex=int(sdp_mline_index) if sdp_mline_index is not None else None, + ) + # Parse the candidate string to populate the fields + ice_candidate.candidate = str(candidate_str) # type: ignore[attr-defined] + await session.peer_connection.addIceCandidate(ice_candidate) + except Exception: + self.logger.exception("Failed to add ICE candidate: %s", candidate) + + async def _setup_data_channel(self, session: WebRTCSession) -> None: + """Set up data channel and bridge to local WebSocket. + + :param session: The WebRTC session. + """ + channel = session.data_channel + if not channel: + return + local_session = aiohttp.ClientSession() + try: + session.local_ws = await local_session.ws_connect(self.local_ws_url) + loop = asyncio.get_event_loop() + asyncio.create_task(self._forward_to_local(session)) + asyncio.create_task(self._forward_from_local(session, local_session)) + + @channel.on("message") # type: ignore[misc] + def on_message(message: str) -> None: + # Called from aiortc thread, use call_soon_threadsafe + loop.call_soon_threadsafe(session.message_queue.put_nowait, message) + + @channel.on("close") # type: ignore[misc] + def on_close() -> None: + # Called from aiortc thread, use call_soon_threadsafe to schedule task + asyncio.run_coroutine_threadsafe(self._close_session(session.session_id), loop) + + except Exception: + self.logger.exception("Failed to connect to local WebSocket") + await local_session.close() + + async def _forward_to_local(self, session: WebRTCSession) -> None: + """Forward messages from WebRTC DataChannel to local WebSocket. + + :param session: The WebRTC session. + """ + try: + while session.local_ws and not session.local_ws.closed: + message = await session.message_queue.get() + + # Check if this is an HTTP proxy request + try: + msg_data = json.loads(message) + if isinstance(msg_data, dict) and msg_data.get("type") == "http-proxy-request": + # Handle HTTP proxy request + await self._handle_http_proxy_request(session, msg_data) + continue + except (json.JSONDecodeError, ValueError): + pass + + # Regular WebSocket message + if session.local_ws and not session.local_ws.closed: + await session.local_ws.send_str(message) + except Exception: + self.logger.exception("Error forwarding to local WebSocket") + + async def _forward_from_local( + self, session: WebRTCSession, local_session: aiohttp.ClientSession + ) -> None: + """Forward messages from local WebSocket to WebRTC DataChannel. + + :param session: The WebRTC session. + :param local_session: The aiohttp client session. + """ + try: + async for msg in session.local_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + if session.data_channel and session.data_channel.readyState == "open": + session.data_channel.send(msg.data) + elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED): + break + except Exception: + self.logger.exception("Error forwarding from local WebSocket") + finally: + await local_session.close() + + async def _handle_http_proxy_request( + self, session: WebRTCSession, request_data: dict[str, Any] + ) -> None: + """Handle HTTP proxy request from remote client. + + :param session: The WebRTC session. + :param request_data: The HTTP proxy request data. + """ + request_id = request_data.get("id") + method = request_data.get("method", "GET") + path = request_data.get("path", "/") + headers = request_data.get("headers", {}) + + # Build local HTTP URL + # Extract host and port from local_ws_url (ws://localhost:8095/ws) + ws_url_parts = self.local_ws_url.replace("ws://", "").split("/") + host_port = ws_url_parts[0] # localhost:8095 + local_http_url = f"http://{host_port}{path}" + + self.logger.debug("HTTP proxy request: %s %s", method, local_http_url) + + try: + # Create a new HTTP client session for this request + async with ( + aiohttp.ClientSession() as http_session, + http_session.request(method, local_http_url, headers=headers) as response, + ): + # Read response body + body = await response.read() + + # Prepare response data + response_data = { + "type": "http-proxy-response", + "id": request_id, + "status": response.status, + "headers": dict(response.headers), + "body": body.hex(), # Send as hex string to avoid encoding issues + } + + # Send response back through data channel + if session.data_channel and session.data_channel.readyState == "open": + session.data_channel.send(json.dumps(response_data)) + + except Exception as err: + self.logger.exception("Error handling HTTP proxy request") + # Send error response + error_response = { + "type": "http-proxy-response", + "id": request_id, + "status": 500, + "headers": {"Content-Type": "text/plain"}, + "body": str(err).encode().hex(), + } + if session.data_channel and session.data_channel.readyState == "open": + session.data_channel.send(json.dumps(error_response)) + + async def _close_session(self, session_id: str) -> None: + """Close a WebRTC session. + + :param session_id: The session ID. + """ + session = self.sessions.pop(session_id, None) + if not session: + return + if session.local_ws and not session.local_ws.closed: + await session.local_ws.close() + if session.data_channel: + session.data_channel.close() + await session.peer_connection.close() diff --git a/music_assistant/helpers/resources/api_docs.html b/music_assistant/helpers/resources/api_docs.html index af5dc3f4..fd4cb2da 100644 --- a/music_assistant/helpers/resources/api_docs.html +++ b/music_assistant/helpers/resources/api_docs.html @@ -789,6 +789,78 @@ curl -X POST {BASE_URL}/api \

See the Commands Reference for detailed documentation of all auth commands.

+ +
+

Remote Access (WebRTC)

+

+ Music Assistant supports remote access via WebRTC, enabling you to connect to your + Music Assistant instance from anywhere without port forwarding or VPN configuration. +

+ +

How It Works

+

+ Remote access uses WebRTC technology to establish a secure, peer-to-peer connection between + your remote client (PWA) and your local Music Assistant server: +

+
    +
  • WebRTC Data Channel: Establishes encrypted connection through NAT/firewalls
  • +
  • Signaling Server: Cloud-based server coordinates connection setup
  • +
  • Remote ID: Unique identifier (format: MA-XXXX-XXXX) to connect to your instance
  • +
  • API Bridge: Remote commands work identically to local WebSocket API
  • +
+ +

Getting Your Remote ID

+

Use the remote_access/info WebSocket command to get your Remote ID and connection status:

+
+# Get remote access information +{ + "message_id": "remote-123", + "command": "remote_access/info", + "args": {} +} + +# Response +{ + "message_id": "remote-123", + "result": { + "enabled": true, + "connected": true, + "remote_id": "MA-K7G3-P2M4", + "signaling_url": "wss://signaling.music-assistant.io/ws" + } +} +
+ +

Connecting Remotely

+

To connect from outside your local network:

+
    +
  1. Get your Remote ID from the remote_access/info command
  2. +
  3. Open the Music Assistant PWA from any device (https://app.music-assistant.io)
  4. +
  5. Enter your Remote ID when prompted
  6. +
  7. Authenticate with your username and password (or OAuth)
  8. +
  9. Full API access over encrypted WebRTC connection
  10. +
+ +
+ Availability Notice: Remote access is currently only available to users who: +
    +
  • Have the Home Assistant integration configured
  • +
  • Possess an active Home Assistant Cloud subscription
  • +
+ This limitation exists because remote access relies on cloud-based STUN/TURN servers provided by Home Assistant Cloud + to establish WebRTC connections through NAT/firewalls. These servers are expensive to host and are made available + as part of the Home Assistant Cloud service. +
+ +

Security

+

Remote access maintains the same security standards as local access:

+
    +
  • End-to-end encryption: All data encrypted via WebRTC (DTLS/SRTP)
  • +
  • Authentication required: Same login and token system as local access
  • +
  • No data inspection: Signaling server cannot decrypt your commands or data
  • +
  • Role-based access: Admin and user permissions work identically
  • +
+

Music Assistant

-

Sign in to continue

+

External Client Authentication

@@ -91,20 +86,6 @@ const returnUrl = urlParams.get('return_url'); const deviceName = urlParams.get('device_name'); - // Validate URL to prevent XSS attacks - // Note: Server-side validation is the primary security layer - function isValidRedirectUrl(url) { - if (!url) return false; - try { - const parsed = new URL(url, window.location.origin); - // Allow http, https, and custom mobile app schemes - const allowedProtocols = ['http:', 'https:', 'musicassistant:']; - return allowedProtocols.includes(parsed.protocol); - } catch { - return false; - } - } - // Show error message function showError(message) { const errorEl = document.getElementById('error'); @@ -223,7 +204,6 @@ // Initiate OAuth flow async function initiateOAuth(providerId) { try { - // Include provider_id in callback URL let authorizeUrl = `${API_BASE}/auth/authorize?provider_id=${providerId}`; // Pass return_url to authorize endpoint if present @@ -235,20 +215,8 @@ const data = await response.json(); if (data.authorization_url) { - // Open OAuth flow in popup window - const width = 600; - const height = 700; - const left = (screen.width - width) / 2; - const top = (screen.height - height) / 2; - const popup = window.open( - data.authorization_url, - 'oauth_popup', - `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes` - ); - - if (!popup) { - showError('Failed to open popup. Please allow popups for this site.'); - } + // Redirect directly to OAuth provider + window.location.href = data.authorization_url; } else { showError('Failed to initiate OAuth flow'); } @@ -257,28 +225,6 @@ } } - // Listen for OAuth success messages from popup window - window.addEventListener('message', (event) => { - // Verify message is from our origin - if (event.origin !== window.location.origin) { - return; - } - - // Check if it's an OAuth success message - if (event.data && event.data.type === 'oauth_success' && event.data.token) { - // Store token in localStorage - localStorage.setItem('auth_token', event.data.token); - - // Redirect to the URL from OAuth callback - const redirectUrl = event.data.redirectUrl || `/?token=${encodeURIComponent(event.data.token)}`; - if (isValidRedirectUrl(redirectUrl)) { - window.location.href = redirectUrl; - } else { - window.location.href = `/?token=${encodeURIComponent(event.data.token)}`; - } - } - }); - // Clear error on input document.querySelectorAll('input').forEach(input => { input.addEventListener('input', hideError); diff --git a/music_assistant/helpers/resources/oauth_callback.html b/music_assistant/helpers/resources/oauth_callback.html index 0b6f5c5e..88933578 100644 --- a/music_assistant/helpers/resources/oauth_callback.html +++ b/music_assistant/helpers/resources/oauth_callback.html @@ -196,17 +196,25 @@ token: token, redirectUrl: redirectUrl }, window.location.origin); + console.log('Successfully sent postMessage to opener'); } catch (e) { console.error('Failed to send postMessage:', e); } } + // Modern browsers allow window.close() for windows opened via window.open() + // Try to close immediately after sending the message setTimeout(() => { + // First try to close the window window.close(); + + // Schedule a check to see if close was successful + // If the window is still open after 300ms, it means close() was blocked setTimeout(() => { - messageEl.textContent = 'Please close this window manually and return to the login page.'; - }, 500); - }, 1000); + // This will only execute if the window wasn't closed + messageEl.textContent = 'Authentication successful! You can close this window.'; + }, 300); + }, 100); } else { // Same window mode - redirect directly localStorage.setItem('auth_token', token); diff --git a/pyproject.toml b/pyproject.toml index 32ec6cd6..ed1f4d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "librosa==0.11.0", "gql[all]==4.0.0", "aiovban>=0.6.3", + "aiortc>=1.6.0", ] description = "Music Assistant" license = {text = "Apache-2.0"} diff --git a/requirements_all.txt b/requirements_all.txt index 09bd8692..2308cbaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,6 +10,7 @@ aiohttp-fast-zlib==0.3.0 aiojellyfin==0.14.1 aiomusiccast==0.15.0 aioresonate==0.13.1 +aiortc>=1.6.0 aiorun==2025.1.1 aioslimproto==3.1.1 aiosonos==0.1.9