420 lines
17 KiB
Python
420 lines
17 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,
|
|
preflight,
|
|
proxy_runtime,
|
|
proxy_service,
|
|
service_install,
|
|
)
|
|
from .policy import 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
|
|
|
|
|
|
def check_binding(release: str, batch: str) -> dict:
|
|
if os.geteuid() != 0:
|
|
raise DeliveryError("protected proxy operator required")
|
|
from .node_health import binding
|
|
|
|
# Reuse the protected live configuration and source binding. Proxy startup
|
|
# does not recertify unrelated judge images; Sandbox checks each execution.
|
|
selected, _, _ = binding()
|
|
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)
|
|
|
|
|
|
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",
|
|
"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()
|