mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-02-08 17:11:00 +00:00
Compare commits
5 Commits
fix-rauc-t
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b9b9535c | ||
|
|
0cd668ec77 | ||
|
|
d1cbb57c34 | ||
|
|
3d4849a3a2 | ||
|
|
4d8d44721d |
@@ -25,7 +25,7 @@ pyudev==0.24.4
|
||||
PyYAML==6.0.3
|
||||
requests==2.32.5
|
||||
securetar==2025.12.0
|
||||
sentry-sdk==2.51.0
|
||||
sentry-sdk==2.52.0
|
||||
setuptools==80.10.2
|
||||
voluptuous==0.16.0
|
||||
dbus-fast==3.1.2
|
||||
|
||||
@@ -21,7 +21,13 @@ from ..utils.logging import AddonLoggerAdapter
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FORWARD_HEADERS = ("X-Speech-Content",)
|
||||
FORWARD_HEADERS = (
|
||||
"X-Speech-Content",
|
||||
"Accept",
|
||||
"Last-Event-ID",
|
||||
"Mcp-Session-Id",
|
||||
"MCP-Protocol-Version",
|
||||
)
|
||||
HEADER_HA_ACCESS = "X-Ha-Access"
|
||||
|
||||
# Maximum message size for websocket messages from Home Assistant.
|
||||
@@ -35,6 +41,38 @@ MAX_MESSAGE_SIZE_FROM_CORE = 64 * 1024 * 1024
|
||||
class APIProxy(CoreSysAttributes):
|
||||
"""API Proxy for Home Assistant."""
|
||||
|
||||
async def _stream_client_response(
|
||||
self,
|
||||
request: web.Request,
|
||||
client: aiohttp.ClientResponse,
|
||||
*,
|
||||
content_type: str,
|
||||
headers_to_copy: tuple[str, ...] = (),
|
||||
) -> web.StreamResponse:
|
||||
"""Stream an upstream aiohttp response to the caller.
|
||||
|
||||
Used for event streams (e.g. Home Assistant /api/stream) and for SSE endpoints
|
||||
such as MCP (text/event-stream).
|
||||
"""
|
||||
response = web.StreamResponse(status=client.status)
|
||||
response.content_type = content_type
|
||||
|
||||
for header in headers_to_copy:
|
||||
if header in client.headers:
|
||||
response.headers[header] = client.headers[header]
|
||||
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
|
||||
try:
|
||||
await response.prepare(request)
|
||||
async for data in client.content:
|
||||
await response.write(data)
|
||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError):
|
||||
# Client disconnected or upstream closed
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
def _check_access(self, request: web.Request):
|
||||
"""Check the Supervisor token."""
|
||||
if AUTHORIZATION in request.headers:
|
||||
@@ -95,16 +133,11 @@ class APIProxy(CoreSysAttributes):
|
||||
|
||||
_LOGGER.info("Home Assistant EventStream start")
|
||||
async with self._api_client(request, "stream", timeout=None) as client:
|
||||
response = web.StreamResponse()
|
||||
response.content_type = request.headers.get(CONTENT_TYPE, "")
|
||||
try:
|
||||
response.headers["X-Accel-Buffering"] = "no"
|
||||
await response.prepare(request)
|
||||
async for data in client.content:
|
||||
await response.write(data)
|
||||
|
||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError):
|
||||
pass
|
||||
response = await self._stream_client_response(
|
||||
request,
|
||||
client,
|
||||
content_type=request.headers.get(CONTENT_TYPE, ""),
|
||||
)
|
||||
|
||||
_LOGGER.info("Home Assistant EventStream close")
|
||||
return response
|
||||
@@ -118,10 +151,31 @@ class APIProxy(CoreSysAttributes):
|
||||
# Normal request
|
||||
path = request.match_info.get("path", "")
|
||||
async with self._api_client(request, path) as client:
|
||||
# Check if this is a streaming response (e.g., MCP SSE endpoints)
|
||||
if client.content_type == "text/event-stream":
|
||||
return await self._stream_client_response(
|
||||
request,
|
||||
client,
|
||||
content_type=client.content_type,
|
||||
headers_to_copy=(
|
||||
"Cache-Control",
|
||||
"Mcp-Session-Id",
|
||||
),
|
||||
)
|
||||
|
||||
# Non-streaming response
|
||||
data = await client.read()
|
||||
return web.Response(
|
||||
response = web.Response(
|
||||
body=data, status=client.status, content_type=client.content_type
|
||||
)
|
||||
# Copy selected headers from the upstream response
|
||||
for header in (
|
||||
"Cache-Control",
|
||||
"Mcp-Session-Id",
|
||||
):
|
||||
if header in client.headers:
|
||||
response.headers[header] = client.headers[header]
|
||||
return response
|
||||
|
||||
async def _websocket_client(self) -> ClientWebSocketResponse:
|
||||
"""Initialize a WebSocket API connection."""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""D-Bus interface for rauc."""
|
||||
|
||||
from ctypes import c_uint32, c_uint64
|
||||
import logging
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
@@ -33,13 +32,15 @@ SlotStatusDataType = TypedDict(
|
||||
"state": str,
|
||||
"device": str,
|
||||
"bundle.compatible": NotRequired[str],
|
||||
"bundle.hash": NotRequired[str],
|
||||
"sha256": NotRequired[str],
|
||||
"size": NotRequired[c_uint64],
|
||||
"installed.count": NotRequired[c_uint32],
|
||||
"size": NotRequired[int],
|
||||
"installed.count": NotRequired[int],
|
||||
"installed.transaction": NotRequired[str],
|
||||
"bundle.version": NotRequired[str],
|
||||
"installed.timestamp": NotRequired[str],
|
||||
"status": NotRequired[str],
|
||||
"activated.count": NotRequired[c_uint32],
|
||||
"activated.count": NotRequired[int],
|
||||
"activated.timestamp": NotRequired[str],
|
||||
"boot-status": NotRequired[str],
|
||||
"bootname": NotRequired[str],
|
||||
@@ -117,7 +118,7 @@ class Rauc(DBusInterfaceProxy):
|
||||
return self.connected_dbus.signal(DBUS_SIGNAL_RAUC_INSTALLER_COMPLETED)
|
||||
|
||||
@dbus_connected
|
||||
async def mark(self, state: RaucState, slot_identifier: str) -> tuple[str, str]:
|
||||
async def mark(self, state: RaucState, slot_identifier: str) -> list[str]:
|
||||
"""Get slot status."""
|
||||
return await self.connected_dbus.Installer.call("mark", state, slot_identifier)
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ class DockerHomeAssistant(DockerInterface):
|
||||
async def run(self, *, restore_job_id: str | None = None) -> None:
|
||||
"""Run Docker image."""
|
||||
environment = {
|
||||
"SUPERVISOR": self.sys_docker.network.supervisor,
|
||||
"HASSIO": self.sys_docker.network.supervisor,
|
||||
"SUPERVISOR": str(self.sys_docker.network.supervisor),
|
||||
"HASSIO": str(self.sys_docker.network.supervisor),
|
||||
ENV_TIME: self.sys_timezone,
|
||||
ENV_TOKEN: self.sys_homeassistant.supervisor_token,
|
||||
ENV_TOKEN_OLD: self.sys_homeassistant.supervisor_token,
|
||||
|
||||
@@ -398,7 +398,7 @@ class DockerAPI(CoreSysAttributes):
|
||||
if restart_policy:
|
||||
host_config["RestartPolicy"] = restart_policy
|
||||
if extra_hosts:
|
||||
host_config["ExtraHosts"] = [f"{k}:{v}" for k, v in extra_hosts.items()]
|
||||
host_config["ExtraHosts"] = [f"{k}:{v!s}" for k, v in extra_hosts.items()]
|
||||
if mounts:
|
||||
host_config["Mounts"] = [mount.to_dict() for mount in mounts]
|
||||
if oom_score_adj is not None:
|
||||
|
||||
@@ -48,7 +48,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes):
|
||||
environment={
|
||||
ENV_TIME: self.sys_timezone,
|
||||
ENV_TOKEN: self.sys_plugins.observer.supervisor_token,
|
||||
ENV_NETWORK_MASK: DOCKER_IPV4_NETWORK_MASK,
|
||||
ENV_NETWORK_MASK: str(DOCKER_IPV4_NETWORK_MASK),
|
||||
},
|
||||
mounts=[MOUNT_DOCKER],
|
||||
ports={"80/tcp": OBSERVER_PORT},
|
||||
|
||||
@@ -72,6 +72,9 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
|
||||
"docker": coresys.docker.info.version,
|
||||
"supervisor": coresys.supervisor.version,
|
||||
},
|
||||
"docker": {
|
||||
"storage_driver": coresys.docker.info.storage,
|
||||
},
|
||||
"host": {
|
||||
"machine": coresys.machine,
|
||||
},
|
||||
@@ -111,6 +114,9 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
|
||||
"docker": coresys.docker.info.version,
|
||||
"supervisor": coresys.supervisor.version,
|
||||
},
|
||||
"docker": {
|
||||
"storage_driver": coresys.docker.info.storage,
|
||||
},
|
||||
"resolution": {
|
||||
"issues": [attr.asdict(issue) for issue in coresys.resolution.issues],
|
||||
"suggestions": [
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aiohttp import ClientWebSocketResponse, WSCloseCode
|
||||
from aiohttp import ClientPayloadError, ClientWebSocketResponse, WSCloseCode
|
||||
from aiohttp.http_websocket import WSMessage, WSMsgType
|
||||
from aiohttp.test_utils import TestClient
|
||||
import pytest
|
||||
@@ -326,3 +326,129 @@ async def test_api_proxy_delete_request(
|
||||
assert response.status == 200
|
||||
assert await response.text() == '{"result": "ok"}'
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
|
||||
async def test_api_proxy_mcp_headers_forwarded(
|
||||
api_client: TestClient,
|
||||
install_addon_example: Addon,
|
||||
):
|
||||
"""Test that MCP headers are forwarded to Home Assistant."""
|
||||
install_addon_example.persist[ATTR_ACCESS_TOKEN] = "abc123"
|
||||
install_addon_example.data["homeassistant_api"] = True
|
||||
|
||||
with patch.object(HomeAssistantAPI, "make_request") as make_request:
|
||||
# Mock the response from make_request
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.content_type = "application/json"
|
||||
mock_response.read.return_value = b"mocked response"
|
||||
mock_response.headers = {"Mcp-Session-Id": "test-session-123"}
|
||||
make_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
response = await api_client.get(
|
||||
"/core/api/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer abc123",
|
||||
"Accept": "text/event-stream",
|
||||
"Last-Event-ID": "5",
|
||||
"Mcp-Session-Id": "test-session-123",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify headers were forwarded in the request
|
||||
assert make_request.call_args[1]["headers"]["Accept"] == "text/event-stream"
|
||||
assert make_request.call_args[1]["headers"]["Last-Event-ID"] == "5"
|
||||
assert (
|
||||
make_request.call_args[1]["headers"]["Mcp-Session-Id"] == "test-session-123"
|
||||
)
|
||||
|
||||
# Verify response headers are preserved
|
||||
assert response.status == 200
|
||||
assert response.headers.get("Mcp-Session-Id") == "test-session-123"
|
||||
|
||||
|
||||
async def test_api_proxy_streaming_response(
|
||||
api_client: TestClient,
|
||||
install_addon_example: Addon,
|
||||
):
|
||||
"""Test that streaming responses (text/event-stream) are handled properly."""
|
||||
install_addon_example.persist[ATTR_ACCESS_TOKEN] = "abc123"
|
||||
install_addon_example.data["homeassistant_api"] = True
|
||||
|
||||
async def mock_content_iter():
|
||||
"""Mock async iterator for streaming content."""
|
||||
yield b"data: event1\n\n"
|
||||
yield b"data: event2\n\n"
|
||||
yield b"data: event3\n\n"
|
||||
|
||||
with patch.object(HomeAssistantAPI, "make_request") as make_request:
|
||||
# Mock the response from make_request
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.content_type = "text/event-stream"
|
||||
mock_response.headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Mcp-Session-Id": "session-456",
|
||||
}
|
||||
mock_response.content = mock_content_iter()
|
||||
make_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
response = await api_client.get(
|
||||
"/core/api/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer abc123",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify it's a streaming response
|
||||
assert response.status == 200
|
||||
assert response.content_type == "text/event-stream"
|
||||
assert response.headers.get("X-Accel-Buffering") == "no"
|
||||
assert response.headers.get("Mcp-Session-Id") == "session-456"
|
||||
|
||||
# Read the streamed content
|
||||
content = await response.read()
|
||||
assert b"data: event1\n\n" in content
|
||||
assert b"data: event2\n\n" in content
|
||||
assert b"data: event3\n\n" in content
|
||||
|
||||
|
||||
async def test_api_proxy_streaming_response_client_payload_error(
|
||||
api_client: TestClient,
|
||||
install_addon_example: Addon,
|
||||
):
|
||||
"""Test that client payload errors during streaming are handled gracefully."""
|
||||
install_addon_example.persist[ATTR_ACCESS_TOKEN] = "abc123"
|
||||
install_addon_example.data["homeassistant_api"] = True
|
||||
|
||||
async def mock_content_iter_error():
|
||||
yield b"data: event1\n\n"
|
||||
raise ClientPayloadError("boom")
|
||||
|
||||
with patch.object(HomeAssistantAPI, "make_request") as make_request:
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status = 200
|
||||
mock_response.content_type = "text/event-stream"
|
||||
mock_response.headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Mcp-Session-Id": "session-789",
|
||||
}
|
||||
mock_response.content = mock_content_iter_error()
|
||||
make_request.return_value.__aenter__.return_value = mock_response
|
||||
|
||||
response = await api_client.get(
|
||||
"/core/api/mcp",
|
||||
headers={
|
||||
"Authorization": "Bearer abc123",
|
||||
"Accept": "text/event-stream",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.content_type == "text/event-stream"
|
||||
assert response.headers.get("X-Accel-Buffering") == "no"
|
||||
assert response.headers.get("Mcp-Session-Id") == "session-789"
|
||||
|
||||
content = await response.read()
|
||||
assert b"data: event1\n\n" in content
|
||||
|
||||
@@ -46,8 +46,8 @@ async def test_homeassistant_start(coresys: CoreSys, container: DockerContainer)
|
||||
"observer": IPv4Address("172.30.32.6"),
|
||||
}
|
||||
assert run.call_args.kwargs["environment"] == {
|
||||
"SUPERVISOR": IPv4Address("172.30.32.2"),
|
||||
"HASSIO": IPv4Address("172.30.32.2"),
|
||||
"SUPERVISOR": "172.30.32.2",
|
||||
"HASSIO": "172.30.32.2",
|
||||
"TZ": ANY,
|
||||
"SUPERVISOR_TOKEN": ANY,
|
||||
"HASSIO_TOKEN": ANY,
|
||||
@@ -166,8 +166,8 @@ async def test_landingpage_start(coresys: CoreSys, container: DockerContainer):
|
||||
"observer": IPv4Address("172.30.32.6"),
|
||||
}
|
||||
assert run.call_args.kwargs["environment"] == {
|
||||
"SUPERVISOR": IPv4Address("172.30.32.2"),
|
||||
"HASSIO": IPv4Address("172.30.32.2"),
|
||||
"SUPERVISOR": "172.30.32.2",
|
||||
"HASSIO": "172.30.32.2",
|
||||
"TZ": ANY,
|
||||
"SUPERVISOR_TOKEN": ANY,
|
||||
"HASSIO_TOKEN": ANY,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test Observer plugin container."""
|
||||
|
||||
from ipaddress import IPv4Address, ip_network
|
||||
from ipaddress import IPv4Address
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiodocker.containers import DockerContainer
|
||||
@@ -26,9 +26,7 @@ async def test_start(coresys: CoreSys, container: DockerContainer):
|
||||
"supervisor": IPv4Address("172.30.32.2")
|
||||
}
|
||||
assert run.call_args.kwargs["oom_score_adj"] == -300
|
||||
assert run.call_args.kwargs["environment"]["NETWORK_MASK"] == ip_network(
|
||||
"172.30.32.0/23"
|
||||
)
|
||||
assert run.call_args.kwargs["environment"]["NETWORK_MASK"] == "172.30.32.0/23"
|
||||
assert run.call_args.kwargs["ports"] == {"80/tcp": 4357}
|
||||
assert run.call_args.kwargs["mounts"] == [
|
||||
DockerMount(
|
||||
|
||||
@@ -121,10 +121,15 @@ async def test_not_started(coresys):
|
||||
assert "versions" in filtered["contexts"]
|
||||
assert "docker" in filtered["contexts"]["versions"]
|
||||
assert "supervisor" in filtered["contexts"]["versions"]
|
||||
assert "docker" in filtered["contexts"]
|
||||
assert "storage_driver" in filtered["contexts"]["docker"]
|
||||
assert "host" in filtered["contexts"]
|
||||
assert "machine" in filtered["contexts"]["host"]
|
||||
assert filtered["contexts"]["versions"]["docker"] == coresys.docker.info.version
|
||||
assert filtered["contexts"]["versions"]["supervisor"] == coresys.supervisor.version
|
||||
assert (
|
||||
filtered["contexts"]["docker"]["storage_driver"] == coresys.docker.info.storage
|
||||
)
|
||||
assert filtered["contexts"]["host"]["machine"] == coresys.machine
|
||||
|
||||
|
||||
@@ -142,6 +147,9 @@ async def test_defaults(coresys):
|
||||
assert filtered["contexts"]["versions"]["supervisor"] == AwesomeVersion(
|
||||
SUPERVISOR_VERSION
|
||||
)
|
||||
assert (
|
||||
filtered["contexts"]["docker"]["storage_driver"] == coresys.docker.info.storage
|
||||
)
|
||||
assert filtered["user"]["id"] == coresys.machine_id
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user