feat(release): install and supervise the bounded HTTPS entry
This commit is contained in:
@@ -83,7 +83,7 @@ these require the recorded independent VM evidence.
|
||||
`generate-proxy` is a separate, offline composition entry. Its private 0600
|
||||
profile has exactly `schema_version: 1`, one canonical HTTPS DNS `origin`,
|
||||
`certificate_sha256` and `key_sha256`. TLS inputs are independently supplied
|
||||
later at `/etc/kf-onlinejudge/tls/fullchain.pem` and `key.pem`; the generator
|
||||
later at `/etc/kf-proxy/tls/fullchain.pem` and `key.pem`; the generator
|
||||
does not read, create or verify a private key. It emits a new private NGINX
|
||||
configuration and receipt tied to the verified release. No arbitrary upstream,
|
||||
include, path, credentials or resource overrides are accepted. Only browser API
|
||||
@@ -340,3 +340,34 @@ are redirected through that formatter; installed API launchers disable access
|
||||
logging so request query strings are not copied into service logs. Development
|
||||
diagnostics remain separate. These ordinary formatting checks are not evidence
|
||||
of actual daemon, HTTP or upload log privacy; that is still a VM acceptance case.
|
||||
|
||||
Build-input version 2 adds an explicitly fingerprinted public NGINX 1.30.5
|
||||
runtime catalog. Version 1 remains available for older engineering bundles without
|
||||
proxy support; there is no implicit proxy default. The separate `proxy_runtime`
|
||||
module permits only the binary and bounded shared libraries, excludes TLS and
|
||||
configuration files, verifies every byte/mode and ships them under `payload/proxy`.
|
||||
This separate library search tree avoids silently replacing existing application,
|
||||
database or language-image libraries. Binary version/signature/ELF-closure evidence
|
||||
is recorded by the operator; accepting a structural catalog is not TLS/server or
|
||||
Linux-safety acceptance. Actual proxy installation and execution remain separate.
|
||||
|
||||
`python3.13 -m scripts.release.proxy_install --source DIRECTORY
|
||||
--receipt-sha256 SHA --certificate PRIVATE_CERTIFICATE --private-key PRIVATE_KEY`
|
||||
installs the explicitly supplied protected proxy snapshot and matching unencrypted
|
||||
TLS files. It requires the same release/API-origin batch, a distinct locked
|
||||
UID/GID 1205 (`kf-proxy`) without supplementary groups, and an already provisioned
|
||||
private 128-MiB ext4 volume at `/var/lib/kf-onlinejudge/proxy`. Provision that
|
||||
volume's ownership/mode explicitly; the installer does not format, mount or
|
||||
recursively reassign unknown data. Existing TLS material must match its fingerprint;
|
||||
this entry does not silently rotate keys or claim DNS/public-browser trust.
|
||||
|
||||
Proxy modules separate public runtime verification, fixed service rendering,
|
||||
Root installation, ordinary launch guarding and read-only HTTPS observation.
|
||||
The ordinary process retains only `CAP_NET_BIND_SERVICE` for 443, no-new-privileges,
|
||||
private PID visibility and actual CPU/memory/swap/PIDs/file bounds. It receives
|
||||
no application DB/MQ/control credentials. Installation preserves older snapshots,
|
||||
refuses foreign system-unit pointers, reads back loaded/inactive metadata and
|
||||
starts no server. Maintenance includes the proxy in start/drain/stop ordering.
|
||||
Readiness uses fixed loopback TLS with the supplied certificate as an explicit
|
||||
local trust anchor and checks hostname/leaf identity plus the API live response;
|
||||
it does not substitute for actual browser CA/HTTPS/security acceptance in the VM.
|
||||
|
||||
@@ -9,7 +9,7 @@ import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from . import manifest
|
||||
from . import manifest, proxy_runtime
|
||||
from .policy import DeliveryError, contract, digest, fields, read_json, relative_name, sha256
|
||||
|
||||
ENTRYPOINTS = {
|
||||
@@ -227,9 +227,19 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict:
|
||||
"runtime_manifest_sha256",
|
||||
"images",
|
||||
}
|
||||
spec = fields(read_json(inputs, 16384), fields_spec, "build inputs")
|
||||
if type(spec["schema_version"]) is not int or spec["schema_version"] != 1:
|
||||
spec = read_json(inputs, 16384)
|
||||
if (
|
||||
not isinstance(spec, dict)
|
||||
or type(spec.get("schema_version")) is not int
|
||||
or spec["schema_version"] not in (1, 2)
|
||||
):
|
||||
raise DeliveryError("unsupported build inputs")
|
||||
if spec["schema_version"] == 2:
|
||||
fields_spec.add("proxy")
|
||||
fields(spec, fields_spec, "build inputs")
|
||||
if spec["schema_version"] == 2:
|
||||
fields(spec["proxy"], {"root", "catalog_sha256"}, "proxy build input")
|
||||
proxy_runtime.verify(Path(spec["proxy"]["root"]), spec["proxy"]["catalog_sha256"])
|
||||
root = Path(spec["runtime_root"]).resolve(strict=True)
|
||||
trusted_manifest = Path(spec["runtime_manifest"])
|
||||
if digest(trusted_manifest) != sha256(spec["runtime_manifest_sha256"]):
|
||||
@@ -391,6 +401,11 @@ def build(repo: Path, inputs: Path, output: Path, work: Path) -> dict:
|
||||
target = payload / "bin" / name
|
||||
target.write_text(wrapper)
|
||||
target.chmod(0o555)
|
||||
if spec["schema_version"] == 2:
|
||||
fields(spec["proxy"], {"root", "catalog_sha256"}, "proxy build input")
|
||||
proxy_runtime.copy_to_payload(
|
||||
Path(spec["proxy"]["root"]), spec["proxy"]["catalog_sha256"], payload / "proxy"
|
||||
)
|
||||
for directory, directories, _ in os.walk(payload, topdown=False):
|
||||
for name in directories:
|
||||
(Path(directory) / name).chmod(0o555)
|
||||
|
||||
@@ -21,6 +21,8 @@ from . import (
|
||||
node_health,
|
||||
operator_units,
|
||||
preflight,
|
||||
proxy_install,
|
||||
proxy_service,
|
||||
service_install,
|
||||
services,
|
||||
)
|
||||
@@ -28,7 +30,7 @@ from .policy import DeliveryError, canonical, digest, fields, read_json, sha256
|
||||
|
||||
STATE = Path("/var/lib/kf-onlinejudge/operations")
|
||||
LAST = STATE / "maintenance.last.json"
|
||||
APPS = ("api", "outbox", "worker", "problem-import", "coordinator", "sandbox-factory")
|
||||
APPS = ("proxy", "api", "outbox", "worker", "problem-import", "coordinator", "sandbox-factory")
|
||||
DEPENDENCIES = ("postgres", "rabbitmq")
|
||||
START_ORDER = (
|
||||
"sandbox-factory",
|
||||
@@ -37,6 +39,7 @@ START_ORDER = (
|
||||
"outbox",
|
||||
"problem-import",
|
||||
"worker",
|
||||
"proxy",
|
||||
)
|
||||
DRAIN_SECONDS = 360
|
||||
|
||||
@@ -263,6 +266,9 @@ class Maintenance:
|
||||
if name in services.PROFILES:
|
||||
directory = service_install.known_link(path)
|
||||
base = service_install.UNITS
|
||||
elif name == "proxy":
|
||||
directory = proxy_install.known_link(path)
|
||||
base = proxy_service.UNITS
|
||||
elif name == "sandbox-factory":
|
||||
directory = factory_service.known_link(path)
|
||||
base = factory_service.UNITS
|
||||
@@ -419,7 +425,7 @@ class Maintenance:
|
||||
time.sleep(2)
|
||||
# Every process/child and final Root resource proof remains required.
|
||||
# No row, notification, identity lease or replay journal is removed here.
|
||||
for name in ("api", "outbox", "problem-import", "worker"):
|
||||
for name in ("proxy", "api", "outbox", "problem-import", "worker"):
|
||||
self.change(name, "stop")
|
||||
final = self.snapshot()
|
||||
if not projection_empty(final):
|
||||
|
||||
@@ -21,6 +21,9 @@ from . import (
|
||||
journal,
|
||||
operator_units,
|
||||
preflight,
|
||||
proxy_health,
|
||||
proxy_install,
|
||||
proxy_service,
|
||||
service_install,
|
||||
services,
|
||||
)
|
||||
@@ -68,6 +71,11 @@ def binding() -> tuple[dict, dict, Path]:
|
||||
"operator_units.py",
|
||||
"outbox_health.py",
|
||||
"journal.py",
|
||||
"proxy_service.py",
|
||||
"proxy_install.py",
|
||||
"proxy_guard.py",
|
||||
"proxy_runtime.py",
|
||||
"proxy_health.py",
|
||||
):
|
||||
shipped = payload / "delivery/scripts/release" / name
|
||||
preflight.protected(shipped)
|
||||
@@ -102,6 +110,8 @@ def binding() -> tuple[dict, dict, Path]:
|
||||
|
||||
|
||||
def service_profile(name: str) -> tuple[int, int, int, int, int]:
|
||||
if name == "proxy":
|
||||
return 1205, 128, 16, 50, 8
|
||||
if name in services.PROFILES:
|
||||
user, memory, pids, cpu, _, _ = services.PROFILES[name]
|
||||
return preflight.ACCOUNTS[user], memory, pids, cpu, 64
|
||||
@@ -121,6 +131,11 @@ def inspect_unit(name: str, selected: dict) -> dict:
|
||||
expected = service_install.UNITS / (
|
||||
selected["release_id"] + "." + selected["configuration_id"]
|
||||
)
|
||||
elif name == "proxy":
|
||||
directory = proxy_install.known_link(link)
|
||||
expected = proxy_service.UNITS / (
|
||||
selected["release_id"] + "." + selected["configuration_id"]
|
||||
)
|
||||
elif name == "sandbox-factory":
|
||||
directory = factory_service.known_link(link)
|
||||
expected = factory_service.UNITS / (
|
||||
@@ -166,7 +181,7 @@ def inspect_unit(name: str, selected: dict) -> dict:
|
||||
raise DeliveryError("effective service identity differs")
|
||||
if not set(info.get("Groups", "").split()) <= {str(uid)}:
|
||||
raise DeliveryError("unexpected service supplementary authority")
|
||||
if name != "sandbox-factory" and (
|
||||
if name not in {"sandbox-factory", "proxy"} and (
|
||||
info.get("NoNewPrivs", "").strip() != "1"
|
||||
or any(
|
||||
not info.get(k, "").strip() or int(info[k].strip(), 16) != 0
|
||||
@@ -174,11 +189,21 @@ def inspect_unit(name: str, selected: dict) -> dict:
|
||||
)
|
||||
):
|
||||
raise DeliveryError("effective ordinary privilege boundary differs")
|
||||
if name == "proxy" and (
|
||||
info.get("NoNewPrivs", "").strip() != "1"
|
||||
or any(
|
||||
not info.get(k, "").strip() or int(info[k].strip(), 16) != proxy_service.CAPABILITY
|
||||
for k in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb")
|
||||
)
|
||||
):
|
||||
raise DeliveryError("proxy acquired excess or missing listener capability")
|
||||
if name == "sandbox-factory" and (
|
||||
info.get("NoNewPrivs", "").strip() != "0" or info.get("Seccomp", "").strip() != "0"
|
||||
):
|
||||
raise DeliveryError("factory inherited incompatible hardening")
|
||||
if name in {"api", "outbox", "problem-import"} and info.get("NSpid", "").split()[-1:] != ["1"]:
|
||||
if name in {"api", "outbox", "problem-import", "proxy"} and info.get("NSpid", "").split()[
|
||||
-1:
|
||||
] != ["1"]:
|
||||
raise DeliveryError("Python service private PID namespace missing")
|
||||
group = Path("/sys/fs/cgroup" + value["ControlGroup"])
|
||||
actual = {
|
||||
@@ -464,7 +489,12 @@ def observe(*, probe: bool) -> dict:
|
||||
components["journal"] = journal.check_ready()
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
errors.append("bounded_journal_unavailable")
|
||||
for name in [*services.PROFILES, "postgres", "rabbitmq", "sandbox-factory"]:
|
||||
try:
|
||||
proxy_install.check_binding(selected["release_id"], selected["configuration_id"])
|
||||
components["https_entry"] = proxy_health.observe(selected)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
errors.append("https_entry_unavailable")
|
||||
for name in [*services.PROFILES, "postgres", "rabbitmq", "sandbox-factory", "proxy"]:
|
||||
try:
|
||||
components[name] = inspect_unit(name, selected)
|
||||
except (OSError, ValueError):
|
||||
|
||||
@@ -23,6 +23,7 @@ ROOTS = (
|
||||
"images/",
|
||||
"deploy/",
|
||||
"delivery/",
|
||||
"proxy/",
|
||||
)
|
||||
FORBIDDEN = {".git", ".localplan", "docs", "__pycache__", ".env", ".ssh"}
|
||||
|
||||
|
||||
@@ -75,8 +75,7 @@ def render(release_id: str, value: dict) -> bytes:
|
||||
add_header Cache-Control "no-store" always;
|
||||
""".replace("HOST", host)
|
||||
return (
|
||||
"""user kf-proxy;
|
||||
worker_processes 1;
|
||||
"""worker_processes 1;
|
||||
pid /run/kf-proxy/nginx.pid;
|
||||
error_log /dev/null crit;
|
||||
events { worker_connections 128; }
|
||||
@@ -108,8 +107,8 @@ http {
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name HOST;
|
||||
ssl_certificate /etc/kf-onlinejudge/tls/fullchain.pem;
|
||||
ssl_certificate_key /etc/kf-onlinejudge/tls/key.pem;
|
||||
ssl_certificate /etc/kf-proxy/tls/fullchain.pem;
|
||||
ssl_certificate_key /etc/kf-proxy/tls/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:TLS:1m;
|
||||
ssl_session_timeout 5m;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Ordinary proxy guard and fixed exec; never read protected application credentials."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import resource
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from . import install, journal, preflight, proxy_install, proxy_runtime, proxy_service
|
||||
from .policy import DeliveryError, digest, fields, read_json
|
||||
|
||||
|
||||
def validate_runtime(value: dict) -> None:
|
||||
fields(
|
||||
value,
|
||||
{
|
||||
"uids",
|
||||
"gids",
|
||||
"groups",
|
||||
"no_new_privileges",
|
||||
"capabilities",
|
||||
"pid",
|
||||
"cgroup",
|
||||
"limits",
|
||||
"node_limits",
|
||||
"process_limits",
|
||||
},
|
||||
"proxy process observation",
|
||||
)
|
||||
uid = str(proxy_service.UID)
|
||||
expected = f"{proxy_service.CAPABILITY:016x}"
|
||||
if (
|
||||
value["uids"] != [uid] * 4
|
||||
or value["gids"] != [uid] * 4
|
||||
or not set(value["groups"]) <= {uid}
|
||||
or value["no_new_privileges"] != "1"
|
||||
or type(value["pid"]) is not int
|
||||
or value["pid"] != 1
|
||||
or value["cgroup"] != "/sys/fs/cgroup/kfonlinejudge.slice/kf-proxy.service"
|
||||
or value["capabilities"] != [expected] * 5
|
||||
):
|
||||
raise DeliveryError("ordinary proxy identity/capability boundary differs")
|
||||
if value["limits"] != {
|
||||
"memory.max": str(128 * 1024**2),
|
||||
"memory.swap.max": "0",
|
||||
"pids.max": "16",
|
||||
"cpu.max": "50000 100000",
|
||||
} or value["node_limits"] != {
|
||||
"memory.max": str(3840 * 1024**2),
|
||||
"memory.swap.max": "0",
|
||||
"pids.max": "384",
|
||||
"cpu.max": "200000 100000",
|
||||
}:
|
||||
raise DeliveryError("proxy effective node/service budgets differ")
|
||||
if value["process_limits"] != {
|
||||
"core": [0, 0],
|
||||
"file": [8 * 1024**2] * 2,
|
||||
"files": [256] * 2,
|
||||
"stack": [16 * 1024**2] * 2,
|
||||
}:
|
||||
raise DeliveryError("proxy effective process limits differ")
|
||||
|
||||
|
||||
def observe() -> dict:
|
||||
status = dict(
|
||||
line.split(":", 1)
|
||||
for line in journal.bounded_text(Path("/proc/self/status"), 16384).splitlines()
|
||||
if ":" in line
|
||||
)
|
||||
group = preflight.current_cgroup()
|
||||
node = Path("/sys/fs/cgroup/kfonlinejudge.slice")
|
||||
names = ("memory.max", "memory.swap.max", "pids.max", "cpu.max")
|
||||
return {
|
||||
"uids": status.get("Uid", "").split(),
|
||||
"gids": status.get("Gid", "").split(),
|
||||
"groups": status.get("Groups", "").split(),
|
||||
"no_new_privileges": status.get("NoNewPrivs", "").strip(),
|
||||
"capabilities": [
|
||||
status.get(k, "").strip() for k in ("CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb")
|
||||
],
|
||||
"pid": os.getpid(),
|
||||
"cgroup": str(group),
|
||||
"limits": {k: (group / k).read_text().strip() for k in names},
|
||||
"node_limits": {k: (node / k).read_text().strip() for k in names},
|
||||
"process_limits": {
|
||||
k: list(resource.getrlimit(r))
|
||||
for k, r in [
|
||||
("core", resource.RLIMIT_CORE),
|
||||
("file", resource.RLIMIT_FSIZE),
|
||||
("files", resource.RLIMIT_NOFILE),
|
||||
("stack", resource.RLIMIT_STACK),
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run(release: str, batch: str) -> None:
|
||||
selected = {"release_id": release, "configuration_id": batch}
|
||||
directory = proxy_service.batch(selected)
|
||||
validate_runtime(observe())
|
||||
proxy_install.account()
|
||||
proxy_install.storage()
|
||||
public = directory / "launch.json"
|
||||
preflight.protected(public)
|
||||
info = public.stat()
|
||||
if stat.S_IMODE(info.st_mode) != 0o644 or info.st_gid != 0:
|
||||
raise DeliveryError("protected public proxy launch spec required")
|
||||
spec = read_json(public, 4096)
|
||||
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)
|
||||
):
|
||||
raise DeliveryError("proxy public launch binding differs")
|
||||
config = directory / "nginx.conf"
|
||||
preflight.protected(config)
|
||||
if digest(config) != spec["configuration_sha256"]:
|
||||
raise DeliveryError("proxy configuration bytes changed")
|
||||
for name, key in [("fullchain.pem", "certificate_sha256"), ("key.pem", "key_sha256")]:
|
||||
path = proxy_service.ROOT / "tls" / name
|
||||
preflight.protected(path)
|
||||
if path.stat().st_size > 65536 or digest(path) != spec[key]:
|
||||
raise DeliveryError("proxy TLS bytes changed")
|
||||
payload = install.RELEASES / release / "payload"
|
||||
root = payload / "proxy"
|
||||
preflight.protected(root, directory=True)
|
||||
# This guard is the already-installed, Root-protected launcher selected by
|
||||
# the fixed unit; catalog checks are additional integrity checks only.
|
||||
proxy_runtime.verify(root, digest(root / "manifest.json"))
|
||||
executable = root / "bin/nginx"
|
||||
preflight.protected(executable)
|
||||
os.execve(
|
||||
executable,
|
||||
[
|
||||
str(executable),
|
||||
"-p",
|
||||
"/run/kf-proxy/",
|
||||
"-c",
|
||||
str(config),
|
||||
"-g",
|
||||
"daemon off; master_process on;",
|
||||
],
|
||||
{"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "LD_LIBRARY_PATH": str(root / "lib")},
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Fixed ordinary HTTPS launcher")
|
||||
parser.add_argument("--release-id", required=True)
|
||||
parser.add_argument("--configuration-id", required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
run(args.release_id, args.configuration_id)
|
||||
except (OSError, ValueError) as error:
|
||||
parser.exit(
|
||||
1,
|
||||
"Proxy launch refused: "
|
||||
+ (str(error) if isinstance(error, DeliveryError) else type(error).__name__)
|
||||
+ "\n",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Read-only fixed HTTPS reachability, pinned certificate and hostname check."""
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import socket
|
||||
import ssl
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from . import proxy_service
|
||||
from .policy import DeliveryError, read_json
|
||||
|
||||
|
||||
def observe(selected: dict) -> dict:
|
||||
directory = proxy_service.batch(selected)
|
||||
spec = read_json(directory / "launch.json", 4096)
|
||||
host = urlsplit(spec["origin"]).hostname
|
||||
# This explicit Root-protected leaf/chain is already byte-bound to selected
|
||||
# TLS material; it is the local health trust anchor, not a claim that public
|
||||
# browsers or the system CA store trust it.
|
||||
certificate = proxy_service.ROOT / "tls/fullchain.pem"
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.load_verify_locations(cafile=certificate)
|
||||
ctx.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
|
||||
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
with socket.create_connection(("127.0.0.1", 443), timeout=5) as raw:
|
||||
with ctx.wrap_socket(raw, server_hostname=host) as client:
|
||||
client.settimeout(5)
|
||||
der = client.getpeercert(binary_form=True)
|
||||
pem = certificate.read_text()
|
||||
leaf = pem[: pem.index("-----END CERTIFICATE-----") + len("-----END CERTIFICATE-----")]
|
||||
if (
|
||||
hashlib.sha256(der).digest()
|
||||
!= hashlib.sha256(ssl.PEM_cert_to_DER_cert(leaf)).digest()
|
||||
):
|
||||
raise DeliveryError("HTTPS served another certificate")
|
||||
client.sendall(
|
||||
(
|
||||
f"GET /api/v1/health/live HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
|
||||
).encode()
|
||||
)
|
||||
response = http.client.HTTPResponse(client)
|
||||
try:
|
||||
response.begin()
|
||||
body = response.read(4097)
|
||||
if response.status != 200 or len(body) > 4096:
|
||||
raise DeliveryError("HTTPS health response exceeds bound or is unavailable")
|
||||
from .operator_units import decode
|
||||
|
||||
value = decode(body, 4096)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("status") != "ok"
|
||||
or value.get("service") != "platform"
|
||||
):
|
||||
raise DeliveryError("HTTPS/API health response unavailable")
|
||||
except http.client.HTTPException as error:
|
||||
raise DeliveryError("HTTPS health framing unavailable") from error
|
||||
finally:
|
||||
response.close()
|
||||
return {
|
||||
"reachable": True,
|
||||
"pinned_certificate_and_hostname_verified": True,
|
||||
"public_browser_trust_verified": False,
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
"""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")
|
||||
selected = install.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)
|
||||
return {"binding_valid": True, "tls_verified": False, "execution_verified": False}
|
||||
|
||||
|
||||
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)
|
||||
body = proxy_service.DATA / "body"
|
||||
if body.exists() or body.is_symlink():
|
||||
i = body.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:
|
||||
body.mkdir(mode=0o700)
|
||||
os.chown(body, proxy_service.UID, proxy_service.UID)
|
||||
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()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Closed public NGINX runtime inventory, independent from TLS and service state."""
|
||||
|
||||
import re
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from .policy import DeliveryError, digest, fields, integer, read_json, sha256
|
||||
|
||||
VERSION = "1.30.5"
|
||||
MAX_BYTES = 64 * 1024**2
|
||||
|
||||
|
||||
def validate(value: dict) -> dict:
|
||||
fields(
|
||||
value,
|
||||
{
|
||||
"schema_version",
|
||||
"format",
|
||||
"version",
|
||||
"architecture",
|
||||
"package_sha256",
|
||||
"files",
|
||||
"directories",
|
||||
"payload_bytes",
|
||||
},
|
||||
"proxy runtime",
|
||||
)
|
||||
if (
|
||||
type(value["schema_version"]) is not int
|
||||
or value["schema_version"] != 1
|
||||
or value["format"] != "kf.nginx.runtime.v1"
|
||||
or value["version"] != VERSION
|
||||
or value["architecture"] != "x86_64"
|
||||
or value["directories"] != ["bin", "lib"]
|
||||
or not isinstance(value["files"], list)
|
||||
or not 1 <= len(value["files"]) <= 32
|
||||
):
|
||||
raise DeliveryError("unsupported proxy runtime profile")
|
||||
sha256(value["package_sha256"])
|
||||
names = set()
|
||||
total = 0
|
||||
for entry in value["files"]:
|
||||
fields(entry, {"path", "bytes", "sha256", "mode"}, "proxy runtime file")
|
||||
name = entry["path"]
|
||||
if not isinstance(name, str) or (
|
||||
name != "bin/nginx" and not re.fullmatch(r"lib/[A-Za-z0-9_.+-]+\.so(?:\.[0-9]+)*", name)
|
||||
):
|
||||
raise DeliveryError("non-runtime or private proxy member")
|
||||
if name in names or entry["mode"] != ("0555" if name == "bin/nginx" else "0444"):
|
||||
raise DeliveryError("duplicate proxy member or unsafe mode")
|
||||
names.add(name)
|
||||
total += integer(entry["bytes"], 1, 32 * 1024**2, "proxy file bytes")
|
||||
sha256(entry["sha256"])
|
||||
if (
|
||||
not {"bin/nginx", "lib/libc.so.6", "lib/ld-linux-x86-64.so.2"} <= names
|
||||
or total > MAX_BYTES
|
||||
or type(value["payload_bytes"]) is not int
|
||||
or value["payload_bytes"] != total
|
||||
):
|
||||
raise DeliveryError("incomplete or unbounded proxy runtime")
|
||||
return value
|
||||
|
||||
|
||||
def verify(root: Path, expected: str) -> dict:
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise DeliveryError("ordinary proxy runtime directory required")
|
||||
catalog = root / "manifest.json"
|
||||
if catalog.is_symlink() or digest(catalog) != sha256(expected):
|
||||
raise DeliveryError("proxy runtime out-of-band fingerprint differs")
|
||||
value = validate(read_json(catalog, 16384))
|
||||
if {p.name for p in root.iterdir()} != {"manifest.json", "bin", "lib"}:
|
||||
raise DeliveryError("unexpected proxy runtime tree member")
|
||||
for directory in (root / "bin", root / "lib"):
|
||||
if (
|
||||
directory.is_symlink()
|
||||
or not directory.is_dir()
|
||||
or stat.S_IMODE(directory.stat().st_mode) != 0o555
|
||||
):
|
||||
raise DeliveryError("readonly ordinary proxy runtime directories required")
|
||||
names = set()
|
||||
for directory in (root / "bin", root / "lib"):
|
||||
for p in directory.iterdir():
|
||||
names.add(p.relative_to(root).as_posix())
|
||||
if names != {entry["path"] for entry in value["files"]}:
|
||||
raise DeliveryError("proxy runtime inventory differs")
|
||||
for entry in value["files"]:
|
||||
p = root / entry["path"]
|
||||
info = p.lstat()
|
||||
if (
|
||||
not stat.S_ISREG(info.st_mode)
|
||||
or info.st_nlink != 1
|
||||
or info.st_size != entry["bytes"]
|
||||
or stat.S_IMODE(info.st_mode) != int(entry["mode"], 8)
|
||||
or digest(p) != entry["sha256"]
|
||||
):
|
||||
raise DeliveryError("proxy runtime bytes or permission differ")
|
||||
return value
|
||||
|
||||
|
||||
def copy_to_payload(root: Path, expected: str, destination: Path) -> None:
|
||||
from .build import copy
|
||||
|
||||
value = verify(root, expected)
|
||||
if destination.exists() or destination.is_symlink():
|
||||
raise DeliveryError("proxy runtime payload must be new")
|
||||
destination.mkdir()
|
||||
for directory in value["directories"]:
|
||||
(destination / directory).mkdir()
|
||||
for entry in value["files"]:
|
||||
copy(root / entry["path"], destination / entry["path"])
|
||||
copy(root / "manifest.json", destination / "manifest.json")
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Fixed ordinary HTTPS service profile, separate from TLS/config installation."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from . import install, proxy
|
||||
from .policy import DeliveryError, fields, sha256
|
||||
from .services import python_environment
|
||||
|
||||
UID = 1205
|
||||
USER = "kf-proxy"
|
||||
UNIT = "kf-proxy.service"
|
||||
ROOT = Path("/etc/kf-proxy")
|
||||
UNITS = ROOT / "units"
|
||||
DATA = Path("/var/lib/kf-onlinejudge/proxy")
|
||||
CAPACITY = 128 * 1024**2
|
||||
CAPABILITY = 1 << 10 # Linux CAP_NET_BIND_SERVICE, solely for the fixed TLS listener.
|
||||
|
||||
|
||||
def validate_receipt(value: dict) -> dict:
|
||||
fields(
|
||||
value,
|
||||
{
|
||||
"schema_version",
|
||||
"source_sha",
|
||||
"manifest_sha256",
|
||||
"origin",
|
||||
"tls",
|
||||
"configuration_sha256",
|
||||
"installed",
|
||||
"tls_verified",
|
||||
"services_started",
|
||||
"execution_verified",
|
||||
},
|
||||
"proxy source 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 any(
|
||||
value[k] is not False
|
||||
for k in ("installed", "tls_verified", "services_started", "execution_verified")
|
||||
)
|
||||
):
|
||||
raise DeliveryError("proxy source receipt authority differs")
|
||||
fields(value["tls"], {"certificate_sha256", "key_sha256"}, "proxy TLS fingerprints")
|
||||
proxy.validate_profile({"schema_version": 1, "origin": value["origin"], **value["tls"]})
|
||||
sha256(value["manifest_sha256"])
|
||||
sha256(value["configuration_sha256"])
|
||||
return value
|
||||
|
||||
|
||||
def batch(selected: dict) -> Path:
|
||||
if not re.fullmatch(r"sd-[0-9a-f]{12}", selected["release_id"]) or not re.fullmatch(
|
||||
r"cfg-[0-9a-f]{24}", selected["configuration_id"]
|
||||
):
|
||||
raise DeliveryError("invalid proxy service binding")
|
||||
return ROOT / (selected["release_id"] + "." + selected["configuration_id"])
|
||||
|
||||
|
||||
def launch_spec(selected: dict, receipt: dict) -> dict:
|
||||
validate_receipt(receipt)
|
||||
if (
|
||||
receipt["source_sha"][:12] != selected["release_id"][3:]
|
||||
or receipt["manifest_sha256"] != selected["manifest_sha256"]
|
||||
):
|
||||
raise DeliveryError("proxy is not this selected release")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"release_id": selected["release_id"],
|
||||
"configuration_id": selected["configuration_id"],
|
||||
"origin": receipt["origin"],
|
||||
"configuration_sha256": receipt["configuration_sha256"],
|
||||
**receipt["tls"],
|
||||
}
|
||||
|
||||
|
||||
def render(selected: dict) -> bytes:
|
||||
batch(selected)
|
||||
payload = install.RELEASES / selected["release_id"] / "payload"
|
||||
arguments = (
|
||||
f" --release-id {selected['release_id']} --configuration-id {selected['configuration_id']}"
|
||||
)
|
||||
clean = "/usr/bin/env -i PATH=/usr/bin:/bin LC_ALL=C.UTF-8 " + python_environment(payload)
|
||||
python = str(payload / "python/bin/python3.13")
|
||||
lines = [
|
||||
"[Unit]",
|
||||
"Description=KF bounded ordinary HTTPS entry",
|
||||
"After=local-fs.target kf-api.service",
|
||||
"StartLimitIntervalSec=600",
|
||||
"StartLimitBurst=3",
|
||||
"[Service]",
|
||||
"Type=exec",
|
||||
"Slice=kfonlinejudge.slice",
|
||||
"User=kf-proxy",
|
||||
"Group=kf-proxy",
|
||||
"UMask=0077",
|
||||
"WorkingDirectory=" + str(payload),
|
||||
"RuntimeDirectory=kf-proxy",
|
||||
"RuntimeDirectoryMode=0700",
|
||||
"ExecStartPre=+"
|
||||
+ clean
|
||||
+ " "
|
||||
+ python
|
||||
+ " -s -P -m scripts.release.proxy_install --check-binding"
|
||||
+ arguments,
|
||||
"ExecStart=" + clean + " " + python + " -s -P -m scripts.release.proxy_guard" + arguments,
|
||||
"MemoryMax=128M",
|
||||
"MemorySwapMax=0",
|
||||
"CPUQuota=50%",
|
||||
"TasksMax=16",
|
||||
"LimitCORE=0",
|
||||
"LimitFSIZE=8M",
|
||||
"LimitNOFILE=256",
|
||||
"LimitSTACK=16M",
|
||||
"NoNewPrivileges=yes",
|
||||
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE",
|
||||
"AmbientCapabilities=CAP_NET_BIND_SERVICE",
|
||||
"ProtectSystem=strict",
|
||||
"ProtectHome=yes",
|
||||
"ProtectControlGroups=yes",
|
||||
"ProtectKernelTunables=yes",
|
||||
"ProtectKernelModules=yes",
|
||||
"PrivateTmp=yes",
|
||||
"PrivateDevices=yes",
|
||||
"PrivatePIDs=yes",
|
||||
"ProtectProc=invisible",
|
||||
"RestrictSUIDSGID=yes",
|
||||
"LockPersonality=yes",
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET",
|
||||
"ReadWritePaths=" + str(DATA),
|
||||
"RequiresMountsFor=" + str(DATA),
|
||||
"KillMode=control-group",
|
||||
"TimeoutStartSec=30",
|
||||
"TimeoutStopSec=30",
|
||||
"RuntimeMaxSec=24h",
|
||||
"Restart=on-failure",
|
||||
"RestartSec=30",
|
||||
"StandardOutput=journal",
|
||||
"StandardError=journal",
|
||||
"LogNamespace=kf-onlinejudge",
|
||||
"LogRateLimitIntervalSec=30s",
|
||||
"LogRateLimitBurst=100",
|
||||
"[Install]",
|
||||
"WantedBy=multi-user.target",
|
||||
]
|
||||
return ("\n".join(lines) + "\n").encode()
|
||||
|
||||
|
||||
def configuration(receipt: dict, release_id: str) -> bytes:
|
||||
validate_receipt(receipt)
|
||||
return proxy.render(
|
||||
release_id, {"schema_version": 1, "origin": receipt["origin"], **receipt["tls"]}
|
||||
)
|
||||
Reference in New Issue
Block a user