Intercept host logs Range header for Systemd v256+ compatibility

Since Systemd v256 the Range header must not end with a trailing colon.
We relied on this undocumented feature when following logs, and the
frontend or CLI may still use it in requests. To fix the requests
failing with new Systemd version, intercept the header and fill in the
num_entries to maximum possible value, which avoids the journal-gatewayd
returning the response prematurely and also works on older Systemd
versions.

The journal-gatewayd would still return response if follow flag is used
along with num_entries, but this behavior is unchanged and would be
better fixed in the backend.

Link: https://github.com/systemd/systemd/issues/37172
This commit is contained in:
Jan Čermák 2025-04-17 16:47:35 +02:00
parent 6fad7d14e1
commit 86e995ff69
No known key found for this signature in database
GPG Key ID: A78C897AA3AF012B
5 changed files with 43 additions and 6 deletions

View File

@ -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

View File

@ -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<cursor>[^:]*):(?P<num_skip>-?\d+):(?P<num_lines>\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}",

View File

@ -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(

View File

@ -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

View File

@ -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,
)