diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 8c1fb11973e..6d60fd0a435 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -18,6 +18,7 @@ from aiohttp.hdrs import ( CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, + RANGE, TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway @@ -41,6 +42,15 @@ NO_TIMEOUT = re.compile( r"|backups/.+/full" r"|backups/.+/partial" r"|backups/[^/]+/(?:upload|download)" + r"|audio/logs/follow" + r"|cli/logs/follow" + r"|core/logs/follow" + r"|dns/logs/follow" + r"|host/logs/follow" + r"|multicast/logs/follow" + r"|observer/logs/follow" + r"|supervisor/logs/follow" + r"|addons/[^/]+/logs/follow" r")$" ) @@ -59,14 +69,23 @@ PATHS_ADMIN = re.compile( r"|backups/[a-f0-9]{8}(/info|/download|/restore/full|/restore/partial)?" r"|backups/new/upload" r"|audio/logs" + r"|audio/logs/follow" r"|cli/logs" + r"|cli/logs/follow" r"|core/logs" + r"|core/logs/follow" r"|dns/logs" + r"|dns/logs/follow" r"|host/logs" + r"|host/logs/follow" r"|multicast/logs" + r"|multicast/logs/follow" r"|observer/logs" + r"|observer/logs/follow" r"|supervisor/logs" + r"|supervisor/logs/follow" r"|addons/[^/]+/(changelog|documentation|logs)" + r"|addons/[^/]+/logs/follow" r")$" ) @@ -83,8 +102,47 @@ NO_STORE = re.compile( r"|app/entrypoint.js" r")$" ) + +# Follow logs should not be compressed, to be able to get streamed by frontend +NO_COMPRESS = re.compile( + r"^(?:" + r"|audio/logs/follow" + r"|cli/logs/follow" + r"|core/logs/follow" + r"|dns/logs/follow" + r"|host/logs/follow" + r"|multicast/logs/follow" + r"|observer/logs/follow" + r"|supervisor/logs/follow" + r"|addons/[^/]+/logs/follow" + r")$" +) + +PATHS_LOGS = re.compile( + r"^(?:" + r"|audio/logs" + r"|audio/logs/follow" + r"|cli/logs" + r"|cli/logs/follow" + r"|core/logs" + r"|core/logs/follow" + r"|dns/logs" + r"|dns/logs/follow" + r"|host/logs" + r"|host/logs/follow" + r"|multicast/logs" + r"|multicast/logs/follow" + r"|observer/logs" + r"|observer/logs/follow" + r"|supervisor/logs" + r"|supervisor/logs/follow" + r"|addons/[^/]+/logs" + r"|addons/[^/]+/logs/follow" + r")$" +) # fmt: on + RESPONSE_HEADERS_FILTER = { TRANSFER_ENCODING, CONTENT_LENGTH, @@ -161,6 +219,10 @@ class HassIOView(HomeAssistantView): assert isinstance(request._stored_content_type, str) # noqa: SLF001 headers[CONTENT_TYPE] = request._stored_content_type # noqa: SLF001 + # forward range headers for logs + if PATHS_LOGS.match(path) and request.headers.get(RANGE): + headers[RANGE] = request.headers[RANGE] + try: client = await self._websession.request( method=request.method, @@ -177,7 +239,7 @@ class HassIOView(HomeAssistantView): ) response.content_type = client.content_type - if should_compress(response.content_type): + if should_compress(response.content_type, path): response.enable_compression() await response.prepare(request) # In testing iter_chunked, iter_any, and iter_chunks: @@ -217,8 +279,10 @@ def _get_timeout(path: str) -> ClientTimeout: return ClientTimeout(connect=10, total=300) -def should_compress(content_type: str) -> bool: +def should_compress(content_type: str, path: str | None = None) -> bool: """Return if we should compress a response.""" + if path is not None and NO_COMPRESS.match(path): + return False if content_type.startswith("image/"): return "svg" in content_type if content_type.startswith("application/"): diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 404c047a56c..5d316da1a12 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -82,7 +82,9 @@ async def test_forward_request_onboarded_user_unallowed_methods( # Unauthenticated path ("supervisor/info", HTTPStatus.UNAUTHORIZED), ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs/follow", HTTPStatus.UNAUTHORIZED), ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs/follow", HTTPStatus.UNAUTHORIZED), ], ) async def test_forward_request_onboarded_user_unallowed_paths( @@ -152,7 +154,9 @@ async def test_forward_request_onboarded_noauth_unallowed_methods( # Unauthenticated path ("supervisor/info", HTTPStatus.UNAUTHORIZED), ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs/follow", HTTPStatus.UNAUTHORIZED), ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs/follow", HTTPStatus.UNAUTHORIZED), ], ) async def test_forward_request_onboarded_noauth_unallowed_paths( @@ -265,7 +269,9 @@ async def test_forward_request_not_onboarded_unallowed_methods( # Unauthenticated path ("supervisor/info", HTTPStatus.UNAUTHORIZED), ("supervisor/logs", HTTPStatus.UNAUTHORIZED), + ("supervisor/logs/follow", HTTPStatus.UNAUTHORIZED), ("addons/bl_b392/logs", HTTPStatus.UNAUTHORIZED), + ("addons/bl_b392/logs/follow", HTTPStatus.UNAUTHORIZED), ], ) async def test_forward_request_not_onboarded_unallowed_paths( @@ -292,7 +298,9 @@ async def test_forward_request_not_onboarded_unallowed_paths( ("addons/bl_b392/icon", False), ("backups/1234abcd/info", True), ("supervisor/logs", True), + ("supervisor/logs/follow", True), ("addons/bl_b392/logs", True), + ("addons/bl_b392/logs/follow", True), ("addons/bl_b392/changelog", True), ("addons/bl_b392/documentation", True), ], @@ -494,3 +502,57 @@ async def test_entrypoint_cache_control( assert resp1.headers["Cache-Control"] == "no-store, max-age=0" assert "Cache-Control" not in resp2.headers + + +async def test_no_follow_logs_compress( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that we do not compress follow logs.""" + aioclient_mock.get("http://127.0.0.1/supervisor/logs/follow") + aioclient_mock.get("http://127.0.0.1/supervisor/logs") + + resp1 = await hassio_client.get("/api/hassio/supervisor/logs/follow") + resp2 = await hassio_client.get("/api/hassio/supervisor/logs") + + # Check we got right response + assert resp1.status == HTTPStatus.OK + assert resp1.headers.get("Content-Encoding") is None + + assert resp2.status == HTTPStatus.OK + assert resp2.headers.get("Content-Encoding") == "deflate" + + +async def test_forward_range_header_for_logs( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that we forward the Range header for logs.""" + aioclient_mock.get("http://127.0.0.1/host/logs") + aioclient_mock.get("http://127.0.0.1/addons/123abc_esphome/logs") + aioclient_mock.get("http://127.0.0.1/backups/1234abcd/download") + + test_range = ":-100:50" + + host_resp = await hassio_client.get( + "/api/hassio/host/logs", headers={"Range": test_range} + ) + addon_resp = await hassio_client.get( + "/api/hassio/addons/123abc_esphome/logs", headers={"Range": test_range} + ) + backup_resp = await hassio_client.get( + "/api/hassio/backups/1234abcd/download", headers={"Range": test_range} + ) + + assert host_resp.status == HTTPStatus.OK + assert addon_resp.status == HTTPStatus.OK + assert backup_resp.status == HTTPStatus.OK + + assert len(aioclient_mock.mock_calls) == 3 + + req_headers1 = aioclient_mock.mock_calls[0][-1] + assert req_headers1.get("Range") == test_range + + req_headers2 = aioclient_mock.mock_calls[1][-1] + assert req_headers2.get("Range") == test_range + + req_headers3 = aioclient_mock.mock_calls[2][-1] + assert req_headers3.get("Range") is None