diff --git a/supervisor/api/host.py b/supervisor/api/host.py index cf91bab1e..631989e41 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -37,6 +37,7 @@ from ..host.const import ( LogFormat, LogFormatter, ) +from ..host.logs import SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX from ..utils.systemd_journal import journal_logs_reader from .const import ( ATTR_AGENT_VERSION, @@ -238,13 +239,11 @@ class APIHost(CoreSysAttributes): # return 2 lines at minimum. lines = max(2, lines) # entries=cursor[[:num_skip]:num_entries] - range_header = f"entries=:-{lines - 1}:{'' if follow else lines}" + range_header = f"entries=:-{lines - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else lines}" elif RANGE in request.headers: range_header = request.headers[RANGE] else: - range_header = ( - f"entries=:-{DEFAULT_LINES - 1}:{'' if follow else DEFAULT_LINES}" - ) + range_header = f"entries=:-{DEFAULT_LINES - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else DEFAULT_LINES}" async with self.sys_host.logs.journald_logs( params=params, range_header=range_header, accept=LogFormat.JOURNAL diff --git a/supervisor/host/logs.py b/supervisor/host/logs.py index 978e99fde..0e7acef58 100644 --- a/supervisor/host/logs.py +++ b/supervisor/host/logs.py @@ -8,6 +8,7 @@ import json import logging import os from pathlib import Path +import re from typing import Self from aiohttp import ClientError, ClientSession, ClientTimeout @@ -34,6 +35,8 @@ SYSLOG_IDENTIFIERS_JSON: Path = ( ) # pylint: enable=no-member +SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX = (1 << 64) - 1 + SYSTEMD_JOURNAL_GATEWAYD_SOCKET: Path = Path("/run/systemd-journal-gatewayd.sock") # From systemd catalog for message IDs (`journalctl --dump-catalog``) @@ -42,6 +45,10 @@ SYSTEMD_JOURNAL_GATEWAYD_SOCKET: Path = Path("/run/systemd-journal-gatewayd.sock # Defined-By: systemd BOOT_IDS_QUERY = {"MESSAGE_ID": "b07a249cd024414a82dd00cd181378ff"} +RE_ENTRIES_HEADER = re.compile( + r"^entries=(?P[^:]*):(?P-?\d+):(?P\d*)$" +) + class LogsControl(CoreSysAttributes): """Handle systemd-journal logs.""" @@ -186,6 +193,16 @@ class LogsControl(CoreSysAttributes): async with ClientSession(base_url=base_url, connector=connector) as session: headers = {ACCEPT: accept} if range_header: + if range_header.endswith(":"): + # Make sure that num_entries is always set - before Systemd v256 it was + # possible to omit it, which made sense when the "follow" option was used, + # but this syntax is now invalid and triggers HTTP 400. + # See: https://github.com/systemd/systemd/issues/37172 + if not (matches := re.match(RE_ENTRIES_HEADER, range_header)): + raise HostNotSupportedError( + f"Invalid range header: {range_header}" + ) + range_header = f"entries={matches.group('cursor')}:{matches.group('num_skip')}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX}" headers[RANGE] = range_header async with session.get( f"{path}", diff --git a/tests/api/__init__.py b/tests/api/__init__.py index 4a32171ff..8980b70ff 100644 --- a/tests/api/__init__.py +++ b/tests/api/__init__.py @@ -7,7 +7,7 @@ from aiohttp.test_utils import TestClient from supervisor.host.const import LogFormat DEFAULT_LOG_RANGE = "entries=:-99:100" -DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:" +DEFAULT_LOG_RANGE_FOLLOW = "entries=:-99:18446744073709551615" async def common_test_api_advanced_logs( diff --git a/tests/api/test_host.py b/tests/api/test_host.py index 824a7150a..0c00bc21c 100644 --- a/tests/api/test_host.py +++ b/tests/api/test_host.py @@ -16,7 +16,7 @@ from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.systemd import Systemd as SystemdService DEFAULT_RANGE = "entries=:-99:100" -DEFAULT_RANGE_FOLLOW = "entries=:-99:" +DEFAULT_RANGE_FOLLOW = "entries=:-99:18446744073709551615" # pylint: disable=protected-access diff --git a/tests/host/test_logs.py b/tests/host/test_logs.py index 38c8b739d..19cbb187a 100644 --- a/tests/host/test_logs.py +++ b/tests/host/test_logs.py @@ -171,3 +171,24 @@ async def test_connection_refused_handled( with pytest.raises(HostServiceError): async with coresys.host.logs.journald_logs(): pass + + +@pytest.mark.parametrize( + "range_header,range_reparse", + [ + ("entries=:-99:", "entries=:-99:18446744073709551615"), + ("entries=:-99:100", "entries=:-99:100"), + ("entries=cursor:0:100", "entries=cursor:0:100"), + ], +) +async def test_range_header_reparse( + journald_gateway: MagicMock, coresys: CoreSys, range_header: str, range_reparse: str +): + """Test that range header with trailing colon contains num_entries.""" + async with coresys.host.logs.journald_logs(range_header=range_header): + journald_gateway.get.assert_called_with( + "/entries", + headers={"Accept": "text/plain", "Range": range_reparse}, + params={}, + timeout=None, + )