Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Hassio HTTP logs/follow to allowed paths #126606

Merged
merged 11 commits into from
Oct 23, 2024
68 changes: 66 additions & 2 deletions homeassistant/components/hassio/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CONTENT_ENCODING,
CONTENT_LENGTH,
CONTENT_TYPE,
RANGE,
TRANSFER_ENCODING,
)
from aiohttp.web_exceptions import HTTPBadGateway
Expand All @@ -41,6 +42,15 @@
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"
wendevlin marked this conversation as resolved.
Show resolved Hide resolved
r")$"
)

Expand All @@ -59,14 +69,23 @@
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")$"
)

Expand All @@ -83,8 +102,47 @@
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"
wendevlin marked this conversation as resolved.
Show resolved Hide resolved
r")$"
)
# fmt: on


RESPONSE_HEADERS_FILTER = {
TRANSFER_ENCODING,
CONTENT_LENGTH,
Expand Down Expand Up @@ -161,6 +219,10 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
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,
Expand All @@ -177,7 +239,7 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse:
)
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:
Expand Down Expand Up @@ -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/"):
Expand Down
62 changes: 62 additions & 0 deletions tests/components/hassio/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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),
],
Expand Down Expand Up @@ -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"

hostResp = await hassio_client.get(
wendevlin marked this conversation as resolved.
Show resolved Hide resolved
"/api/hassio/host/logs", headers={"Range": test_range}
)
addonResp = await hassio_client.get(
"/api/hassio/addons/123abc_esphome/logs", headers={"Range": test_range}
)
backupResp = await hassio_client.get(
"/api/hassio/backups/1234abcd/download", headers={"Range": test_range}
)

assert hostResp.status == HTTPStatus.OK
assert addonResp.status == HTTPStatus.OK
assert backupResp.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