From 3cde1983582f2334c313bad74c7570601df6ffe2 Mon Sep 17 00:00:00 2001 From: kronecker <2026692687@qq.com> Date: Mon, 12 Oct 2026 20:44:40 +0800 Subject: [PATCH] feat(delivery): add guarded offline installation and node configuration --- backend/tests/test_delivery.py | 126 +++++++ deploy/release/README.md | 38 ++ scripts/release/README.md | 41 +++ scripts/release/__main__.py | 59 ++- scripts/release/build.py | 10 +- scripts/release/configuration.py | 597 +++++++++++++++++++++++++++++++ scripts/release/database.py | 177 +++++++++ scripts/release/install.py | 335 +++++++++++++++++ scripts/release/manifest.py | 9 +- scripts/release/policy.py | 32 +- scripts/release/preflight.py | 261 ++++++++++++++ 11 files changed, 1674 insertions(+), 11 deletions(-) create mode 100644 scripts/release/configuration.py create mode 100644 scripts/release/database.py create mode 100644 scripts/release/install.py create mode 100644 scripts/release/preflight.py diff --git a/backend/tests/test_delivery.py b/backend/tests/test_delivery.py index 6f95a95..79aed7c 100644 --- a/backend/tests/test_delivery.py +++ b/backend/tests/test_delivery.py @@ -2,6 +2,7 @@ import importlib import json +import secrets import sys from pathlib import Path @@ -12,6 +13,7 @@ sys.path.insert(0, str(ROOT)) policy = importlib.import_module("scripts.release.policy") manifest = importlib.import_module("scripts.release.manifest") builder = importlib.import_module("scripts.release.build") +configuration = importlib.import_module("scripts.release.configuration") architecture = importlib.import_module("scripts.check_architecture") @@ -111,3 +113,127 @@ def test_signed_guest_library_alias_uses_guest_root(tmp_path): builder.curated(root, records, "services/usr/lib/library.so", target) assert target.read_bytes() == library.read_bytes() assert not target.is_symlink() + + +@pytest.fixture +def delivery_configuration(tmp_path, monkeypatch): + """Synthetic operator files only; no services, DB or user program are run.""" + import hashlib + + images = {} + for name, toolchain, kind, version in ( + ("gnu", "gnu-cxx-v1", "linux_gnu_cxx_x86_64_v1", "16.2.1+r23+gd564253eb6c8"), + ("python313", "cpython313-v1", "linux_cpython313_x86_64_v1", "3.13.16"), + ("java21", "openjdk21-v1", "linux_openjdk21_x86_64_v1", "21.0.12.1+1-LTS"), + ): + images[name] = { + "toolchain_id": toolchain, + "image_kind": kind, + "image_revision": 1, + "runtime_version": version, + "image_digest": hashlib.sha256(name.encode()).hexdigest(), + } + data = { + "images": images, + "release_id": "sd-123456789abc", + "source_sha": "123456789abc" + "d" * 28, + } + node = { + "schema_version": 1, + "origin": "https://oj.test", + "worker_id": "a28adc33-8fbd-4a33-8b21-7a46431e506c", + "measurement_nonce": secrets.token_hex(32), + } + urls = {} + for name, role in configuration.PROFILES.items(): + password = secrets.token_urlsafe(32) + authority = "" if name == "import" else "127.0.0.1:5432" + urls[name] = f"postgresql://{role}:{password}@{authority}/kf_onlinejudge" + if name == "import": + urls[name] += "?host=/run/postgresql" + endpoints = { + "schema_version": 1, + "database": urls, + "amqp": { + name: f"amqp://kf_{name}:{secrets.token_urlsafe(32)}@127.0.0.1:5672/kf" + for name in ("worker", "outbox") + }, + } + configuration.validate_profile(node) + configuration.validate_connections(endpoints) + observed = { + "kernel": "6.18-fixture", + "architecture": "x86_64", + "online_cpus": 2, + "processor": {"model name": "fixture"}, + } + monkeypatch.setattr(configuration, "measured_hardware", lambda: dict(observed)) + raw = configuration.compose(ROOT, data, node, endpoints, attest=True) + paths = {} + for name, content in raw.items(): + path = tmp_path / name + path.write_bytes(content) + path.chmod(0o600) + paths[name] = path + return paths, raw, data, node, endpoints, observed + + +def test_generated_configuration_preserves_wire_policies_and_backend_settings( + delivery_configuration, monkeypatch +): + from kf_oj.core.settings import Settings + + paths, raw, data, _, _, _ = delivery_configuration + configuration.validate_contents(paths, ROOT, data, attest=True) + env = configuration.environment_read(raw["api.env"]) + for key, value in env.items(): + monkeypatch.setenv(key, value) + settings = Settings(_env_file=None) + assert settings.database_url.get_secret_value().startswith("postgresql+asyncpg:") + admission = json.loads(raw["admission.json"]) + coordinator, worker, root = [ + json.loads(raw[name]) for name in ("coordinator.json", "worker.json", "runtime.json") + ] + assert settings.judge_admission.model_dump(mode="json") == admission + assert coordinator["admission"] == worker["admission"] == admission + assert "database_url" not in worker and "database_url" not in root + assert root["identities"]["count"] == 1 + assert root["image_root"] not in [image["image_root"] for image in root["runtime_images"]] + assert admission["legacy"]["toolchain"] == { + "toolchain_id": "gnu-cxx-v1", + "image_kind": "linux_gnu_cxx_x86_64_v1", + "image_revision": 1, + } + + +def test_epoch_rebinds_when_kernel_changes(delivery_configuration): + paths, raw, data, node, endpoints, observed = delivery_configuration + before = json.loads(raw["measurement.json"])["epoch"] + observed["kernel"] = "6.19-fixture" + with pytest.raises(policy.DeliveryError, match="measurement policy mismatch"): + configuration.validate_contents(paths, ROOT, data, attest=True) + after = configuration.compose(ROOT, data, node, endpoints, attest=True) + assert json.loads(after["measurement.json"])["epoch"] != before + + +def test_unattested_configuration_has_no_comparable_epoch(delivery_configuration): + _, _, data, node, endpoints, _ = delivery_configuration + raw = configuration.compose(ROOT, data, node, endpoints, attest=False) + assert json.loads(raw["coordinator.json"])["workers"][0]["measurement_epoch"] is None + + +def test_configuration_receipt_is_bound_before_path_use(): + receipt = { + "schema_version": 1, + "source_sha": "123456789abc" + "d" * 28, + "release_id": "sd-123456789abc", + "manifest_sha256": "f" * 64, + "configuration_files": {name: "b" * 64 for name in configuration.CONFIG_FILES}, + "operator_attested_measurement": False, + "installed": False, + "execution_verified": False, + } + configuration.validate_receipt(receipt, installed=False) + receipt["installed"] = 0 + with pytest.raises(policy.DeliveryError, match="configuration binding"): + configuration.validate_receipt(receipt, installed=False) diff --git a/deploy/release/README.md b/deploy/release/README.md index e19bc1d..efc8e07 100644 --- a/deploy/release/README.md +++ b/deploy/release/README.md @@ -44,3 +44,41 @@ data-loss target; restoring from already prepared release/backup materials has a verification. These are acceptance targets, not results inferred from this file. Installation/service/restore evidence is required before this pre-release can be treated as an accepted delivery; the contract itself does not deploy production. + +The node input for `generate-configuration` has exactly `schema_version: 1`, +`origin` (one HTTPS origin), `worker_id` (canonical nonzero UUID), and +`measurement_nonce` (null or a fresh operator-generated SHA-256-sized nonce). +It is a 0600 single-link file. Endpoint input is a separate 0600 JSON object +with exactly `schema_version: 1`, `database` and `amqp`: database keys are +`api`, `coordinator`, `import`, `migration`, `identity`; broker keys are `outbox` +and `worker`. Login names and numeric loopback/socket locations are validated by +the tool. Every endpoint requires its own operator-supplied private credential; +there is no working example password or shared default. Import uses the reachable +`/run/postgresql` Unix socket; all other control connections use numeric loopback. + +Generated admission uses the actual verified catalogs for C17/C++20, CPython +3.13.16 and OpenJDK 21.0.12.1+1-LTS and the unchanged public per-language budgets. +The protected GNU legacy image is a separate directory: it retains the original +v1 header and identical verified GNU executable/library bytes, while the three +v2 catalog identities remain untouched. Shared Worker/task UID/GID pool and +execution/prefetch/import slots remain one. No canonical judging report or +historical runtime identity is converted by the installer. + +Comparability defaults to unavailable (`measurement_epoch=null`). A trusted +operator may explicitly use `--attest-measurement` only after actual node/service +limits and the intended execution environment have been verified. The attestation +binds a fresh nonce, worker, source, exact images, budgets, CPU/microcode, kernel +and node scheduling snapshot. `check-configuration` refuses an old binding when +those values change; it does not manufacture replacement metrics for old reports. +Before any service resume after such a change, drain execution, generate/install +a new complete private group with a new nonce and independently revalidate the +environment. Raw configuration checks are not performance or isolation evidence. + +The installation entry points are available with `python3.13 -m scripts.release +--help`. Use them in order: verify/preflight, install-files, generate-configuration, +install-configuration, explicit DB identity provision, controlled Backend Alembic +and account CLI operations, then activate and the separately verified bounded +service lifecycle. These operations never contact a package index and do not +install onto a business host on their own. OS dependencies (systemd 262, matching +glibc ABI bytes, PostgreSQL 18.6 and the fixed broker runtime) must already be +present from separately verified offline materials. diff --git a/scripts/release/README.md b/scripts/release/README.md index efecab5..31f577f 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -28,3 +28,44 @@ contains the source and manifest fingerprints, file count and byte total, with `installed=false` and `execution_verified=false`. Installation and service acceptance remain separate operations. See `deploy/release/README.md` for the versioned installation contract and authority split. + +`preflight` reads installation eligibility only. `install-files` requires root, +a completely root-controlled verified input tree and the fixed target profile; +it installs protected versions/images and bounded OS identities, without table +migrations, application accounts, services or the active-version switch. +Repeated identical files converge; unknown modes/owners, changed bytes or an +incomplete staging directory require operator inspection and are never removed. + +`generate-configuration` composes exact public contracts with separately supplied +0600 node and endpoint inputs. It emits a new private directory and a hash-bound +receipt, never prints tokens, never changes a supplied password and never silently +overwrites an earlier identity. API, publisher, import, migration and identity +operations have distinct required database authority; Worker holds only its own +MQ/control identity, Root receives no business credentials. Backend URLs use the +existing asyncpg driver. No uvx/pip or online runtime installation is used. + +`install-configuration` validates the entire group before writing any member; +`check-configuration` checks installed bytes, same-source admission, identity and +hardware/kernel/node scheduler bindings. `activate` requires that check and +stopped control ports, no factory socket and no retained task/identity/capture +resources. It atomically selects an installed directory and starts no process; +draining, migrations and actual health remain separate operations. It does not +erase retained journals or release resources lacking cleanup proof. + +`provision-database-identities` is an explicit local database operator step. +It uses the locked asyncpg dependency, bounded connections/statements and a +120-second overall deadline. It installs only fixed independent logins/NOLOGIN +groups and the application database, verifies every supplied credential and +refuses unexpected existing authority. There is no application table DDL or +automatic account bootstrap: the migration login owns the database without +global superuser/create-role/create-db/bypass-RLS rights; Backend Alembic remains +the only schema/grant migration entry. Runtime logins cannot grant/set group +authority. See [PostgreSQL role membership](https://www.postgresql.org/docs/18/role-membership.html) +and [function ownership](https://www.postgresql.org/docs/18/sql-alterfunction.html). + +Always bootstrap validation from a separately trusted tool copy and an out-of-band +manifest SHA. Do not execute a checker supplied inside an unverified bundle to +establish trust in that same bundle. A verified delivery includes `bin/kf-delivery` +and its source for later offline operations. Command success/receipt generation +does not claim Linux execution, working service roles or installation acceptance; +these require the recorded independent VM evidence. diff --git a/scripts/release/__main__.py b/scripts/release/__main__.py index dda9672..43546c0 100644 --- a/scripts/release/__main__.py +++ b/scripts/release/__main__.py @@ -5,7 +5,7 @@ import json import subprocess from pathlib import Path -from . import build, manifest, policy +from . import build, configuration, database, install, manifest, policy, preflight def main() -> None: @@ -21,6 +21,28 @@ def main() -> None: check.add_argument("--inputs", type=Path, required=True) check.add_argument("--output", type=Path, required=True) check.add_argument("--work", type=Path, required=True) + for name in ("preflight", "install-files"): + check = sub.add_parser(name) + check.add_argument("bundle", type=Path) + check.add_argument("--manifest-sha256", required=True) + check = sub.add_parser("generate-configuration") + check.add_argument("bundle", type=Path) + check.add_argument("--manifest-sha256", required=True) + check.add_argument("--profile", type=Path, required=True) + check.add_argument("--connections", type=Path, required=True) + check.add_argument("--output", type=Path, required=True) + check.add_argument("--attest-measurement", action="store_true") + check = sub.add_parser("install-configuration") + check.add_argument("directory", type=Path) + check.add_argument("--receipt-sha256", required=True) + check = sub.add_parser("check-configuration") + check.add_argument("receipt", type=Path) + check = sub.add_parser("provision-database-identities") + check.add_argument("--operator", type=Path, required=True) + check.add_argument("--connections", type=Path, required=True) + check = sub.add_parser("activate") + check.add_argument("release_id") + check.add_argument("--manifest-sha256", required=True) args = parser.parse_args() try: if args.command == "contract": @@ -36,10 +58,41 @@ def main() -> None: "installed": False, "execution_verified": False, } - else: + elif args.command == "build": result = build.build(args.repo, args.inputs, args.output, args.work) + elif args.command == "preflight": + manifest.verify(args.bundle, args.manifest_sha256) + result = preflight.observe(args.bundle / "payload") + print(json.dumps(result, sort_keys=True)) + if not result["eligible"]: + parser.exit(1) + return + elif args.command == "install-files": + result = install.files(args.bundle, args.manifest_sha256) + elif args.command == "generate-configuration": + result = configuration.generate( + args.bundle, + args.manifest_sha256, + args.profile, + args.connections, + args.output, + attest=args.attest_measurement, + ) + elif args.command == "install-configuration": + result = configuration.install_configuration(args.directory, args.receipt_sha256) + elif args.command == "check-configuration": + receipt = configuration.check_installed(args.receipt) + result = { + "configuration_valid": True, + "source_sha": receipt["source_sha"], + "execution_verified": False, + } + elif args.command == "provision-database-identities": + result = database.provision(args.operator, args.connections) + else: + result = install.activate(args.release_id, args.manifest_sha256) print(json.dumps(result, sort_keys=True)) - except (policy.DeliveryError, OSError, subprocess.SubprocessError) as error: + except (ValueError, OSError, subprocess.SubprocessError) as error: # OSError filenames can include private operator paths; do not echo them. parser.exit( 1, diff --git a/scripts/release/build.py b/scripts/release/build.py index 8ea9492..4ef6600 100644 --- a/scripts/release/build.py +++ b/scripts/release/build.py @@ -13,6 +13,7 @@ from . import manifest from .policy import DeliveryError, contract, digest, fields, read_json, relative_name, sha256 ENTRYPOINTS = { + "kf-delivery": "scripts.release", "kf-api": ( "uvicorn kf_oj.api.app:create_app --factory --host 127.0.0.1 --port 8000 " "--no-proxy-headers --limit-concurrency 32 --backlog 64" @@ -322,6 +323,13 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict: dependency_records(payload / "site") tree(repo / "frontend/dist", payload / "frontend") tracked(repo, "deploy", payload) + for raw in subprocess.check_output( + ["git", "ls-files", "-z", "scripts/release"], cwd=repo + ).split(b"\0"): + if raw: + name = raw.decode() + relative_name("delivery/" + name) + copy(repo / name, payload / "delivery" / name) for name in ("kf-sandbox", "kf-worker", "kf-coordinator"): copy(repo / "judge/target/release" / name, payload / "bin" / name) for name in sorted(records): @@ -374,7 +382,7 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict: "#!/bin/sh\nset -eu\n" 'APP_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)\n' 'export PYTHONHOME="$APP_ROOT/python"\n' - 'export PYTHONPATH="$APP_ROOT/backend/src:$APP_ROOT/site"\n' + 'export PYTHONPATH="$APP_ROOT/backend/src:$APP_ROOT/site:$APP_ROOT/delivery"\n' "export PYTHONDONTWRITEBYTECODE=1\n" 'export LD_LIBRARY_PATH="$APP_ROOT/lib:$APP_ROOT/python/lib"\n' 'cd "$APP_ROOT"\n' diff --git a/scripts/release/configuration.py b/scripts/release/configuration.py new file mode 100644 index 0000000..394e111 --- /dev/null +++ b/scripts/release/configuration.py @@ -0,0 +1,597 @@ +"""Compose public contracts and private identities without application state writes.""" + +import hashlib +import json +import os +import re +import secrets +import stat +import uuid +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from . import manifest, preflight +from .policy import DeliveryError, canonical, digest, fields, read_json, sha256 + +PROFILES = { + "api": "kf_platform", + "coordinator": "kf_coordinator", + "import": "kf_problem_import", + "identity": "kf_identity_operator", + "migration": "kf_migrator", +} +IMPORT_CGROUP = "/sys/fs/cgroup/system.slice/kf-problem-import.service" +CONFIG_FILES = { + "api.env", + "outbox.env", + "import.env", + "identity.env", + "migration.env", + "coordinator.json", + "worker.json", + "runtime.json", + "admission.json", + "measurement.json", +} + + +def private(path: Path) -> None: + info = path.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_uid != os.geteuid() + ): + raise DeliveryError("private single-link operator input required") + + +def profile(path: Path) -> dict: + private(path) + return validate_profile(read_json(path, 16384)) + + +def validate_profile(value: dict) -> dict: + value = fields( + value, + {"schema_version", "origin", "worker_id", "measurement_nonce"}, + "node profile", + ) + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise DeliveryError("unsupported node profile") + origin = value["origin"] + if not isinstance(origin, str) or len(origin) > 253: + raise DeliveryError("invalid browser origin") + parts = urlsplit(origin) + if ( + parts.scheme != "https" + or not parts.hostname + or parts.path + or parts.query + or parts.fragment + or parts.username + or parts.password + or not re.fullmatch(r"[a-z0-9][a-z0-9.-]*[a-z0-9]", parts.hostname) + or parts.port not in (None, 443) + ): + raise DeliveryError("explicit HTTPS origin required") + try: + worker = uuid.UUID(value["worker_id"]) + except (ValueError, TypeError, AttributeError) as error: + raise DeliveryError("invalid worker identity") from error + if str(worker) != value["worker_id"] or worker.int == 0: + raise DeliveryError("canonical nonzero worker UUID required") + if value["measurement_nonce"] is not None: + sha256(value["measurement_nonce"]) + return value + + +def connections(path: Path) -> dict: + private(path) + return validate_connections(read_json(path, 16384)) + + +def validate_connections(value: dict) -> dict: + value = fields(value, {"schema_version", "database", "amqp"}, "service connections") + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise DeliveryError("unsupported service connections") + database = fields(value["database"], set(PROFILES), "database connections") + passwords = set() + for name, raw in database.items(): + if not isinstance(raw, str) or len(raw) > 1024: + raise DeliveryError("invalid database connection") + parts = urlsplit(raw) + unix = ( + name == "import" + and parts.hostname is None + and parse_qs(parts.query, strict_parsing=True) == {"host": ["/run/postgresql"]} + ) + network = ( + name != "import" + and parts.hostname == "127.0.0.1" + and parts.port == 5432 + and not parts.query + ) + if ( + parts.scheme != "postgresql" + or parts.username != PROFILES[name] + or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "") + or parts.path != "/kf_onlinejudge" + or parts.fragment + or not (unix or network) + ): + raise DeliveryError("database authority or loopback/socket policy mismatch") + if parts.password in passwords: + raise DeliveryError("service credentials must be independent") + passwords.add(parts.password) + broker = fields(value["amqp"], {"outbox", "worker"}, "broker connections") + for name, raw in broker.items(): + if not isinstance(raw, str) or len(raw) > 1024: + raise DeliveryError("invalid broker connection") + parts = urlsplit(raw) + if ( + parts.scheme != "amqp" + or parts.hostname != "127.0.0.1" + or parts.port != 5672 + or parts.username != "kf_" + name + or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "") + or parts.path != "/kf" + or parts.query + or parts.fragment + ): + raise DeliveryError("broker authority or loopback policy mismatch") + if parts.password in passwords: + raise DeliveryError("service credentials must be independent") + passwords.add(parts.password) + return value + + +def environmental(values: dict) -> bytes: + """JSON strings are valid systemd EnvironmentFile double-quoted values here. + + Input URLs/passwords cannot contain dollar/backtick/newline expansions; + only trusted JSON admission data and known fixed paths appear otherwise. + """ + return "".join( + key + "=" + json.dumps(value, separators=(",", ":")) + "\n" + for key, value in sorted(values.items()) + ).encode() + + +def measured_hardware() -> dict: + value = preflight.hardware() + # Operator command cgroup does not define execution scheduling. The fixed + # execution/supervisor budgets below are bound separately and read back by + # Sandbox; no transient installer PID/unit name is part of the epoch. + value.pop("parent_limits") + # Bind stable node-wide scheduling and ancestor constraints too. Per-service + # ceilings are in the policy; Sandbox separately verifies their actual + # values on every execution. This is not a transient operator unit snapshot. + value["node_scheduler"] = {} + for label, group in ( + ("root", Path("/sys/fs/cgroup")), + ("system", Path("/sys/fs/cgroup/system.slice")), + ): + value["node_scheduler"][label] = { + name: (group / name).read_text().strip() if (group / name).exists() else None + for name in ("cpu.max", "memory.max", "cpuset.cpus.effective") + } + return value + + +def admission_runtime(payload: Path, data: dict) -> tuple[dict, dict]: + from kf_oj.contracts.judging_v2 import AdmissionRegistry + + admission = read_json(payload / "deploy/judge.admission.example.json", 16384) + root = read_json(payload / "deploy/sandbox.managed.runtime.example.json", 16384) + for cap in admission["multi_runtime"]["capabilities"]: + image = {"c17": "gnu", "cpp20": "gnu", "python313": "python313", "java21": "java21"}[ + cap["language"] + ] + cap["toolchain"] = data["images"][image] + admission = AdmissionRegistry.model_validate(admission).model_dump(mode="json") + state = "/var/lib/kf-judge/images/" + data["release_id"] + root["image_root"] = state + "/legacy-gnu" + root["delegated_cgroup_root"] = "/sys/fs/cgroup/kf-judge-tasks" + root["identities"] = { + "worker_uid": preflight.ACCOUNTS["kf-worker"], + "worker_gid": preflight.ACCOUNTS["kf-worker"], + "first_task_uid": preflight.TASK_UID, + "first_task_gid": preflight.TASK_GID, + "count": 1, + } + for image, entry in zip(("gnu", "python313", "java21"), root["runtime_images"], strict=True): + entry["image_root"] = state + "/" + image + entry["runtime_identity"] = data["images"][image] + if root["max_concurrent_runs"] != 1: + raise DeliveryError("single-slot configuration required") + return admission, root + + +def compose( + payload: Path, + data: dict, + node: dict, + endpoints: dict, + *, + attest: bool, + service_tokens: tuple[str, str] | None = None, +) -> dict: + """Caller has verified the full delivery inventory before this composition.""" + admission, root = admission_runtime(payload, data) + measurement = { + "schema_version": 1, + "hardware": measured_hardware(), + "source_sha": data["source_sha"], + "images": data["images"], + "admission": admission, + "sandbox_budget": root["aggregate"], + "measurement_nonce": node["measurement_nonce"], + "worker_id": node["worker_id"], + } + epoch = None + if attest: + if node["measurement_nonce"] is None: + raise DeliveryError("explicit fresh measurement nonce required for attestation") + epoch = hashlib.sha256(canonical(measurement)).hexdigest() + worker_token, platform_token = service_tokens or (secrets.token_hex(32), secrets.token_hex(32)) + coordinator = { + "listen": "127.0.0.1:8100", + "database_url": endpoints["database"]["coordinator"], + "workers": [{"id": node["worker_id"], "token": worker_token, "measurement_epoch": epoch}], + "platform_token": platform_token, + "admission": admission, + "lease_seconds": 120, + "recovery_seconds": 5, + } + worker = { + "worker_id": node["worker_id"], + "service_token": worker_token, + "coordinator_url": "http://127.0.0.1:8100", + "platform_url": "http://127.0.0.1:8000", + "amqp_url": endpoints["amqp"]["worker"], + "socket": "/var/lib/kf-judge/factory/control.sock", + "staging": "/var/lib/kf-onlinejudge/worker", + "admission": admission, + "control_timeout_ms": 5000, + } + common = { + "KF_OJ_ENVIRONMENT": "production", + "KF_OJ_LOG_LEVEL": "INFO", + "KF_OJ_BROWSER_ORIGINS": canonical([node["origin"]]).decode().strip(), + "KF_OJ_ARTIFACT_ROOT": "/var/lib/kf-onlinejudge/artifacts", + "KF_OJ_IMPORT_STAGING_ROOT": "/var/lib/kf-onlinejudge/imports", + "KF_OJ_ARTIFACT_MAX_BYTES": "536870912", + "KF_OJ_ARTIFACT_MAX_FILES": "4096", + } + + def backend_url(name): + return endpoints["database"][name].replace("postgresql:", "postgresql+asyncpg:", 1) + + api = { + **common, + "KF_OJ_DATABASE_URL": backend_url("api"), + "KF_OJ_JUDGE_ADMISSION": canonical(admission).decode().strip(), + "KF_OJ_COORDINATOR_URL": "http://127.0.0.1:8100", + "KF_OJ_COORDINATOR_TOKEN": platform_token, + } + files = { + "api.env": environmental(api), + "outbox.env": environmental( + { + **common, + "KF_OJ_DATABASE_URL": backend_url("api"), + "KF_OJ_AMQP_URL": endpoints["amqp"]["outbox"], + } + ), + "import.env": environmental( + { + **common, + "KF_OJ_DATABASE_URL": backend_url("import"), + "KF_OJ_IMPORT_WORKER_CGROUP": IMPORT_CGROUP, + } + ), + "identity.env": environmental( + { + **common, + "KF_OJ_DATABASE_URL": backend_url("api"), + "KF_OJ_IDENTITY_OPERATOR_DATABASE_URL": backend_url("identity"), + } + ), + "migration.env": environmental({**common, "KF_OJ_DATABASE_URL": backend_url("migration")}), + "admission.json": canonical(admission), + "runtime.json": canonical(root), + "coordinator.json": canonical(coordinator), + "worker.json": canonical(worker), + "measurement.json": canonical( + {"binding": measurement, "epoch": epoch, "operator_attested": attest} + ), + } + return files + + +def validate_receipt(value: dict, *, installed: bool) -> dict: + value = fields( + value, + { + "schema_version", + "source_sha", + "release_id", + "manifest_sha256", + "configuration_files", + "operator_attested_measurement", + "installed", + "execution_verified", + }, + "configuration receipt", + ) + if ( + type(value["schema_version"]) is not int + or value["schema_version"] != 1 + or not isinstance(value["source_sha"], str) + or not re.fullmatch(r"[0-9a-f]{40}", value["source_sha"]) + or value["release_id"] != "sd-" + value["source_sha"][:12] + or type(value["operator_attested_measurement"]) is not bool + or value["installed"] is not installed + or value["execution_verified"] is not False + ): + raise DeliveryError("invalid configuration binding") + sha256(value["manifest_sha256"]) + names = CONFIG_FILES + if installed: + names = {installed_path(value["release_id"], name).as_posix() for name in CONFIG_FILES} + fields(value["configuration_files"], names, "configuration inventory") + for checksum in value["configuration_files"].values(): + sha256(checksum) + return value + + +def installed_path(release_id: str, name: str) -> Path: + base = "/etc/kf-judge" if name == "runtime.json" else "/etc/kf-onlinejudge" + return Path(base) / (release_id + "." + name) + + +def environment_read(contents: bytes) -> dict: + if len(contents) > 32768: + raise DeliveryError("environment file exceeds limit") + values = {} + for line in contents.decode().splitlines(): + name, sep, raw = line.partition("=") + if not sep or not re.fullmatch(r"KF_OJ_[A-Z_]+", name) or name in values: + raise DeliveryError("invalid service environment") + value = json.loads(raw) + if not isinstance(value, str) or "\n" in value or "\r" in value or "\x00" in value: + raise DeliveryError("invalid service environment value") + values[name] = value + return values + + +def validate_contents(paths: dict[str, Path], payload: Path, data: dict, *, attest: bool) -> None: + """Validate public policy, endpoint roles and epoch binding, without health claims.""" + if set(paths) != CONFIG_FILES: + raise DeliveryError("incomplete service configuration") + raw = {} + for name, path in paths.items(): + private(path) + if path.stat().st_size > 32768: + raise DeliveryError("service configuration exceeds limit") + raw[name] = path.read_bytes() + envs = {name: environment_read(raw[name]) for name in CONFIG_FILES if name.endswith(".env")} + measurement = fields( + read_json(paths["measurement.json"], 32768), + {"binding", "epoch", "operator_attested"}, + "measurement record", + ) + binding = fields( + measurement["binding"], + { + "schema_version", + "hardware", + "source_sha", + "images", + "admission", + "sandbox_budget", + "measurement_nonce", + "worker_id", + }, + "measurement binding", + ) + origins = json.loads(envs["api.env"].get("KF_OJ_BROWSER_ORIGINS", "null")) + if not isinstance(origins, list) or len(origins) != 1: + raise DeliveryError("one explicit browser origin required") + node = validate_profile( + { + "schema_version": 1, + "origin": origins[0], + "worker_id": binding["worker_id"], + "measurement_nonce": binding["measurement_nonce"], + } + ) + coordinator = fields( + read_json(paths["coordinator.json"], 32768), + { + "listen", + "database_url", + "workers", + "platform_token", + "admission", + "lease_seconds", + "recovery_seconds", + }, + "coordinator configuration", + ) + worker = fields( + read_json(paths["worker.json"], 32768), + { + "worker_id", + "service_token", + "coordinator_url", + "platform_url", + "amqp_url", + "socket", + "staging", + "admission", + "control_timeout_ms", + }, + "worker configuration", + ) + + def database_url(name: str, key="KF_OJ_DATABASE_URL"): + raw = envs[name].get(key) + if not isinstance(raw, str) or not raw.startswith("postgresql+asyncpg:"): + raise DeliveryError("backend database driver mismatch") + return raw.replace("postgresql+asyncpg:", "postgresql:", 1) + + endpoints = validate_connections( + { + "schema_version": 1, + "database": { + "api": database_url("api.env"), + "coordinator": coordinator.get("database_url"), + "import": database_url("import.env"), + "migration": database_url("migration.env"), + "identity": database_url("identity.env", "KF_OJ_IDENTITY_OPERATOR_DATABASE_URL"), + }, + "amqp": { + "outbox": envs["outbox.env"].get("KF_OJ_AMQP_URL"), + "worker": worker.get("amqp_url"), + }, + } + ) + worker_token, platform_token = ( + sha256(worker.get("service_token")), + sha256(coordinator.get("platform_token")), + ) + if worker_token == platform_token: + raise DeliveryError("service identities overlap") + expected = compose( + payload, data, node, endpoints, attest=attest, service_tokens=(worker_token, platform_token) + ) + for name in CONFIG_FILES: + # JSON comparisons via canonical bytes distinguish true/1 and null/[]; + # environmental files must have precisely the generated keys/encoding. + if name.endswith(".json"): + match = canonical(read_json(paths[name], 32768)) == expected[name] + else: + match = raw[name] == expected[name] + if not match: + raise DeliveryError("configuration/identity/measurement policy mismatch") + + +def check_installed(receipt_path: Path) -> dict: + from .install import RELEASES + + preflight.protected(receipt_path) + private(receipt_path) + receipt = validate_receipt(read_json(receipt_path, 16384), installed=True) + release = RELEASES / receipt["release_id"] + preflight.protected(release, directory=True) + data = manifest.verify(release, receipt["manifest_sha256"]) + if receipt["source_sha"] != data["source_sha"]: + raise DeliveryError("configuration source differs from release") + paths = {} + for name in CONFIG_FILES: + path = installed_path(receipt["release_id"], name) + preflight.protected(path) + if digest(path) != receipt["configuration_files"][path.as_posix()]: + raise DeliveryError("installed configuration fingerprint differs") + paths[name] = path + validate_contents( + paths, release / "payload", data, attest=receipt["operator_attested_measurement"] + ) + return receipt + + +def generate( + bundle: Path, + expected: str, + node_path: Path, + connections_path: Path, + output: Path, + *, + attest: bool = False, +) -> dict: + data = manifest.verify(bundle, expected) + node, endpoints = profile(node_path), connections(connections_path) + if output.exists() or output.is_symlink(): + raise DeliveryError("configuration output must be new; secrets are never overwritten") + files = compose(bundle / "payload", data, node, endpoints, attest=attest) + output.mkdir(mode=0o700) + hashes = {} + for name, contents in files.items(): + with (output / name).open("xb") as stream: + os.chmod(output / name, 0o600) + stream.write(contents) + hashes[name] = digest(output / name) + receipt = { + "schema_version": 1, + "source_sha": data["source_sha"], + "release_id": data["release_id"], + "manifest_sha256": expected, + "configuration_files": hashes, + "operator_attested_measurement": attest, + "installed": False, + "execution_verified": False, + } + (output / "receipt.json").write_bytes(canonical(receipt)) + (output / "receipt.json").chmod(0o600) + return { + "configuration_generated": True, + "source_sha": data["source_sha"], + "receipt_sha256": digest(output / "receipt.json"), + "operator_attested_measurement": attest, + "installed": False, + "execution_verified": False, + } + + +def install_configuration(source: Path, expected_receipt: str) -> dict: + from .install import RELEASES, exclusive_file + + if os.geteuid() != 0: + raise DeliveryError("configuration installation requires root") + preflight.protected(source, directory=True) + receipt_path = source / "receipt.json" + preflight.protected(receipt_path) + if digest(receipt_path) != sha256(expected_receipt): + raise DeliveryError("configuration receipt fingerprint differs") + receipt = validate_receipt(read_json(receipt_path, 16384), installed=False) + data = manifest.verify(RELEASES / receipt["release_id"], receipt["manifest_sha256"]) + if ( + receipt["source_sha"] != data["source_sha"] + or type(receipt["schema_version"]) is not int + or receipt["schema_version"] != 1 + ): + raise DeliveryError("configuration source mismatch") + if {p.name for p in source.iterdir()} != CONFIG_FILES | {"receipt.json"}: + raise DeliveryError("unexpected configuration member") + # Validate the entire group before publishing any configuration member. + for name, checksum in receipt["configuration_files"].items(): + preflight.protected(source / name) + if digest(source / name) != checksum: + raise DeliveryError("configuration bytes differ") + validate_contents( + {name: source / name for name in CONFIG_FILES}, + RELEASES / data["release_id"] / "payload", + data, + attest=receipt["operator_attested_measurement"], + ) + installed = {} + for name, checksum in receipt["configuration_files"].items(): + member = source / name + preflight.protected(member) + private(member) + if digest(member) != sha256(checksum): + raise DeliveryError("configuration bytes differ") + target = installed_path(data["release_id"], name) + exclusive_file(target, member.read_bytes(), 0o600) + installed[str(target)] = checksum + receipt = {**receipt, "configuration_files": installed, "installed": True} + target = Path("/etc/kf-onlinejudge") / (data["release_id"] + ".configuration.json") + exclusive_file(target, canonical(receipt), 0o600) + return { + "configuration_installed": True, + "source_sha": data["source_sha"], + "receipt_sha256": digest(target), + "execution_verified": False, + } diff --git a/scripts/release/database.py b/scripts/release/database.py new file mode 100644 index 0000000..a0afba3 --- /dev/null +++ b/scripts/release/database.py @@ -0,0 +1,177 @@ +"""Explicit DB identities only; all application schema changes belong to Alembic.""" + +import asyncio +import os +import re +from pathlib import Path +from urllib.parse import urlsplit + +from .configuration import PROFILES, connections, private +from .policy import DeliveryError, fields, read_json + +GROUPS = ( + "kf_oj_platform", + "kf_oj_coordinator", + "kf_oj_problem_import", + "kf_oj_identity_operator", + "kf_oj_identity_authority_owner", +) +MEMBERSHIP = { + "kf_platform": "kf_oj_platform", + "kf_coordinator": "kf_oj_coordinator", + "kf_problem_import": "kf_oj_problem_import", + "kf_identity_operator": "kf_oj_identity_operator", + "kf_migrator": "kf_oj_identity_authority_owner", +} +DATABASE = "kf_onlinejudge" +SETTINGS = { + "statement_timeout": "5000", + "lock_timeout": "1000", + "idle_in_transaction_session_timeout": "5000", + "application_name": "kf-delivery-identities", +} + + +def operator_url(path: Path) -> str: + private(path) + value = fields(read_json(path, 4096), {"schema_version", "url"}, "DB operator connection") + if type(value["schema_version"]) is not int or value["schema_version"] != 1: + raise DeliveryError("unsupported DB operator input") + raw = value["url"] + if not isinstance(raw, str) or len(raw) > 1024: + raise DeliveryError("invalid DB operator connection") + parts = urlsplit(raw) + if ( + parts.scheme != "postgresql" + or parts.hostname != "127.0.0.1" + or parts.port != 5432 + or parts.username != "postgres" + or parts.path != "/postgres" + or parts.query + or parts.fragment + or not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", parts.password or "") + ): + raise DeliveryError("explicit local DB operator identity required") + return raw + + +async def _provision(operator: Path, endpoint_path: Path) -> dict: + import asyncpg + + endpoints = connections(endpoint_path) + db = await asyncpg.connect( + operator_url(operator), + timeout=3, + command_timeout=5, + server_settings={**SETTINGS, "log_min_error_statement": "panic"}, + ) + try: + if not await db.fetchval("SELECT rolsuper FROM pg_roles WHERE rolname=current_user"): + raise DeliveryError("explicit local DB identity operator required") + for name, expected in ( + ("log_statement", "none"), + ("log_min_duration_statement", "-1"), + ("log_min_duration_sample", "-1"), + ("log_transaction_sample_rate", "0"), + ): + if await db.fetchval("SHOW " + name) != expected: + raise DeliveryError("DB statement logging incompatible with secret provisioning") + async with db.transaction(): + for name in (*GROUPS, *PROFILES.values()): + login = name in PROFILES.values() + row = await db.fetchrow( + "SELECT rolcanlogin,rolsuper,rolcreatedb,rolcreaterole," + "rolreplication,rolbypassrls," + "rolconnlimit FROM pg_roles WHERE rolname=$1", + name, + ) + wanted = (login, False, False, False, False, False, 8 if login else -1) + if row is None: + # Names are fixed public constants. The password alphabet + # is validated before any utility SQL is constructed. + statement = ( + "CREATE ROLE " + + name + + (" LOGIN" if login else " NOLOGIN") + + " NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION " + "NOBYPASSRLS INHERIT" + ) + if login: + statement += " CONNECTION LIMIT 8" + key = next(key for key, role in PROFILES.items() if role == name) + password = urlsplit(endpoints["database"][key]).password + statement += " PASSWORD '" + password + "'" + await db.execute(statement) + elif tuple(row) != wanted: + raise DeliveryError("existing DB authority differs; refused overwrite") + memberships = [ + tuple(row) + for row in await db.fetch( + "SELECT r.rolname,m.admin_option,m.inherit_option,m.set_option " + "FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.roleid " + "JOIN pg_roles u ON u.oid=m.member WHERE u.rolname=$1", + name, + ) + ] + expected = ( + [] + if not login + else [(MEMBERSHIP[name], False, name != "kf_migrator", name == "kf_migrator")] + ) + if memberships and memberships != expected: + raise DeliveryError("existing DB role membership differs") + for login, group in MEMBERSHIP.items(): + inherit = "FALSE" if login == "kf_migrator" else "TRUE" + set_role = "TRUE" if login == "kf_migrator" else "FALSE" + await db.execute( + f"GRANT {group} TO {login} WITH ADMIN FALSE, INHERIT {inherit}, SET {set_role}" + ) + existing = await db.fetchval( + "SELECT r.rolname FROM pg_database d JOIN pg_roles r ON r.oid=d.datdba " + "WHERE d.datname=$1", + DATABASE, + ) + if existing is None: + await db.execute( + "CREATE DATABASE kf_onlinejudge OWNER kf_migrator " + "TEMPLATE template0 ENCODING 'UTF8'" + ) + elif existing != "kf_migrator": + raise DeliveryError("existing application database owner differs") + finally: + await db.close(timeout=3) + verified = [] + for name, raw in endpoints["database"].items(): + session = await asyncpg.connect(raw, timeout=3, command_timeout=5, server_settings=SETTINGS) + try: + row = await session.fetchrow("SELECT current_user,current_database()") + if tuple(row) != (PROFILES[name], DATABASE): + raise DeliveryError("DB identity readback differs") + verified.append(name) + finally: + await session.close(timeout=3) + return { + "database_identities_provisioned": sorted(verified), + "application_schema_migrated": False, + "application_accounts_created": False, + "secrets_reset": False, + } + + +def provision(operator: Path, endpoint_path: Path) -> dict: + """No table DDL, no secret reset and no automatic application initialization. + + Pre-created NOLOGIN groups allow the unchanged Backend Alembic migrations + to run under a separate non-superuser database owner. Only this migration + login can SET the authority-function owner; ordinary logins inherit their + own one group and cannot grant it. Unexpected existing authority is refused. + """ + if os.geteuid() != 0: + raise DeliveryError("DB provisioning requires the root operator") + import asyncpg + + try: + return asyncio.run(asyncio.wait_for(_provision(operator, endpoint_path), timeout=120)) + except (asyncpg.PostgresError, asyncpg.InterfaceError, OSError, TimeoutError) as error: + # A server error may include utility SQL; never format it or its query. + raise DeliveryError("DB identity provisioning/readback refused") from error diff --git a/scripts/release/install.py b/scripts/release/install.py new file mode 100644 index 0000000..5df18fd --- /dev/null +++ b/scripts/release/install.py @@ -0,0 +1,335 @@ +"""Root operator file installation, independent of migrations and service start.""" + +import os +import re +import secrets +import shutil +import stat +import subprocess +from pathlib import Path + +from . import manifest, preflight +from .policy import DeliveryError, canonical, digest, read_json, sha256 + +RELEASES = Path("/opt/kf-onlinejudge/releases") +CURRENT = Path("/opt/kf-onlinejudge/current") +IMAGES = Path("/var/lib/kf-judge/images") +LEGACY_HEADER = {"image_kind": "linux_gnu_cxx_x86_64_v1", "architecture": "x86_64", "revision": 1} + + +def directory(path: Path, mode: int) -> None: + if not path.exists() and not path.is_symlink(): + preflight.protected(path.parent, directory=True) + path.mkdir(mode=mode) + preflight.protected(path, directory=True) + if stat.S_IMODE(path.stat().st_mode) != mode: + raise DeliveryError("existing directory mode differs; operator review required") + + +def exclusive_file(path: Path, data: bytes, mode: int) -> None: + preflight.protected(path.parent, directory=True) + if path.exists() or path.is_symlink(): + preflight.protected(path) + if stat.S_IMODE(path.stat().st_mode) != mode or path.read_bytes() != data: + raise DeliveryError("existing installed file differs; refused overwrite") + return + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, mode) + try: + with os.fdopen(fd, "wb", closefd=False) as output: + output.write(data) + output.flush() + os.fsync(fd) + finally: + os.close(fd) + sync_directory(path.parent) + + +def sync_directory(path: Path) -> None: + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def copy_verified(source: Path, target: Path, expected: str, mode: int) -> None: + preflight.protected(source) + if digest(source) != sha256(expected): + raise DeliveryError("source changed after bundle verification") + if target.exists() or target.is_symlink(): + preflight.protected(target) + if digest(target) != expected or stat.S_IMODE(target.stat().st_mode) != mode: + raise DeliveryError("existing release differs; refused overwrite") + return + preflight.protected(target.parent, directory=True) + incoming = os.open(source, os.O_RDONLY | os.O_NOFOLLOW) + outgoing = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + try: + with ( + os.fdopen(incoming, "rb", closefd=False) as src, + os.fdopen(outgoing, "wb", closefd=False) as dst, + ): + shutil.copyfileobj(src, dst, 1024 * 1024) + dst.flush() + os.fsync(outgoing) + if digest(target) != expected: + raise DeliveryError("installed bytes differ; incomplete release retained") + os.fchmod(outgoing, mode) + finally: + os.close(incoming) + os.close(outgoing) + + +def ensure_accounts() -> None: + if preflight.identity_conflicts(): + raise DeliveryError("OS identity conflicts") + import grp + import pwd + + for name, identity in preflight.ACCOUNTS.items(): + try: + grp.getgrnam(name) + except KeyError: + subprocess.run( + ["/usr/bin/groupadd", "--gid", str(identity), name], + check=True, + timeout=10, + capture_output=True, + ) + try: + pwd.getpwnam(name) + except KeyError: + subprocess.run( + [ + "/usr/bin/useradd", + "--uid", + str(identity), + "--gid", + str(identity), + "--no-create-home", + "--no-user-group", + "--key", + "SUB_UID_COUNT=0", + "--key", + "SUB_GID_COUNT=0", + "--home-dir", + "/nonexistent", + "--shell", + "/usr/bin/nologin", + name, + ], + check=True, + timeout=10, + capture_output=True, + ) + if preflight.identity_conflicts(): + raise DeliveryError("OS identity readback differs") + + +def data_directory(path: Path, owner: int) -> None: + preflight.protected(path.parent, directory=True) + if not path.exists() and not path.is_symlink(): + path.mkdir(mode=0o700) + os.chown(path, owner, owner) + info = path.lstat() + if ( + not stat.S_ISDIR(info.st_mode) + or info.st_uid != owner + or info.st_gid != owner + or stat.S_IMODE(info.st_mode) != 0o700 + ): + raise DeliveryError("existing private data directory identity/mode differs") + + +def frozen_copy(source: Path, target: Path, *, path_prefix: str = "") -> None: + """For already verified release images, never accept external archive members.""" + records = manifest.inventory(source, path_prefix=path_prefix) + directories = manifest.directories(source, path_prefix=path_prefix) + if target.exists() or target.is_symlink(): + preflight.protected(target, directory=True) + if ( + manifest.inventory(target, path_prefix=path_prefix) != records + or manifest.directories(target, path_prefix=path_prefix) != directories + ): + raise DeliveryError("existing protected image differs") + for record in records: + preflight.protected(target / record["path"]) + return + target.mkdir(mode=0o700) + for name in sorted(directories, key=lambda name: (name.count("/"), name)): + (target / name).mkdir(mode=0o700) + for record in records: + copy_verified( + source / record["path"], + target / record["path"], + record["sha256"], + int(record["mode"], 8), + ) + for name in sorted(directories, key=lambda name: name.count("/"), reverse=True): + (target / name).chmod(0o555) + sync_directory(target / name) + target.chmod(0o555) + sync_directory(target) + if ( + manifest.inventory(target, path_prefix=path_prefix) != records + or manifest.directories(target, path_prefix=path_prefix) != directories + ): + raise DeliveryError("installed image readback differs") + + +def files(bundle: Path, expected: str) -> dict: + if os.geteuid() != 0: + raise DeliveryError("installer requires root") + preflight.protected(bundle, directory=True) + data = manifest.verify(bundle, expected) + observation = preflight.observe(bundle / "payload") + if not observation["eligible"]: + raise DeliveryError("installation preflight refused: " + "; ".join(observation["errors"])) + # Per-entry ownership matters even when the inventory is identical. + for record in data["files"]: + preflight.protected(bundle / "payload" / record["path"]) + for name in data["directories"]: + preflight.protected(bundle / "payload" / name, directory=True) + ensure_accounts() + for name, mode in ( + ("/opt/kf-onlinejudge", 0o755), + (str(RELEASES), 0o755), + ("/etc/kf-onlinejudge", 0o700), + ("/etc/kf-judge", 0o700), + ("/var/lib/kf-onlinejudge", 0o755), + ("/var/lib/kf-judge", 0o755), + ("/var/lib/kf-judge/tasks", 0o755), + (str(IMAGES), 0o755), + ): + directory(Path(name), mode) + for name in ("artifacts", "imports"): + data_directory(Path("/var/lib/kf-onlinejudge") / name, preflight.ACCOUNTS["kf-oj"]) + data_directory(Path("/var/lib/kf-onlinejudge/worker"), preflight.ACCOUNTS["kf-worker"]) + release = RELEASES / data["release_id"] + if release.exists(): + preflight.protected(release, directory=True) + manifest.verify(release, expected) + for record in data["files"]: + preflight.protected(release / "payload" / record["path"]) + else: + stage = RELEASES / ("." + data["release_id"] + ".installing") + if stage.exists() or stage.is_symlink(): + raise DeliveryError("incomplete release requires explicit operator inspection") + stage.mkdir(mode=0o700) + # `frozen_copy` accepts names through the same bounded allowlist. + frozen_copy(bundle / "payload", stage / "payload") + copy_verified(bundle / "manifest.json", stage / "manifest.json", expected, 0o444) + manifest.verify(stage, expected) + stage.chmod(0o555) + os.rename(stage, release) + sync_directory(RELEASES) + image_base = IMAGES / data["release_id"] + directory(image_base, 0o755) + for name in data["images"]: + frozen_copy( + release / "payload/images" / name, image_base / name, path_prefix="images/catalog/" + ) + legacy = image_base / "legacy-gnu" + if legacy.exists(): + preflight.protected(legacy, directory=True) + if read_json(legacy / "manifest.json") != LEGACY_HEADER: + raise DeliveryError("legacy compatibility image header differs") + wanted = { + r["path"]: r + for r in manifest.inventory(image_base / "gnu", path_prefix="images/catalog/") + if r["path"] != "manifest.json" + } + actual = { + r["path"]: r + for r in manifest.inventory(legacy, path_prefix="images/catalog/") + if r["path"] != "manifest.json" + } + if wanted != actual or ( + manifest.directories(legacy, path_prefix="images/catalog/") + != manifest.directories(image_base / "gnu", path_prefix="images/catalog/") + ): + raise DeliveryError("legacy compatibility image bytes differ") + else: + # This is an explicit v1 compatibility image, not a catalog rewrite. + # All executable/library bytes are identical to the verified GNU image. + frozen_copy(image_base / "gnu", legacy, path_prefix="images/catalog/") + legacy.chmod(0o700) + old = legacy / "manifest.json" + old.chmod(0o600) + old.write_bytes(canonical(LEGACY_HEADER)) + old.chmod(0o444) + legacy.chmod(0o555) + receipt = { + "schema_version": 1, + "release_id": data["release_id"], + "source_sha": data["source_sha"], + "manifest_sha256": expected, + "images": data["images"], + "legacy_header_sha256": digest(legacy / "manifest.json"), + "files_installed": True, + "activated": False, + "migrated": False, + "execution_verified": False, + } + exclusive_file( + Path("/etc/kf-onlinejudge") / (data["release_id"] + ".files.json"), + canonical(receipt), + 0o600, + ) + return receipt + + +def activate(release_id: str, expected: str) -> dict: + """Only files + separately installed exact configuration may be selected. + + This command starts no service, migrates no table, and removes no old version. + Runtime admission/isolation/health remains a separate bounded operation. + """ + if os.geteuid() != 0 or not re.fullmatch(r"sd-[0-9a-f]{12}", release_id): + raise DeliveryError("invalid activation identity or privilege") + release = RELEASES / release_id + preflight.protected(release, directory=True) + data = manifest.verify(release, expected) + config_receipt = Path("/etc/kf-onlinejudge") / (release_id + ".configuration.json") + from .configuration import check_installed + + binding = check_installed(config_receipt) + if ( + binding.get("manifest_sha256") != expected + or binding.get("source_sha") != data["source_sha"] + ): + raise DeliveryError("configuration not bound to selected release") + for port in (8000, 8100): + import socket + + with socket.socket() as check: + try: + check.bind(("127.0.0.1", port)) + except OSError as error: + raise DeliveryError("stop and drain services before activation") from error + if Path("/var/lib/kf-judge/factory/control.sock").exists(): + raise DeliveryError("factory socket present; explicit stop/cleanup required") + for name in ("tasks", "identities", "captures"): + path = Path("/var/lib/kf-judge") / name + if path.exists(): + preflight.protected(path, directory=True) + if next(path.iterdir(), None) is not None: + raise DeliveryError("execution leases or task resources remain; activation refused") + if CURRENT.exists() or CURRENT.is_symlink(): + info = CURRENT.lstat() + if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0: + raise DeliveryError("unknown current entry refused") + old = Path(os.readlink(CURRENT)) + if old.parent.parent != RELEASES or old.name != "payload": + raise DeliveryError("current link outside release layout") + preflight.protected(old, directory=True) + temporary = CURRENT.parent / (".current-" + secrets.token_hex(16)) + temporary.symlink_to(release / "payload") + os.replace(temporary, CURRENT) + sync_directory(CURRENT.parent) + return { + "selected_source_sha": data["source_sha"], + "activated": True, + "services_started": False, + "execution_verified": False, + } diff --git a/scripts/release/manifest.py b/scripts/release/manifest.py index 89fdea0..436bf41 100644 --- a/scripts/release/manifest.py +++ b/scripts/release/manifest.py @@ -24,7 +24,7 @@ from .policy import ( ) -def inventory(root: Path) -> list[dict]: +def inventory(root: Path, *, path_prefix: str = "") -> list[dict]: """No links/devices or inferred inclusion; bounds apply before hashing.""" if not root.is_dir() or root.is_symlink(): raise DeliveryError("payload root must be an ordinary directory") @@ -50,7 +50,8 @@ def inventory(root: Path) -> list[dict]: total += size if total > MAX_BUNDLE_BYTES or len(entries) >= MAX_FILES: raise DeliveryError("payload aggregate limit") - relative = relative_name(path.relative_to(root).as_posix()) + relative = path.relative_to(root).as_posix() + relative_name(path_prefix + relative) entries.append( { "path": relative, @@ -62,13 +63,13 @@ def inventory(root: Path) -> list[dict]: return sorted(entries, key=lambda x: x["path"]) -def directories(root: Path) -> list[str]: +def directories(root: Path, *, path_prefix: str = "") -> list[str]: result = [] for directory, names, _ in os.walk(root, followlinks=False): for name in names: path = Path(directory) / name value = path.relative_to(root).as_posix() - relative_name(value + "/.__directory__") + relative_name(path_prefix + value + "/.__directory__") if path.is_symlink() or stat.S_IMODE(path.stat().st_mode) != 0o555: raise DeliveryError("payload directories must be read-only ordinary directories") result.append(value) diff --git a/scripts/release/policy.py b/scripts/release/policy.py index e352b5a..b79ffed 100644 --- a/scripts/release/policy.py +++ b/scripts/release/policy.py @@ -13,7 +13,17 @@ MAX_BUNDLE_BYTES = 4 * 1024**3 FORMAT = "kf.single-node.directory.v1" PLATFORM = "linux_x86_64_v3_glibc244_systemd" TOOLCHAINS = {"python": "3.13.16", "rust": "1.99.0", "node": "26.10.0", "pnpm": "11.28.4"} -ROOTS = ("backend/", "python/", "site/", "lib/", "bin/", "frontend/", "images/", "deploy/") +ROOTS = ( + "backend/", + "python/", + "site/", + "lib/", + "bin/", + "frontend/", + "images/", + "deploy/", + "delivery/", +) FORBIDDEN = {".git", ".localplan", "docs", "__pycache__", ".env", ".ssh"} @@ -73,11 +83,27 @@ def read_json(path: Path, maximum: int = MAX_METADATA_BYTES) -> dict: if len(data) > maximum: raise DeliveryError("metadata size limit") try: - return json.loads(data, object_pairs_hook=unique_object) - except (UnicodeError, json.JSONDecodeError) as error: + value = json.loads(data, object_pairs_hook=unique_object, parse_constant=invalid_constant) + pending = [(value, 0)] + count = 0 + while pending: + item, depth = pending.pop() + count += 1 + if depth > 32 or count > 400000: + raise DeliveryError("metadata structure limit") + if isinstance(item, dict): + pending.extend((member, depth + 1) for member in item.values()) + elif isinstance(item, list): + pending.extend((member, depth + 1) for member in item) + return value + except (UnicodeError, json.JSONDecodeError, RecursionError) as error: raise DeliveryError("invalid JSON metadata") from error +def invalid_constant(_value: str): + raise DeliveryError("nonfinite JSON value refused") + + def canonical(value: Any) -> bytes: return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode() diff --git a/scripts/release/preflight.py b/scripts/release/preflight.py new file mode 100644 index 0000000..caddc74 --- /dev/null +++ b/scripts/release/preflight.py @@ -0,0 +1,261 @@ +"""Read-only installation eligibility; never an execution isolation permit.""" + +import grp +import os +import platform +import pwd +import re +import shutil +import socket +import stat +import subprocess +from pathlib import Path + +from .policy import DeliveryError, digest + +GIB = 1024**3 +ACCOUNTS = {"kf-oj": 1200, "kf-coordinator": 1201, "kf-worker": 1202} +TASK_UID = 100000 +TASK_GID = 100000 + + +def protected(path: Path, *, directory: bool = False, ancestors: bool = True) -> None: + """Only the deliberate `current` link is handled by the activation module.""" + paths = [path, *path.parents] if ancestors else [path] + for item in paths: + info = item.lstat() + if info.st_uid != 0 or info.st_mode & 0o022 or stat.S_ISLNK(info.st_mode): + raise DeliveryError("root-owned non-writable ordinary path required") + if item != path or directory: + if not stat.S_ISDIR(info.st_mode): + raise DeliveryError("ordinary protected directory required") + elif not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise DeliveryError("ordinary protected single-link file required") + + +def hardware() -> dict: + """Conservative measurement binding; no performance claim is inferred.""" + cpu = Path("/proc/cpuinfo").read_text()[: 1024 * 1024] + keys = {"vendor_id", "cpu family", "model", "model name", "stepping", "microcode", "flags"} + processor = {} + for line in cpu.split("\n\n", 1)[0].splitlines(): + if ":" in line: + key, value = line.split(":", 1) + if key.strip() in keys: + processor[key.strip()] = value.strip() + group = current_cgroup() + limits = { + name: (group / name).read_text().strip() if (group / name).exists() else "max" + for name in ("cpu.max", "memory.max") + } + cpuset = group / "cpuset.cpus.effective" + limits["cpuset.cpus.effective"] = cpuset.read_text().strip() if cpuset.exists() else "" + return { + "kernel": platform.release(), + "architecture": platform.machine(), + "online_cpus": os.cpu_count(), + "processor": processor, + "parent_limits": limits, + } + + +def current_cgroup() -> Path: + value = Path("/proc/self/cgroup").read_text().strip() + if not value.startswith("0::/") or "\n" in value: + raise DeliveryError("unified cgroup v2 required") + return Path("/sys/fs/cgroup") / value.removeprefix("0::/") + + +def identity_conflicts() -> list[str]: + errors = [] + for name, identity in ACCOUNTS.items(): + for lookup, kind in ((pwd.getpwuid, "uid"), (grp.getgrgid, "gid")): + try: + row = lookup(identity) + except KeyError: + continue + if row[0] != name: + errors.append("reserved " + kind + " conflicts") + try: + account = pwd.getpwnam(name) + if (account.pw_uid, account.pw_gid, account.pw_dir, account.pw_shell) != ( + identity, + identity, + "/nonexistent", + "/usr/bin/nologin", + ): + errors.append("existing service identity differs") + except KeyError: + pass + try: + group = grp.getgrnam(name) + if group.gr_gid != identity or group.gr_mem: + errors.append("existing service group differs") + except KeyError: + pass + if any(name in group.gr_mem for group in grp.getgrall() if group.gr_gid != identity): + errors.append("service identity has supplementary authority") + for rows, field, reserved in ( + (pwd.getpwall(), "pw_uid", TASK_UID), + (grp.getgrall(), "gr_gid", TASK_GID), + ): + if any(getattr(row, field) == reserved for row in rows): + errors.append("task identity already assigned") + for name, reserved in (("subuid", TASK_UID), ("subgid", TASK_GID)): + source = Path("/etc") / name + if not source.exists(): + continue + raw = source.read_text() + if len(raw) > 1024 * 1024: + errors.append("subordinate identity file exceeds bound") + continue + for line in raw.splitlines(): + if not line or line.startswith("#"): + continue + fields = line.split(":") + if len(fields) != 3 or not fields[1].isdigit() or not fields[2].isdigit(): + errors.append("subordinate identity allocation invalid") + elif int(fields[1]) <= reserved < int(fields[1]) + int(fields[2]): + errors.append("task identity overlaps subordinate allocation") + return errors + + +def observe(payload: Path, *, require_root: bool = True) -> dict: + errors = [] + observations = {} + if platform.system() != "Linux" or platform.machine() != "x86_64": + errors.append("unsupported architecture") + version = re.match(r"(\d+)\.(\d+)", platform.release()) + if not version or tuple(map(int, version.groups())) < (6, 6): + errors.append("kernel below supported policy") + if require_root and os.geteuid() != 0: + errors.append("installer requires root") + os_release = Path("/etc/os-release").read_text() + if not re.search(r'^ID=(?:"?)(arch|cachyos)(?:"?)$', os_release, re.MULTILINE): + errors.append("unsupported distribution profile") + if Path("/proc/1/comm").read_text().strip() != "systemd": + errors.append("systemd PID1 required") + try: + observations["hardware"] = hardware() + flags = set(observations["hardware"]["processor"].get("flags", "").split()) + required = { + "avx", + "avx2", + "bmi1", + "bmi2", + "f16c", + "fma", + "movbe", + "xsave", + "ssse3", + "sse4_1", + "sse4_2", + "cx16", + "popcnt", + } + if not required <= flags or not flags & {"abm", "lzcnt"}: + errors.append("x86_64-v3 features missing") + if (os.cpu_count() or 0) < 2: + errors.append("two online CPUs required") + controllers = set(Path("/sys/fs/cgroup/cgroup.controllers").read_text().split()) + if not {"cpu", "memory", "pids"} <= controllers: + errors.append("required cgroup controllers missing") + group = current_cgroup() + while True: + if group == Path("/sys/fs/cgroup"): + break + memory_max = (group / "memory.max").read_text().strip() + if memory_max != "max" and int(memory_max) < 7 * GIB // 2: + errors.append("effective memory limit below node capacity") + quota, period = (group / "cpu.max").read_text().split() + if quota != "max" and int(quota) < 2 * int(period): + errors.append("effective CPU quota below two CPUs") + group = group.parent + except (OSError, ValueError): + errors.append("cgroup/CPU observations unavailable") + memory = { + key: int(value.split()[0]) * 1024 + for key, value in ( + line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() + ) + } + observations["memory"] = {key: memory[key] for key in ("MemTotal", "MemAvailable")} + # A 4-GiB physical guest exposes slightly less MemTotal after kernel reserve. + if memory["MemTotal"] < 15 * GIB // 4 or memory["MemAvailable"] < 5 * GIB // 2: + errors.append("physical/available memory below installation capacity") + observations["disk_free_bytes"] = shutil.disk_usage("/var/lib").free + if observations["disk_free_bytes"] < 12 * GIB: + errors.append("installation/data disk reserve below 12 GiB") + for name in ("libc.so.6", "ld-linux-x86-64.so.2"): + system = Path("/usr/lib") / name + try: + protected(system) + if digest(system) != digest(payload / "lib" / name): + errors.append("system GNU ABI bytes differ from verified release") + except (DeliveryError, OSError): + errors.append("system GNU ABI unavailable/unprotected") + for executable in ( + "/usr/bin/systemctl", + "/usr/bin/useradd", + "/usr/bin/groupadd", + "/usr/bin/nologin", + "/usr/bin/postgres", + "/usr/bin/psql", + ): + try: + protected(Path(executable)) + except (DeliveryError, OSError): + errors.append("offline system dependency absent/unprotected") + for path in ( + "/usr/lib/rabbitmq/sbin/rabbitmq-server", + "/usr/lib/rabbitmq/plugins/rabbit-4.3.6/ebin/rabbit.app", + "/usr/lib/erlang/erts-15.2.7/bin/beam.smp", + ): + try: + protected(Path(path)) + except (DeliveryError, OSError): + errors.append("offline broker/OTP dependency absent/unprotected") + broker_version = Path("/usr/lib/rabbitmq/plugins/rabbit-4.3.6/ebin/rabbit.app") + if broker_version.is_file(): + with broker_version.open("rb") as stream: + header = stream.read(65537) + if len(header) > 65536 or not re.search(rb'\{vsn,\s*"4\.3\.6"\}', header): + errors.append("offline broker version differs") + for executable, expected in ( + ("/usr/bin/systemctl", "systemd 262"), + ("/usr/bin/postgres", "(PostgreSQL) 18.6"), + ): + try: + result = subprocess.run( + [executable, "--version"], + capture_output=True, + text=True, + timeout=5, + env={"PATH": "/usr/bin", "LC_ALL": "C"}, + ) + if result.returncode or expected not in result.stdout[:4096]: + errors.append("offline system dependency version differs") + except (OSError, subprocess.SubprocessError): + errors.append("offline system dependency unavailable") + errors += identity_conflicts() + for directory in ("/opt", "/etc", "/var/lib"): + try: + protected(Path(directory), directory=True) + except (OSError, DeliveryError): + errors.append("installation ancestor unprotected") + # No socket is opened for a running service and no peer is contacted. + for port in (8000, 8100): + with socket.socket() as check: + try: + check.bind(("127.0.0.1", port)) + except OSError: + errors.append("application control port occupied") + if Path("/var/lib/kf-judge/factory/control.sock").exists(): + errors.append("factory socket exists; drain and stop before installation") + return { + "eligible": not errors, + "errors": sorted(set(errors)), + "observations": observations, + "installed": False, + "execution_verified": False, + }