--- /dev/null
+# 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 <token>` 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
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."""
)
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:
set_current_user,
)
from .helpers.auth_providers import BuiltinLoginProvider
+from .remote_access import RemoteAccessManager
from .websocket_client import WebsocketClientHandler
if TYPE_CHECKING:
)
self.manifest.icon = "web-box"
self.auth = AuthenticationManager(self)
+ self.remote_access = RemoteAccessManager(self)
@property
def base_url(self) -> str:
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,
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
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)
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]
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."""
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", "")
)
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()
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 = """
+ <html>
+ <head><title>Authentication Successful</title></head>
+ <body style="font-family: Arial, sans-serif; text-align: center;
+ padding: 50px;">
+ <h1 style="color: #4CAF50;">✓ Authentication Successful</h1>
+ <p>You have successfully authenticated with Music Assistant.</p>
+ <p>You can now close this window and return to your application.</p>
+ </body>
+ </html>
+ """
+ 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
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 = {}
--- /dev/null
+"""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,
+ }
--- /dev/null
+"""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()
</ul>
<p>See the <a href="{BASE_URL}/api-docs/commands#auth" style="color: var(--primary); text-decoration: none; font-weight: 500;">Commands Reference</a> for detailed documentation of all auth commands.</p>
</div>
+
+ <div class="section">
+ <h2>Remote Access (WebRTC)</h2>
+ <p>
+ Music Assistant supports <span class="highlight">remote access via WebRTC</span>, enabling you to connect to your
+ Music Assistant instance from anywhere without port forwarding or VPN configuration.
+ </p>
+
+ <h3>How It Works</h3>
+ <p>
+ Remote access uses WebRTC technology to establish a secure, peer-to-peer connection between
+ your remote client (PWA) and your local Music Assistant server:
+ </p>
+ <ul>
+ <li><strong>WebRTC Data Channel:</strong> Establishes encrypted connection through NAT/firewalls</li>
+ <li><strong>Signaling Server:</strong> Cloud-based server coordinates connection setup</li>
+ <li><strong>Remote ID:</strong> Unique identifier (format: MA-XXXX-XXXX) to connect to your instance</li>
+ <li><strong>API Bridge:</strong> Remote commands work identically to local WebSocket API</li>
+ </ul>
+
+ <h3>Getting Your Remote ID</h3>
+ <p>Use the <code>remote_access/info</code> WebSocket command to get your Remote ID and connection status:</p>
+ <div class="code-block">
+<span class="comment"># Get remote access information</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"remote-123"</span>,
+ <span class="string">"command"</span>: <span class="string">"remote_access/info"</span>,
+ <span class="string">"args"</span>: {}
+}
+
+<span class="comment"># Response</span>
+{
+ <span class="string">"message_id"</span>: <span class="string">"remote-123"</span>,
+ <span class="string">"result"</span>: {
+ <span class="string">"enabled"</span>: <span class="keyword">true</span>,
+ <span class="string">"connected"</span>: <span class="keyword">true</span>,
+ <span class="string">"remote_id"</span>: <span class="string">"MA-K7G3-P2M4"</span>,
+ <span class="string">"signaling_url"</span>: <span class="string">"wss://signaling.music-assistant.io/ws"</span>
+ }
+}
+ </div>
+
+ <h3>Connecting Remotely</h3>
+ <p>To connect from outside your local network:</p>
+ <ol style="margin-left: 24px; color: var(--text-secondary);">
+ <li>Get your Remote ID from the <code>remote_access/info</code> command</li>
+ <li>Open the Music Assistant PWA from any device (https://app.music-assistant.io)</li>
+ <li>Enter your Remote ID when prompted</li>
+ <li>Authenticate with your username and password (or OAuth)</li>
+ <li>Full API access over encrypted WebRTC connection</li>
+ </ol>
+
+ <div class="info-box">
+ <strong>Availability Notice:</strong> Remote access is currently only available to users who:
+ <ul style="margin-top: 12px;">
+ <li>Have the Home Assistant integration configured</li>
+ <li>Possess an active <a href="https://www.nabucasa.com/" style="color: var(--primary); text-decoration: none; font-weight: 500;">Home Assistant Cloud</a> subscription</li>
+ </ul>
+ 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.
+ </div>
+
+ <h3>Security</h3>
+ <p>Remote access maintains the same security standards as local access:</p>
+ <ul>
+ <li><strong>End-to-end encryption:</strong> All data encrypted via WebRTC (DTLS/SRTP)</li>
+ <li><strong>Authentication required:</strong> Same login and token system as local access</li>
+ <li><strong>No data inspection:</strong> Signaling server cannot decrypt your commands or data</li>
+ <li><strong>Role-based access:</strong> Admin and user permissions work identically</li>
+ </ul>
+ </div>
</div>
<div class="footer">
.oauth-providers {
margin-top: 8px;
}
-
- .provider-icon {
- width: 20px;
- height: 20px;
- }
</style>
</head>
<body>
</div>
<h1>Music Assistant</h1>
- <p class="subtitle">Sign in to continue</p>
+ <p class="subtitle">External Client Authentication</p>
<div id="error" class="error"></div>
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');
// 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
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');
}
}
}
- // 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);
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);
"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"}
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