From ad7eebee549b68fc1895a03c3dd86ba20adb1d5f Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Tue, 13 Oct 2026 05:25:10 +0800 Subject: [PATCH] feat(release): add isolated journal profile installation --- backend/tests/test_delivery.py | 30 +++ scripts/release/README.md | 18 ++ scripts/release/journal.py | 324 +++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 scripts/release/journal.py diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index d843b1d..b96fb49 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -26,6 +26,7 @@ dependency_initialize = importlib.import_module("scripts.release.dependency_init node_health = importlib.import_module("scripts.release.node_health") broker_health = importlib.import_module("scripts.release.broker_health") lifecycle = importlib.import_module("scripts.release.lifecycle") +journal = importlib.import_module("scripts.release.journal") def test_delivery_authorities_and_layout(): @@ -942,3 +943,32 @@ def test_maintenance_receipt_cannot_silently_adopt_unknown_history_or_release_pr value["drain_projection"]["factory"]["cleanup_complete"] = False with pytest.raises(policy.DeliveryError): lifecycle.validate_record(value) + + +@pytest.mark.parametrize( + "field,value", + [ + ("shared", True), + ("capacity_bytes", True), + ("capacity_bytes", 128 * 1024**2), + ("options", ["ro", "nosuid", "nodev", "noexec"]), + ("root", "/another-directory"), + ("filesystem", "tmpfs"), + ], +) +def test_journal_profile_never_substitutes_rotation_settings_for_a_dedicated_hard_volume( + field, value +): + path = Path("/var/log/journal/" + "b" * 32 + ".kf-onlinejudge") + observed = { + "path": str(path), + "root": "/", + "filesystem": "ext4", + "options": ["rw", "nosuid", "nodev", "noexec"], + "shared": False, + "capacity_bytes": 64 * 1024**2, + } + journal.validate_storage(observed, path) + observed[field] = value + with pytest.raises(policy.DeliveryError): + journal.validate_storage(observed, path) diff --git a/scripts/release/README.md b/scripts/release/README.md index 575230a..b6cd265 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -304,3 +304,21 @@ operations and cleanup results. Root factory termination uses a signal notice an the existing bounded guardian/recovery path; ordinary import termination finishes its current authorized operation before claiming another. These interfaces require actual independent-VM lifecycle acceptance and are not installation/safety proof. + +`python3.13 -m scripts.release.journal` separately installs a fixed KF journal +namespace profile. It requires a preprovisioned dedicated 64-MiB ext4 mount at +`/var/log/journal/MACHINE_ID.kf-onlinejudge`, with the same private hard-volume +mount rules used for dependencies. It never creates/formats/mounts a disk, changes +the default system journal, adopts old unrecorded log data or starts a daemon. +Unknown configuration/overrides and partial installations are retained and refused. + +The profile fixes 32-MiB persistent/8-MiB runtime rotation budgets, bounded files, +lines and seven-day retention. Its namespaced daemon override limits CPU, memory, +swap and PIDs. Rotation settings are not a hard filesystem guarantee; the dedicated +volume must also be observed. The `check_ready` interface reads actual namespace +service/process/cgroup state. Connecting service templates to this profile, +redaction and real retention/capacity behavior remain C+D/F installation acceptance +work; installing the profile alone does not route existing service logs or verify +their privacy. The profile relies on the pinned systemd 262 +[namespace interface](https://github.com/systemd/systemd/blob/v262/man/systemd.exec.xml) +and [journal configuration](https://github.com/systemd/systemd/blob/v262/man/journald.conf.xml). diff --git a/scripts/release/journal.py b/scripts/release/journal.py new file mode 100644 index 0000000..5aaed41 --- /dev/null +++ b/scripts/release/journal.py @@ -0,0 +1,324 @@ +"""A separate bounded log namespace; never change the host's default journal.""" + +import argparse +import hashlib +import json +import os +import re +import stat +import subprocess +from pathlib import Path + +from . import configuration, install, preflight +from .policy import DeliveryError, canonical, digest, fields, read_json + +NAMESPACE = "kf-onlinejudge" +UNIT = "systemd-journald@kf-onlinejudge.service" +CONFIG = Path("/etc/systemd/journald@kf-onlinejudge.conf") +DROPIN = Path("/etc/systemd/system") / (UNIT + ".d") / "50-kf-bounds.conf" +RECEIPT = Path("/var/lib/kf-onlinejudge/journal-profile.json") +CAPACITY = 64 * 1024**2 +PROFILE = b"""[Journal] +Storage=persistent +SplitMode=none +SystemMaxUse=32M +RuntimeMaxUse=8M +SystemMaxFileSize=4M +RuntimeMaxFileSize=2M +SystemMaxFiles=8 +RuntimeMaxFiles=4 +MaxFileSec=1h +MaxRetentionSec=7day +RateLimitIntervalSec=30s +RateLimitBurst=100 +LineMax=8192 +ForwardToSyslog=no +ForwardToKMsg=no +ForwardToConsole=no +ForwardToWall=no +""" +UNIT_BOUNDS = b"""[Unit] +RequiresMountsFor=/var/log/journal/%m.kf-onlinejudge +StartLimitIntervalSec=600 +StartLimitBurst=3 +[Service] +Slice=kfonlinejudge.slice +MemoryMax=64M +MemorySwapMax=0 +TasksMax=16 +CPUQuota=25% +LimitCORE=0 +LimitFSIZE=8M +LimitNOFILE=256 +Restart=on-failure +RestartSec=30 +TimeoutStopSec=15 +""" + + +def manager(*args: str) -> str: + # Import after service profiles are initialized so their future log-binding + # checks can call this independent installer without an import cycle. + from .service_install import systemctl + + return systemctl(*args) + + +def machine_id() -> str: + path = Path("/etc/machine-id") + preflight.protected(path) + value = bounded_text(path, 64).strip() + if not re.fullmatch(r"[0-9a-f]{32}", value) or value == "0" * 32: + raise DeliveryError("fixed machine journal identity required") + return value + + +def bounded_text(path: Path, maximum: int) -> str: + with path.open() as source: + value = source.read(maximum + 1) + if len(value) > maximum: + raise DeliveryError("journal observation exceeds bound") + return value + + +def validate_storage(value: dict, path: Path) -> None: + fields( + value, + {"path", "root", "filesystem", "options", "shared", "capacity_bytes"}, + "journal storage", + ) + if ( + value["path"] != str(path) + or value["root"] != "/" + or value["filesystem"] != "ext4" + or value["shared"] is not False + or not isinstance(value["options"], list) + or not all(isinstance(option, str) for option in value["options"]) + or not {"rw", "nosuid", "nodev", "noexec"} <= set(value["options"]) + or type(value["capacity_bytes"]) is not int + or not CAPACITY * 9 // 10 <= value["capacity_bytes"] <= CAPACITY + ): + raise DeliveryError("dedicated journal hard storage budget differs") + + +def storage() -> dict: + path = Path("/var/log/journal") / (machine_id() + "." + NAMESPACE) + preflight.protected(path, directory=True) + rows = [ + line.split() for line in bounded_text(Path("/proc/self/mountinfo"), 1024**2).splitlines() + ] + selected = [row for row in rows if len(row) > 6 and row[4] == str(path)] + if len(selected) != 1: + raise DeliveryError("dedicated journal mount required; never created by installer") + row = selected[0] + separator = row.index("-") + info = os.statvfs(path) + value = { + "path": str(path), + "root": row[3], + "filesystem": row[separator + 1], + "options": row[5].split(","), + "shared": any(other[2] == row[2] and other != row for other in rows), + "capacity_bytes": info.f_frsize * info.f_blocks, + } + validate_storage(value, path) + return value + + +def validate_receipt(value: dict) -> None: + fields( + value, + { + "schema_version", + "namespace", + "machine_id", + "files", + "services_started", + "execution_verified", + }, + "journal receipt", + ) + if ( + type(value["schema_version"]) is not int + or value["schema_version"] != 1 + or value["namespace"] != NAMESPACE + or not isinstance(value["machine_id"], str) + or not re.fullmatch(r"[0-9a-f]{32}", value["machine_id"]) + or value["machine_id"] == "0" * 32 + or value["files"] + != { + str(CONFIG): hashlib.sha256(PROFILE).hexdigest(), + str(DROPIN): hashlib.sha256(UNIT_BOUNDS).hexdigest(), + } + or value["services_started"] is not False + or value["execution_verified"] is not False + ): + raise DeliveryError("journal receipt differs from fixed profile") + + +def check_profile() -> dict: + preflight.protected(RECEIPT) + configuration.private(RECEIPT) + receipt = read_json(RECEIPT, 4096) + validate_receipt(receipt) + if receipt["machine_id"] != machine_id(): + raise DeliveryError("journal profile belongs to another machine") + for path, expected in receipt["files"].items(): + member = Path(path) + preflight.protected(member) + if ( + member.stat().st_gid != 0 + or stat.S_IMODE(member.stat().st_mode) != 0o644 + or digest(member) != expected + ): + raise DeliveryError("protected journal profile changed") + # Refuse unrecorded instance-specific overrides; systemd's actual limits are + # checked separately, including overrides inherited from the vendor template. + if {p.name for p in DROPIN.parent.iterdir()} != {DROPIN.name}: + raise DeliveryError("unknown journal instance override") + check_configuration_overrides() + return storage() + + +def check_configuration_overrides() -> None: + for root in ("/etc/systemd", "/run/systemd", "/usr/local/lib/systemd", "/usr/lib/systemd"): + directory = Path(root) / ("journald@" + NAMESPACE + ".conf.d") + if directory.exists() or directory.is_symlink(): + preflight.protected(directory, directory=True) + if next(directory.iterdir(), None) is not None: + raise DeliveryError("unrecorded namespace journal configuration override") + + +def check_ready() -> dict: + value = check_profile() + props = dict( + line.split("=", 1) + for line in manager( + "show", + UNIT, + "--property=ActiveState,SubState,MainPID,ControlGroup,FragmentPath,DropInPaths", + ).splitlines() + if "=" in line + ) + group = "/kfonlinejudge.slice/" + UNIT + if ( + props.get("ActiveState") != "active" + or props.get("SubState") != "running" + or props.get("ControlGroup") != group + ): + raise DeliveryError("bounded journal instance unavailable") + if props.get( + "FragmentPath" + ) != "/usr/lib/systemd/system/systemd-journald@.service" or props.get( + "DropInPaths", "" + ).split() != [str(DROPIN)]: + raise DeliveryError("unrecorded journal manager executable or override") + pid = int(props.get("MainPID", "0")) + if not 1 < pid < 2**31: + raise DeliveryError("journal process identity unavailable") + status = dict( + line.split(":", 1) + for line in bounded_text(Path(f"/proc/{pid}/status"), 16384).splitlines() + if ":" in line + ) + if status.get("Uid", "").split() != ["0"] * 4: + raise DeliveryError("journal process authority differs") + directory = Path("/sys/fs/cgroup" + group) + limits = { + name: (directory / name).read_text().strip() + for name in ("memory.max", "memory.swap.max", "pids.max", "cpu.max") + } + if limits != { + "memory.max": str(64 * 1024**2), + "memory.swap.max": "0", + "pids.max": "16", + "cpu.max": "25000 100000", + }: + raise DeliveryError("effective journal budgets differ") + value["effective_limits"] = limits + return value + + +def install_profile() -> dict: + if os.getuid() != 0 or os.geteuid() != 0 or os.getgid() != 0: + raise DeliveryError("root journal operator required") + selected = install.selection() + payload = install.RELEASES / selected["release_id"] / "payload" + shipped = payload / "delivery/scripts/release/journal.py" + preflight.protected(shipped) + if digest(shipped) != digest(Path(__file__)): + raise DeliveryError("journal operator differs from selected delivery") + install.require_stopped() + observed = storage() + if RECEIPT.exists() or RECEIPT.is_symlink(): + check_profile() + return { + "profile_installed": True, + "storage": observed, + "services_started": False, + "execution_verified": False, + } + props = dict( + line.split("=", 1) + for line in manager("show", UNIT, "--property=ActiveState,MainPID").splitlines() + if "=" in line + ) + if props.get("ActiveState") != "inactive" or props.get("MainPID") != "0": + raise DeliveryError("unrecorded journal instance; refused adoption") + path = Path(observed["path"]) + if any( + p.name != "lost+found" + or p.is_symlink() + or not p.is_dir() + or next(p.iterdir(), None) is not None + for p in path.iterdir() + ): + raise DeliveryError("unrecorded journal data; refused adoption") + for member in (CONFIG, DROPIN): + if member.exists() or member.is_symlink(): + raise DeliveryError("unrecorded journal configuration; refused overwrite") + check_configuration_overrides() + install.directory(DROPIN.parent, 0o755) + if next(DROPIN.parent.iterdir(), None) is not None: + raise DeliveryError("unknown journal override directory") + install.exclusive_file(CONFIG, PROFILE, 0o644) + install.exclusive_file(DROPIN, UNIT_BOUNDS, 0o644) + receipt = { + "schema_version": 1, + "namespace": NAMESPACE, + "machine_id": machine_id(), + "files": { + str(CONFIG): hashlib.sha256(PROFILE).hexdigest(), + str(DROPIN): hashlib.sha256(UNIT_BOUNDS).hexdigest(), + }, + "services_started": False, + "execution_verified": False, + } + validate_receipt(receipt) + install.exclusive_file(RECEIPT, canonical(receipt), 0o600) + manager("daemon-reload") + check_profile() + return { + "profile_installed": True, + "storage": observed, + "services_started": False, + "execution_verified": False, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Install only the fixed KF log namespace profile") + parser.parse_args() + try: + print(json.dumps(install_profile(), sort_keys=True)) + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.exit( + 1, + "Journal profile refused: " + + (str(error) if isinstance(error, DeliveryError) else type(error).__name__) + + "\n", + ) + + +if __name__ == "__main__": + main()