Prepare remote connect feature (#2710)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Fri, 28 Nov 2025 21:08:05 +0000 (22:08 +0100)
committerGitHub <noreply@github.com>
Fri, 28 Nov 2025 21:08:05 +0000 (22:08 +0100)
music_assistant/controllers/webserver/README.md [new file with mode: 0644]
music_assistant/controllers/webserver/auth.py
music_assistant/controllers/webserver/controller.py
music_assistant/controllers/webserver/remote_access/__init__.py [new file with mode: 0644]
music_assistant/controllers/webserver/remote_access/gateway.py [new file with mode: 0644]
music_assistant/helpers/resources/api_docs.html
music_assistant/helpers/resources/login.html
music_assistant/helpers/resources/oauth_callback.html
pyproject.toml
requirements_all.txt

diff --git a/music_assistant/controllers/webserver/README.md b/music_assistant/controllers/webserver/README.md
new file mode 100644 (file)
index 0000000..fde5f1a
--- /dev/null
@@ -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 <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
index a386a80fbe5c371189ea6b09abc13aa50e9bdd55..900b8ea7862b79ca75e3c0d23b0ce6a053df5cec 100644 (file)
@@ -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:
index 03d8386d1f60e8f058a8167a3fc2654d12642708..b595dd0c991cc40145f8509cc5d8fb36d082270a 100644 (file)
@@ -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 = """
+                    <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
@@ -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 (file)
index 0000000..5948906
--- /dev/null
@@ -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 (file)
index 0000000..fe516fe
--- /dev/null
@@ -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()
index af5dc3f4587fad6605bce521a2cb959cd87860ef..fd4cb2da8bd0b0f72e683bf0169a91bc93e77703 100644 (file)
@@ -789,6 +789,78 @@ curl -X POST {BASE_URL}/api \
                 </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">
index eac1173108f80bf715cef44a944160080d74c2b7..bfc92d3ef9465886588c346be2ae27aed1f123e2 100644 (file)
         .oauth-providers {
             margin-top: 8px;
         }
-
-        .provider-icon {
-            width: 20px;
-            height: 20px;
-        }
     </style>
 </head>
 <body>
@@ -57,7 +52,7 @@
         </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);
index 0b6f5c5ef6a8a9a0080b4ea746d0a2a23048639f..88933578b52a69319591eed8e07c6f8c91d3bb75 100644 (file)
                             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);
index 32ec6cd6ffd7895c57f692cbdfc11be811791aa2..ed1f4d8df37592d27b2b55d5e5263623d841090a 100644 (file)
@@ -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"}
index 09bd8692248940bacc555e84416ae46ee748606a..2308cbaf19cc9603e7d23896a9f58b7b282a784f 100644 (file)
@@ -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