Files
KF-OnlineJudge/scripts/release/proxy_install.py
T

492 lines
19 KiB
Python

"""Protected HTTPS installation; no default keys, schema migration or server start."""
import argparse
import grp
import json
import os
import pwd
import secrets
import ssl
import stat
import subprocess
from pathlib import Path
from . import (
configuration,
install,
journal,
manifest,
preflight,
proxy_runtime,
proxy_service,
service_install,
)
from .policy import FORMAT, DeliveryError, canonical, digest, fields, read_json, sha256
def account(*, create: bool = False, check_lock: bool = False) -> None:
name, uid = proxy_service.USER, proxy_service.UID
matches = [user for user in pwd.getpwall() if user.pw_name == name or user.pw_uid == uid]
groups = [group for group in grp.getgrall() if group.gr_name == name or group.gr_gid == uid]
if not matches and not groups and create:
for args in (
["/usr/bin/groupadd", "--system", "--gid", str(uid), name],
[
"/usr/bin/useradd",
"--system",
"--uid",
str(uid),
"--gid",
str(uid),
"--home-dir",
"/nonexistent",
"--shell",
"/usr/bin/nologin",
"--no-user-group",
name,
],
):
p = subprocess.run(
args, env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"}, capture_output=True, timeout=10
)
if p.returncode:
raise DeliveryError("proxy account provisioning refused")
return account(check_lock=True)
if (
len(matches) != 1
or len(groups) != 1
or matches[0].pw_name != name
or matches[0].pw_uid != uid
or matches[0].pw_gid != uid
or matches[0].pw_dir != "/nonexistent"
or matches[0].pw_shell != "/usr/bin/nologin"
or groups[0].gr_name != name
or groups[0].gr_gid != uid
or groups[0].gr_mem
):
raise DeliveryError("conflicting ordinary proxy account")
if os.getgrouplist(name, uid) != [uid]:
raise DeliveryError("proxy supplementary authority refused")
if create or check_lock:
preflight.protected(Path("/etc/shadow"))
rows = [
line.split(":")
for line in journal.bounded_text(Path("/etc/shadow"), 1024**2).splitlines()
]
selected = [row for row in rows if row[0] == name]
if len(selected) != 1 or len(selected[0]) != 9 or selected[0][1] not in ("!", "!!", "*"):
raise DeliveryError("proxy OS login must remain locked")
def storage(*, allow_read_only: bool = False) -> dict:
path = proxy_service.DATA
preflight.protected(path.parent, directory=True)
info = path.lstat()
if (
not stat.S_ISDIR(info.st_mode)
or info.st_uid != proxy_service.UID
or info.st_gid != proxy_service.UID
or stat.S_IMODE(info.st_mode) != 0o700
):
raise DeliveryError("private ordinary proxy hard volume required")
rows = [
line.split()
for line in journal.bounded_text(Path("/proc/self/mountinfo"), 1024**2).splitlines()
]
found = [row for row in rows if len(row) > 6 and row[4] == str(path)]
if len(found) != 1:
raise DeliveryError("dedicated proxy volume required")
row = found[0]
index = row.index("-")
vfs = os.statvfs(path)
if (
row[3] != "/"
or row[index + 1] != "ext4"
or not {"nosuid", "nodev", "noexec"} <= set(row[5].split(","))
or set(row[5].split(",")) & {"ro", "rw"}
not in (({"ro"}, {"rw"}) if allow_read_only else ({"rw"},))
or not proxy_service.CAPACITY * 9 // 10
<= vfs.f_frsize * vfs.f_blocks
<= proxy_service.CAPACITY
or any(other[2] == row[2] and other != row for other in rows)
):
raise DeliveryError("proxy hard storage capacity/mount differs")
return {
"capacity_bytes": vfs.f_frsize * vfs.f_blocks,
"available_bytes": vfs.f_frsize * vfs.f_bavail,
}
def known_link(path: Path) -> Path | None:
try:
info = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISLNK(info.st_mode) or info.st_uid != 0 or info.st_gid != 0:
raise DeliveryError("unknown proxy system unit")
target = Path(os.readlink(path))
if (
target.name != proxy_service.UNIT
or target.parent.parent != proxy_service.UNITS
or target.as_posix() != os.readlink(path)
):
raise DeliveryError("unknown proxy unit pointer")
preflight.protected(target.parent / "binding.json")
configuration.private(target.parent / "binding.json")
selected = read_json(target.parent / "binding.json", 4096)
fields(
selected,
{"schema_version", "release_id", "configuration_id", "manifest_sha256", "receipt_sha256"},
"proxy unit binding",
)
if type(selected["schema_version"]) is not int or selected["schema_version"] != 1:
raise DeliveryError("proxy unit binding version differs")
sha256(selected["manifest_sha256"])
sha256(selected["receipt_sha256"])
if target.parent.name != proxy_service.batch(selected).name:
raise DeliveryError("proxy unit snapshot binding differs")
preflight.protected(target)
preflight.protected(target.parent / "binding.json")
if (
target.read_bytes() != proxy_service.render(selected)
or stat.S_IMODE(target.stat().st_mode) != 0o644
):
raise DeliveryError("proxy unit snapshot changed")
return target.parent
STARTUP_MODULES = (
"__init__.py",
"configuration.py",
"install.py",
"layout.py",
"journal.py",
"manifest.py",
"policy.py",
"preflight.py",
"proxy.py",
"proxy_guard.py",
"proxy_install.py",
"proxy_runtime.py",
"proxy_service.py",
"service_install.py",
"services.py",
"service_profiles.py",
)
def startup_selection() -> dict:
"""Bind only the protected proxy source; do not load judging contracts."""
selected = install.selection(check_configuration=False)
payload = install.RELEASES / selected["release_id"] / "payload"
receipt_path = configuration.installed_receipt_path(
selected["release_id"], selected["configuration_id"]
)
preflight.protected(receipt_path)
configuration.private(receipt_path)
receipt = configuration.validate_receipt(read_json(receipt_path, 16384), installed=True)
if digest(receipt_path) != selected["receipt_sha256"] or any(
receipt[key] != selected[key]
for key in ("release_id", "configuration_id", "manifest_sha256")
):
raise DeliveryError("selected proxy receipt changed")
manifest_path = payload.parent / "manifest.json"
preflight.protected(manifest_path)
if digest(manifest_path) != selected["manifest_sha256"]:
raise DeliveryError("selected proxy manifest changed")
data = read_json(manifest_path)
if (
data.get("format") != FORMAT
or type(data.get("schema_version")) is not int
or data["schema_version"] != 1
or data.get("source_sha") != receipt["source_sha"]
or data.get("release_id") != selected["release_id"]
or not isinstance(data.get("files"), list)
):
raise DeliveryError("selected proxy source binding changed")
# The installer certifies the full frozen inventory. Startup independently
# reads back just this helper's source closure against that protected catalog;
# it neither imports admission models nor recomputes judge measurement policy.
for name in STARTUP_MODULES:
relative = "delivery/scripts/release/" + name
records = [entry for entry in data["files"] if entry.get("path") == relative]
if len(records) != 1:
raise DeliveryError("selected proxy source inventory changed")
entry = records[0]
shipped = payload / relative
preflight.protected(shipped)
info = shipped.stat()
if (
entry.get("mode") != "0444"
or stat.S_IMODE(info.st_mode) != 0o444
or info.st_size != entry.get("bytes")
or digest(shipped) != sha256(entry.get("sha256"))
or digest(Path(__file__).parent / name) != entry["sha256"]
):
raise DeliveryError("proxy tool differs from selected delivery")
return selected
def check_binding(release: str, batch: str) -> dict:
if os.geteuid() != 0:
raise DeliveryError("protected proxy operator required")
selected = startup_selection()
if (release, batch) != (selected["release_id"], selected["configuration_id"]):
raise DeliveryError("proxy unit belongs to another selected configuration")
directory = proxy_service.batch(selected)
spec = read_json(directory / "launch.json", 4096)
for member in (directory / "launch.json", directory / "nginx.conf"):
preflight.protected(member)
if member.stat().st_gid != 0 or stat.S_IMODE(member.stat().st_mode) != 0o644:
raise DeliveryError("protected public proxy configuration required")
fields(
spec,
{
"schema_version",
"release_id",
"configuration_id",
"origin",
"configuration_sha256",
"certificate_sha256",
"key_sha256",
},
"proxy launch spec",
)
if (
spec["schema_version"] != 1
or type(spec["schema_version"]) is not int
or (spec["release_id"], spec["configuration_id"]) != (release, batch)
or digest(directory / "nginx.conf") != spec["configuration_sha256"]
):
raise DeliveryError("proxy configuration binding changed")
from .proxy import validate_profile
validate_profile(
{
"schema_version": 1,
"origin": spec["origin"],
"certificate_sha256": spec["certificate_sha256"],
"key_sha256": spec["key_sha256"],
}
)
for name, key, mode, gid in [
("fullchain.pem", "certificate_sha256", 0o644, 0),
("key.pem", "key_sha256", 0o640, proxy_service.UID),
]:
p = proxy_service.ROOT / "tls" / name
preflight.protected(p)
i = p.stat()
if (
i.st_gid != gid
or stat.S_IMODE(i.st_mode) != mode
or i.st_size > 65536
or digest(p) != spec[key]
):
raise DeliveryError("proxy TLS input changed")
journal.check_profile(allow_read_only=True)
storage(allow_read_only=True)
account(check_lock=True)
if install.selection(check_configuration=False) != selected:
raise DeliveryError("selected proxy binding changed during startup check")
return {"binding_valid": True, "tls_verified": False, "execution_verified": False}
def temporary_directories() -> None:
for name in ("body", "proxy", "fastcgi", "uwsgi", "scgi"):
temporary_directory = proxy_service.DATA / name
if temporary_directory.exists() or temporary_directory.is_symlink():
i = temporary_directory.lstat()
if (
not stat.S_ISDIR(i.st_mode)
or i.st_uid != proxy_service.UID
or i.st_gid != proxy_service.UID
or stat.S_IMODE(i.st_mode) != 0o700
):
raise DeliveryError("unknown proxy temporary directory")
else:
temporary_directory.mkdir(mode=0o700)
os.chown(temporary_directory, proxy_service.UID, proxy_service.UID)
@manifest.verified_operation
def install_proxy(source: Path, expected: str, certificate: Path, key: Path) -> dict:
if os.getuid() != 0 or os.geteuid() != 0 or os.getgid() != 0:
raise DeliveryError("root proxy installer required")
preflight.protected(source, directory=True)
receipt_path = source / "receipt.json"
preflight.protected(receipt_path)
configuration.private(receipt_path)
if digest(receipt_path) != sha256(expected):
raise DeliveryError("proxy receipt fingerprint differs")
receipt = proxy_service.validate_receipt(read_json(receipt_path, 8192))
selected = install.selection()
spec = proxy_service.launch_spec(selected, receipt)
payload = install.RELEASES / selected["release_id"] / "payload"
for name in (
"proxy.py",
"proxy_service.py",
"proxy_install.py",
"manifest.py",
"proxy_guard.py",
"proxy_runtime.py",
):
p = payload / "delivery/scripts/release" / name
preflight.protected(p)
if digest(p) != digest(Path(__file__).parent / name):
raise DeliveryError("proxy installer differs from selected delivery")
runtime = payload / "proxy"
preflight.protected(runtime / "manifest.json")
data = proxy_runtime.verify(runtime, digest(runtime / "manifest.json"))
# The selected manifest already bound every installed byte; local catalog
# verification here is an additional runtime-tree check, not a trust origin.
inventory = read_json(payload.parent / "manifest.json")["files"]
item = next((entry for entry in inventory if entry["path"] == "proxy/manifest.json"), None)
if item is None or item["sha256"] != digest(runtime / "manifest.json"):
raise DeliveryError("proxy catalog absent from selected manifest")
env = configuration.environment_read(
configuration.installed_path(
selected["release_id"], selected["configuration_id"], "api.env"
).read_bytes()
)
if json.loads(env["KF_OJ_BROWSER_ORIGINS"]) != [receipt["origin"]]:
raise DeliveryError("proxy browser origin differs from API batch")
if {p.name for p in source.iterdir()} != {"receipt.json", "nginx.conf"}:
raise DeliveryError("unknown proxy source member")
preflight.protected(source / "nginx.conf")
configuration.private(source / "nginx.conf")
contents = proxy_service.configuration(receipt, selected["release_id"])
if (source / "nginx.conf").read_bytes() != contents or digest(source / "nginx.conf") != spec[
"configuration_sha256"
]:
raise DeliveryError("proxy is not fixed trusted composition")
for p, fingerprint in ((certificate, spec["certificate_sha256"]), (key, spec["key_sha256"])):
preflight.protected(p)
configuration.private(p)
if not 1 <= p.stat().st_size <= 65536 or digest(p) != fingerprint:
raise DeliveryError("explicit bounded TLS material differs")
install.require_stopped()
account(create=True)
# Storage provisioning is an explicit operator step; no format/mount and no
# recursive ownership change of existing data are performed here.
storage()
journal.check_profile()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
def no_password_prompt():
raise DeliveryError("explicit unencrypted private TLS file required; no interactive prompt")
ctx.load_cert_chain(certificate, key, password=no_password_prompt)
system = service_install.SYSTEM_UNITS / proxy_service.UNIT
known_link(system)
install.directory(proxy_service.ROOT, 0o711)
install.directory(proxy_service.ROOT / "tls", 0o711)
directory = proxy_service.batch(selected)
install.directory(directory, 0o755)
if not {p.name for p in directory.iterdir()} <= {"nginx.conf", "launch.json"}:
raise DeliveryError("unknown proxy configuration snapshot")
install.exclusive_file(
proxy_service.ROOT / "tls" / "fullchain.pem", certificate.read_bytes(), 0o644
)
private = proxy_service.ROOT / "tls" / "key.pem"
if private.exists() or private.is_symlink():
preflight.protected(private)
i = private.stat()
if (
stat.S_IMODE(i.st_mode) != 0o640
or i.st_gid != proxy_service.UID
or digest(private) != spec["key_sha256"]
):
raise DeliveryError("unknown existing TLS key; refused rotation")
else:
install.exclusive_file(private, key.read_bytes(), 0o640)
os.chown(private, 0, proxy_service.UID)
install.exclusive_file(directory / "nginx.conf", contents, 0o644)
install.exclusive_file(directory / "launch.json", canonical(spec), 0o644)
install.directory(proxy_service.UNITS, 0o700)
unitdir = proxy_service.UNITS / directory.name
install.directory(unitdir, 0o700)
install.exclusive_file(unitdir / "binding.json", canonical(selected), 0o600)
install.exclusive_file(unitdir / proxy_service.UNIT, proxy_service.render(selected), 0o644)
temporary_directories()
check_binding(selected["release_id"], selected["configuration_id"])
install.require_stopped()
if install.selection() != selected:
raise DeliveryError("proxy selection changed before unit switch")
old = known_link(system)
if old != unitdir:
temporary = service_install.SYSTEM_UNITS / (".kf-proxy-" + secrets.token_hex(16))
temporary.symlink_to(unitdir / proxy_service.UNIT)
try:
os.replace(temporary, system)
install.sync_directory(service_install.SYSTEM_UNITS)
finally:
if temporary.is_symlink():
temporary.unlink()
service_install.systemctl("daemon-reload")
props = dict(
line.split("=", 1)
for line in service_install.systemctl(
"show", proxy_service.UNIT, "--property=LoadState,ActiveState,MainPID,FragmentPath"
).splitlines()
if "=" in line
)
if (
props.get("LoadState") != "loaded"
or props.get("ActiveState") != "inactive"
or props.get("MainPID") != "0"
or props.get("FragmentPath") not in {str(system), str(unitdir / proxy_service.UNIT)}
):
raise DeliveryError("proxy loaded/inactive readback unconfirmed")
return {
"installed": True,
"runtime_version": data["version"],
"source_sha": receipt["source_sha"],
"configuration_id": selected["configuration_id"],
"tls_material_parsed": True,
"tls_verified": False,
"services_started": False,
"execution_verified": False,
}
def main() -> None:
parser = argparse.ArgumentParser(description="Fixed protected HTTPS installation")
parser.add_argument("--check-binding", action="store_true")
parser.add_argument("--release-id")
parser.add_argument("--configuration-id")
parser.add_argument("--source", type=Path)
parser.add_argument("--receipt-sha256")
parser.add_argument("--certificate", type=Path)
parser.add_argument("--private-key", type=Path)
args = parser.parse_args()
try:
if args.check_binding:
if (
not args.release_id
or not args.configuration_id
or any((args.source, args.receipt_sha256, args.certificate, args.private_key))
):
raise DeliveryError("explicit binding-only arguments required")
result = check_binding(args.release_id, args.configuration_id)
else:
if any((args.release_id, args.configuration_id)) or not all(
(args.source, args.receipt_sha256, args.certificate, args.private_key)
):
raise DeliveryError("explicit installation inputs required")
result = install_proxy(
args.source, args.receipt_sha256, args.certificate, args.private_key
)
print(json.dumps(result, sort_keys=True))
except (OSError, ValueError, subprocess.SubprocessError) as error:
parser.exit(
1,
"Proxy installation refused: "
+ (str(error) if isinstance(error, DeliveryError) else type(error).__name__)
+ "\n",
)
if __name__ == "__main__":
main()