288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""Complete bundle byte proofs limited to one live Root maintenance invocation."""
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import stat
|
|
import time
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from . import preflight
|
|
from .policy import DeliveryError, canonical, digest, fields, integer, read_json, sha256
|
|
|
|
DIRECTORY = Path("/run/kf-node-verification")
|
|
RECORD = DIRECTORY / "byte-proof.json"
|
|
PARENT = "kf-node-maintenance.service"
|
|
GROUP = "/sys/fs/cgroup/kfonlinejudge.slice/"
|
|
# Privileged ExecStartPre checks retain their ordinary service's supplementary
|
|
# group. These are fixed unit identities, never groups supplied by a proof.
|
|
CONSUMERS = {
|
|
"kf-postgres.service": 1203,
|
|
"kf-rabbitmq.service": 1204,
|
|
"kf-sandbox-factory.service": 0,
|
|
"kf-coordinator.service": 1201,
|
|
"kf-api.service": 1200,
|
|
"kf-outbox.service": 1200,
|
|
"kf-problem-import.service": 1200,
|
|
"kf-worker.service": 1202,
|
|
"kf-proxy.service": 1205,
|
|
"kf-node-health.service": 0,
|
|
}
|
|
MAX_AGE_NS = 900 * 10**9
|
|
MAX_RECORD = 4096
|
|
_REPORTED: tuple[int, str] | None = None
|
|
|
|
|
|
def _root(allowed_groups: frozenset[int] = frozenset({0})) -> bool:
|
|
return (os.getuid(), os.geteuid(), os.getgid(), os.getegid()) == (
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
) and set(os.getgroups()) <= allowed_groups
|
|
|
|
|
|
def _now() -> int:
|
|
return time.clock_gettime_ns(time.CLOCK_BOOTTIME)
|
|
|
|
|
|
def _boot() -> str:
|
|
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
|
|
|
|
def _signature(value: dict) -> str:
|
|
return hashlib.sha256(canonical(value)).hexdigest()
|
|
|
|
|
|
def _manager(release: str, configuration: str) -> dict:
|
|
from . import service_install
|
|
|
|
expected = {
|
|
"LoadState": "loaded",
|
|
"ActiveState": "active",
|
|
"SubState": "running",
|
|
"Transient": "yes",
|
|
"ControlGroup": "/kfonlinejudge.slice/" + PARENT,
|
|
"Description": "KF fixed node-maintenance " + release + "." + configuration,
|
|
"User": "root",
|
|
"Group": "root",
|
|
"MemoryMax": "268435456",
|
|
"MemorySwapMax": "0",
|
|
"TasksMax": "32",
|
|
"CPUQuotaPerSecUSec": "500ms",
|
|
"RuntimeMaxUSec": "15min",
|
|
"NoNewPrivileges": "yes",
|
|
"CapabilityBoundingSet": "",
|
|
"AmbientCapabilities": "",
|
|
"Restart": "no",
|
|
"KillMode": "control-group",
|
|
"RuntimeDirectory": DIRECTORY.name,
|
|
"RuntimeDirectoryMode": "0700",
|
|
}
|
|
names = [*expected, "MainPID", "InvocationID"]
|
|
observed = dict(
|
|
line.split("=", 1)
|
|
for line in service_install.systemctl(
|
|
"show", PARENT, "--property=" + ",".join(names)
|
|
).splitlines()
|
|
if "=" in line
|
|
)
|
|
if (
|
|
set(observed) != set(names)
|
|
or any(observed[key] != value for key, value in expected.items())
|
|
or not re.fullmatch(r"[1-9][0-9]{0,9}", observed.get("MainPID", ""))
|
|
or not re.fullmatch(r"[0-9a-f]{32}", observed.get("InvocationID", ""))
|
|
or observed["InvocationID"] == "0" * 32
|
|
):
|
|
raise DeliveryError("bundle proof maintenance invocation unavailable")
|
|
return {"pid": int(observed["MainPID"]), "invocation": observed["InvocationID"]}
|
|
|
|
|
|
def _read() -> tuple[dict, tuple[int, ...]]:
|
|
preflight.protected(DIRECTORY, directory=True)
|
|
directory = DIRECTORY.lstat()
|
|
if directory.st_gid != 0 or stat.S_IMODE(directory.st_mode) != 0o700:
|
|
raise DeliveryError("private maintenance proof directory required")
|
|
preflight.protected(RECORD)
|
|
before = RECORD.lstat()
|
|
if before.st_gid != 0 or stat.S_IMODE(before.st_mode) != 0o400:
|
|
raise DeliveryError("private maintenance proof file required")
|
|
value = read_json(RECORD, MAX_RECORD)
|
|
after = RECORD.lstat()
|
|
if (before.st_dev, before.st_ino, before.st_ctime_ns) != (
|
|
after.st_dev,
|
|
after.st_ino,
|
|
after.st_ctime_ns,
|
|
):
|
|
raise DeliveryError("maintenance proof changed while reading")
|
|
validate(value)
|
|
return value, (
|
|
before.st_dev,
|
|
before.st_ino,
|
|
before.st_mode,
|
|
before.st_nlink,
|
|
before.st_uid,
|
|
before.st_gid,
|
|
before.st_size,
|
|
before.st_mtime_ns,
|
|
before.st_ctime_ns,
|
|
)
|
|
|
|
|
|
def validate(value: dict) -> dict:
|
|
fields(
|
|
value,
|
|
{
|
|
"schema_version",
|
|
"bundle",
|
|
"manifest_sha256",
|
|
"release_id",
|
|
"configuration_id",
|
|
"metadata_sha256",
|
|
"boot_id",
|
|
"issued_boottime_ns",
|
|
"pid",
|
|
"invocation",
|
|
},
|
|
"maintenance bundle proof",
|
|
)
|
|
integer(value["schema_version"], 1, 1, "maintenance proof schema")
|
|
integer(value["pid"], 1, 2**31 - 1, "maintenance proof PID")
|
|
integer(value["issued_boottime_ns"], 0, 2**63 - 1, "maintenance proof time")
|
|
for key, pattern in (
|
|
("release_id", r"sd-[0-9a-f]{12}"),
|
|
("configuration_id", r"cfg-[0-9a-f]{24}"),
|
|
("invocation", r"[0-9a-f]{32}"),
|
|
("boot_id", r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}"),
|
|
):
|
|
if not isinstance(value[key], str) or not re.fullmatch(pattern, value[key]):
|
|
raise DeliveryError("maintenance proof identity differs")
|
|
sha256(value["manifest_sha256"])
|
|
sha256(value["metadata_sha256"])
|
|
return value
|
|
|
|
|
|
def _live(value: dict) -> None:
|
|
if (
|
|
value["boot_id"] != _boot()
|
|
or not 0 <= _now() - value["issued_boottime_ns"] <= MAX_AGE_NS
|
|
or _manager(value["release_id"], value["configuration_id"])
|
|
!= {key: value[key] for key in ("pid", "invocation")}
|
|
):
|
|
raise DeliveryError("maintenance byte proof expired or owner changed")
|
|
|
|
|
|
def _observed(value: dict) -> None:
|
|
"""One fixed journal event per process/invocation, never proof authority."""
|
|
global _REPORTED
|
|
owner = (os.getpid(), value["invocation"])
|
|
if _REPORTED == owner:
|
|
return
|
|
message = (
|
|
"KF complete byte proof reused manifest="
|
|
+ value["manifest_sha256"]
|
|
+ " invocation="
|
|
+ value["invocation"]
|
|
+ "\n"
|
|
).encode()
|
|
if os.write(2, message) != len(message):
|
|
raise DeliveryError("maintenance byte proof observation unavailable")
|
|
_REPORTED = owner
|
|
|
|
|
|
def reuse(bundle: Path, expected: str) -> dict | None:
|
|
"""Missing/ineligible proofs use full bytes; eligible corrupt proofs refuse."""
|
|
allowed_groups = {GROUP + unit: frozenset({0, gid}) for unit, gid in CONSUMERS.items()}.get(
|
|
str(preflight.current_cgroup())
|
|
)
|
|
if allowed_groups is None or not _root(allowed_groups):
|
|
return None
|
|
try:
|
|
RECORD.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
value, identity = _read()
|
|
if value["bundle"] != str(bundle) or value["manifest_sha256"] != expected:
|
|
raise DeliveryError("maintenance byte proof bundle differs")
|
|
_live(value)
|
|
preflight.protected(bundle, directory=True)
|
|
from . import manifest
|
|
|
|
before = manifest._bundle_signature(bundle)
|
|
if _signature(before) != value["metadata_sha256"]:
|
|
raise DeliveryError("maintenance byte proof metadata changed")
|
|
metadata = bundle / "manifest.json"
|
|
if digest(metadata) != expected:
|
|
raise DeliveryError("bundle manifest fingerprint mismatch")
|
|
data = manifest.validate(read_json(metadata))
|
|
if data["release_id"] != value["release_id"]:
|
|
raise DeliveryError("maintenance byte proof release differs")
|
|
manifest._unchanged_bundle(bundle, before)
|
|
reread, current = _read()
|
|
if current != identity or reread != value:
|
|
raise DeliveryError("maintenance byte proof replaced during use")
|
|
_live(value)
|
|
_observed(value)
|
|
return data
|
|
|
|
|
|
@contextmanager
|
|
def publish(selected: dict, bundle: Path):
|
|
"""Read all bytes once; systemd removes the private directory at unit exit."""
|
|
from . import install, manifest, operator_units
|
|
|
|
operator_units.guarded("node-maintenance")
|
|
if not _root() or str(preflight.current_cgroup()) != GROUP + PARENT:
|
|
raise DeliveryError("maintenance byte proof requires its fixed owner")
|
|
manager = _manager(selected["release_id"], selected["configuration_id"])
|
|
if manager["pid"] != os.getpid():
|
|
raise DeliveryError("only maintenance MainPID may publish byte proof")
|
|
if install.selection(check_configuration=False) != selected:
|
|
raise DeliveryError("maintenance proof selection changed")
|
|
preflight.protected(DIRECTORY, directory=True)
|
|
if RECORD.exists() or RECORD.is_symlink():
|
|
raise DeliveryError("existing maintenance byte proof refused")
|
|
created = None
|
|
try:
|
|
with manifest.verification_scope():
|
|
preflight.protected(bundle, directory=True)
|
|
before = manifest._bundle_signature(bundle)
|
|
if any(stamp[4:6] != (0, 0) or stamp[2] & 0o022 for stamp in before.values()):
|
|
raise DeliveryError("maintenance byte proof requires a protected Root tree")
|
|
data = manifest.verify(bundle, selected["manifest_sha256"])
|
|
manifest._unchanged_bundle(bundle, before)
|
|
if data["release_id"] != selected["release_id"]:
|
|
raise DeliveryError("maintenance byte proof release differs")
|
|
value = {
|
|
"schema_version": 1,
|
|
"bundle": str(bundle),
|
|
"manifest_sha256": selected["manifest_sha256"],
|
|
"release_id": selected["release_id"],
|
|
"configuration_id": selected["configuration_id"],
|
|
"metadata_sha256": _signature(before),
|
|
"boot_id": _boot(),
|
|
"issued_boottime_ns": _now(),
|
|
**manager,
|
|
}
|
|
contents = canonical(value)
|
|
if len(contents) > MAX_RECORD:
|
|
raise DeliveryError("maintenance byte proof exceeds bound")
|
|
install.exclusive_file(RECORD, contents, 0o400)
|
|
actual, created = _read()
|
|
if actual != value:
|
|
raise DeliveryError("maintenance byte proof publication differs")
|
|
_live(value)
|
|
yield
|
|
if install.selection(check_configuration=False) != selected:
|
|
raise DeliveryError("maintenance proof selection changed")
|
|
_live(value)
|
|
manifest._unchanged_bundle(bundle, before)
|
|
finally:
|
|
if created is not None:
|
|
actual, identity = _read()
|
|
if identity != created or actual != value:
|
|
raise DeliveryError("foreign maintenance proof; refused cleanup")
|
|
RECORD.unlink()
|
|
install.sync_directory(DIRECTORY)
|