325 lines
11 KiB
Python
325 lines
11 KiB
Python
"""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()
|