/
/
/
1"""Sendspin WebSocket proxy handler for Music Assistant.
2
3This module provides an authenticated WebSocket proxy to the internal Sendspin server,
4allowing web clients to connect through the main webserver instead of requiring direct
5access to the Sendspin port.
6"""
7
8from __future__ import annotations
9
10import asyncio
11import contextlib
12import json
13import logging
14from typing import TYPE_CHECKING
15
16from aiohttp import WSMsgType, web
17
18from music_assistant.constants import MASS_LOGGER_NAME
19from music_assistant.controllers.webserver.helpers.auth_middleware import (
20 get_authenticated_user,
21 is_request_from_ingress,
22)
23
24if TYPE_CHECKING:
25 import aiohttp
26 from music_assistant_models.auth import User
27
28 from music_assistant.controllers.webserver import WebserverController
29
30LOGGER = logging.getLogger(f"{MASS_LOGGER_NAME}.sendspin_proxy")
31
32
33class SendspinProxyHandler:
34 """Handler for proxying WebSocket connections to the internal Sendspin server."""
35
36 def __init__(self, webserver: WebserverController) -> None:
37 """Initialize the Sendspin proxy handler.
38
39 :param webserver: The webserver controller instance.
40 """
41 self.webserver = webserver
42 self.mass = webserver.mass
43 self.logger = LOGGER
44
45 @property
46 def internal_sendspin_url(self) -> str:
47 """Return the internal sendspin URL for connecting to the internal Sendspin server."""
48 # Connect via localhost since the proxy and Sendspin server run in the same process
49 # If the server binds to 0.0.0.0 (all interfaces), use localhost for efficiency
50 # Otherwise use the actual bind IP in case it's configured to a specific interface
51 bind_ip = self.mass.streams.bind_ip
52 connect_ip = "127.0.0.1" if bind_ip == "0.0.0.0" else bind_ip
53 return f"ws://{connect_ip}:8927/sendspin"
54
55 async def handle_sendspin_proxy(self, request: web.Request) -> web.WebSocketResponse:
56 """
57 Handle incoming WebSocket connection and proxy to internal Sendspin server.
58
59 Authentication is required as the first message. The client must send:
60 {"type": "auth", "token": "<access_token>"}
61
62 After successful authentication, all messages are proxied bidirectionally.
63
64 :param request: The incoming HTTP request to upgrade to WebSocket.
65 :return: The WebSocket response.
66 """
67 wsock = web.WebSocketResponse(heartbeat=30)
68 await wsock.prepare(request)
69
70 self.logger.debug("Sendspin proxy connection from %s", request.remote)
71
72 # Check for ingress authentication (HA handles auth via headers)
73 if is_request_from_ingress(request):
74 user = await get_authenticated_user(request)
75 if not user:
76 self.logger.warning(
77 "Ingress auth failed for sendspin proxy from %s", request.remote
78 )
79 await wsock.close(code=4001, message=b"Ingress authentication failed")
80 return wsock
81 self.logger.debug("Sendspin proxy authenticated via ingress: %s", user.username)
82 else:
83 # Regular auth via first message
84 try:
85 user = await self._authenticate(wsock)
86 if not user:
87 return wsock
88 except TimeoutError:
89 self.logger.warning("Auth timeout for sendspin proxy from %s", request.remote)
90 await wsock.close(code=4001, message=b"Authentication timeout")
91 return wsock
92 except Exception:
93 self.logger.exception("Auth error for sendspin proxy")
94 await wsock.close(code=4001, message=b"Authentication error")
95 return wsock
96
97 try:
98 internal_ws = await self.mass.http_session.ws_connect(self.internal_sendspin_url)
99 except Exception:
100 self.logger.exception("Failed to connect to internal Sendspin server")
101 await wsock.close(code=1011, message=b"Internal server error")
102 return wsock
103
104 self.logger.debug("Sendspin proxy authenticated and connected for %s", request.remote)
105
106 try:
107 await self._proxy_messages(wsock, internal_ws)
108 finally:
109 if not internal_ws.closed:
110 await internal_ws.close()
111 if not wsock.closed:
112 await wsock.close()
113
114 return wsock
115
116 async def _authenticate(self, wsock: web.WebSocketResponse) -> User | None:
117 """Wait for and validate authentication message.
118
119 :param wsock: The client WebSocket connection.
120 :return: The authenticated user, or None if authentication failed.
121 """
122 async with asyncio.timeout(10):
123 msg = await wsock.receive()
124
125 if msg.type != WSMsgType.TEXT:
126 await wsock.close(code=4001, message=b"Expected text message for auth")
127 return None
128
129 try:
130 auth_data = json.loads(msg.data)
131 except json.JSONDecodeError:
132 await wsock.close(code=4001, message=b"Invalid JSON in auth message")
133 return None
134
135 if auth_data.get("type") != "auth":
136 await wsock.close(code=4001, message=b"First message must be auth")
137 return None
138
139 token = auth_data.get("token")
140 if not token:
141 await wsock.close(code=4001, message=b"Token required in auth message")
142 return None
143
144 user = await self.webserver.auth.authenticate_with_token(token)
145 if not user:
146 await wsock.close(code=4001, message=b"Invalid or expired token")
147 return None
148
149 # Set the sendspin player_id on the user's websocket client(s)
150 # This allows the player controller to auto-whitelist this (web)player
151 # without modifying the user's player_filter list
152 client_id = auth_data.get("client_id")
153 if client_id:
154 self.webserver.set_sendspin_player_for_user(user.user_id, client_id)
155 self.logger.debug("Registered sendspin player %s for user %s", client_id, user.username)
156
157 self.logger.debug("Sendspin proxy authenticated user: %s", user.username)
158 await wsock.send_str('{"type": "auth_ok"}')
159 return user
160
161 async def _proxy_messages(
162 self,
163 client_ws: web.WebSocketResponse,
164 internal_ws: aiohttp.ClientWebSocketResponse,
165 ) -> None:
166 """
167 Proxy messages bidirectionally between client and internal Sendspin server.
168
169 :param client_ws: The client WebSocket connection.
170 :param internal_ws: The internal Sendspin server WebSocket connection.
171 """
172 client_to_internal = asyncio.create_task(
173 self._forward_client_to_internal(client_ws, internal_ws)
174 )
175 internal_to_client = asyncio.create_task(
176 self._forward_internal_to_client(client_ws, internal_ws)
177 )
178
179 _done, pending = await asyncio.wait(
180 [client_to_internal, internal_to_client],
181 return_when=asyncio.FIRST_COMPLETED,
182 )
183
184 for task in pending:
185 task.cancel()
186 with contextlib.suppress(asyncio.CancelledError):
187 await task
188
189 async def _forward_client_to_internal(
190 self,
191 client_ws: web.WebSocketResponse,
192 internal_ws: aiohttp.ClientWebSocketResponse,
193 ) -> None:
194 """
195 Forward messages from client to internal Sendspin server.
196
197 :param client_ws: The client WebSocket connection.
198 :param internal_ws: The internal Sendspin server WebSocket connection.
199 """
200 async for msg in client_ws:
201 if msg.type == WSMsgType.TEXT:
202 await internal_ws.send_str(msg.data)
203 elif msg.type == WSMsgType.BINARY:
204 await internal_ws.send_bytes(msg.data)
205 elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR):
206 break
207
208 async def _forward_internal_to_client(
209 self,
210 client_ws: web.WebSocketResponse,
211 internal_ws: aiohttp.ClientWebSocketResponse,
212 ) -> None:
213 """
214 Forward messages from internal Sendspin server to client.
215
216 :param client_ws: The client WebSocket connection.
217 :param internal_ws: The internal Sendspin server WebSocket connection.
218 """
219 async for msg in internal_ws:
220 if msg.type == WSMsgType.TEXT:
221 await client_ws.send_str(msg.data)
222 elif msg.type == WSMsgType.BINARY:
223 await client_ws.send_bytes(msg.data)
224 elif msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR):
225 break
226