562 lines
19 KiB
Python
562 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Owner-approved activation/rollback controller for the AIOS GPT HTTPS edge.
|
|
|
|
This controller never creates approval, TLS, or ingress credentials. It consumes
|
|
owner-managed inputs, applies one exact public-bind overlay, verifies the live
|
|
edge, and provides an idempotent rollback path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from typing import Any, Mapping
|
|
|
|
APPROVAL_SCHEMA = "aios.gpt-https-edge.public-approval.v1"
|
|
STATE_SCHEMA = "aios.gpt-https-edge.activation-state.v1"
|
|
|
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
APPROVAL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
|
DOMAIN_RE = re.compile(
|
|
r"(?=.{1,253}\Z)"
|
|
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
|
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
|
)
|
|
|
|
EDGE_UNIT = "aios-gpt-https-edge.service"
|
|
INGRESS_UNIT = "aios-gpt-online-task-ingress.service"
|
|
PUBLIC_HOST = "0.0.0.0"
|
|
PUBLIC_PORT = 18443
|
|
INGRESS_HEALTH = "http://127.0.0.1:18945/health"
|
|
DROPIN_NAME = "zzzzzzzzzz-owner-approved-public.conf"
|
|
|
|
|
|
class ActivationError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def canonical(value: object) -> bytes:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
return sha256_bytes(path.read_bytes())
|
|
|
|
|
|
def parse_time(value: object, *, field: str) -> datetime:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID")
|
|
text = value.strip()
|
|
if text.endswith("Z"):
|
|
text = text[:-1] + "+00:00"
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError as exc:
|
|
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID") from exc
|
|
if parsed.tzinfo is None:
|
|
raise ActivationError("APPROVAL_" + field.upper() + "_INVALID")
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def validate_domain(value: object) -> str:
|
|
if not isinstance(value, str):
|
|
raise ActivationError("APPROVAL_DOMAIN_INVALID")
|
|
domain = value.strip().lower()
|
|
if DOMAIN_RE.fullmatch(domain) is None:
|
|
raise ActivationError("APPROVAL_DOMAIN_INVALID")
|
|
return domain
|
|
|
|
|
|
def validate_root_file(
|
|
path: Path,
|
|
*,
|
|
exact_mode: int | None = None,
|
|
max_size: int = 256 * 1024,
|
|
) -> Path:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise ActivationError("OWNER_FILE_INVALID")
|
|
info = path.stat()
|
|
if info.st_uid != 0 or info.st_gid != 0:
|
|
raise ActivationError("OWNER_FILE_OWNERSHIP_INVALID")
|
|
mode = stat.S_IMODE(info.st_mode)
|
|
if exact_mode is not None and mode != exact_mode:
|
|
raise ActivationError("OWNER_FILE_MODE_INVALID")
|
|
if info.st_size < 16 or info.st_size > max_size:
|
|
raise ActivationError("OWNER_FILE_SIZE_INVALID")
|
|
return path
|
|
|
|
|
|
def certificate_dns_names(cert_path: Path) -> tuple[str, ...]:
|
|
try:
|
|
decoded = ssl._ssl._test_decode_cert(str(cert_path))
|
|
except Exception as exc:
|
|
raise ActivationError("TLS_CERT_DECODE_INVALID") from exc
|
|
names = [
|
|
value
|
|
for kind, value in decoded.get("subjectAltName", ())
|
|
if kind == "DNS"
|
|
]
|
|
if not names:
|
|
for rdn in decoded.get("subject", ()):
|
|
for key, value in rdn:
|
|
if key == "commonName":
|
|
names.append(value)
|
|
return tuple(names)
|
|
|
|
|
|
def cert_matches_domain(cert_path: Path, domain: str) -> bool:
|
|
names = certificate_dns_names(cert_path)
|
|
try:
|
|
return bool(names) and any(ssl._dnsname_match(name, domain) for name in names)
|
|
except ssl.CertificateError:
|
|
return False
|
|
|
|
|
|
def validate_tls_pair(
|
|
cert_path: Path,
|
|
key_path: Path,
|
|
domain: str,
|
|
*,
|
|
now_fn=time.time,
|
|
) -> dict[str, Any]:
|
|
validate_root_file(cert_path)
|
|
validate_root_file(key_path, exact_mode=0o600)
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
context.options |= ssl.OP_NO_COMPRESSION
|
|
try:
|
|
context.load_cert_chain(str(cert_path), str(key_path))
|
|
except Exception as exc:
|
|
raise ActivationError("TLS_CERT_KEY_PAIR_INVALID") from exc
|
|
if not cert_matches_domain(cert_path, domain):
|
|
raise ActivationError("TLS_CERT_DOMAIN_MISMATCH")
|
|
decoded = ssl._ssl._test_decode_cert(str(cert_path))
|
|
not_after = decoded.get("notAfter")
|
|
if not not_after:
|
|
raise ActivationError("TLS_CERT_EXPIRY_MISSING")
|
|
remaining = ssl.cert_time_to_seconds(not_after) - now_fn()
|
|
if remaining < 7 * 86400:
|
|
raise ActivationError("TLS_CERT_EXPIRY_TOO_CLOSE")
|
|
return {
|
|
"cert_sha256": sha256_file(cert_path),
|
|
"remaining_days": int(remaining // 86400),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Approval:
|
|
approval_id: str
|
|
candidate_oid: str
|
|
domain: str
|
|
bind_host: str
|
|
port: int
|
|
tls_cert_sha256: str
|
|
owner: str
|
|
issued_at: datetime
|
|
not_before: datetime
|
|
expires_at: datetime
|
|
raw_sha256: str
|
|
|
|
@classmethod
|
|
def load(
|
|
cls,
|
|
path: Path,
|
|
*,
|
|
candidate_oid: str,
|
|
cert_path: Path,
|
|
now: datetime | None = None,
|
|
) -> "Approval":
|
|
validate_root_file(path, exact_mode=0o600, max_size=64 * 1024)
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise ActivationError("APPROVAL_JSON_INVALID") from exc
|
|
if not isinstance(value, Mapping):
|
|
raise ActivationError("APPROVAL_JSON_INVALID")
|
|
allowed = {
|
|
"schema",
|
|
"approval_id",
|
|
"candidate_oid",
|
|
"domain",
|
|
"public_bind",
|
|
"bind_host",
|
|
"port",
|
|
"tls_cert_sha256",
|
|
"issued_at",
|
|
"not_before",
|
|
"expires_at",
|
|
"owner",
|
|
}
|
|
if set(value) != allowed or value.get("schema") != APPROVAL_SCHEMA:
|
|
raise ActivationError("APPROVAL_FIELDS_INVALID")
|
|
|
|
approval_id = value.get("approval_id")
|
|
if not isinstance(approval_id, str) or APPROVAL_ID_RE.fullmatch(approval_id) is None:
|
|
raise ActivationError("APPROVAL_ID_INVALID")
|
|
if not OID_RE.fullmatch(str(value.get("candidate_oid") or "")):
|
|
raise ActivationError("APPROVAL_CANDIDATE_INVALID")
|
|
if value["candidate_oid"] != candidate_oid:
|
|
raise ActivationError("APPROVAL_CANDIDATE_MISMATCH")
|
|
if value.get("public_bind") is not True:
|
|
raise ActivationError("APPROVAL_PUBLIC_BIND_REQUIRED")
|
|
if value.get("bind_host") != PUBLIC_HOST or value.get("port") != PUBLIC_PORT:
|
|
raise ActivationError("APPROVAL_BINDING_INVALID")
|
|
|
|
cert_sha = str(value.get("tls_cert_sha256") or "")
|
|
if SHA_RE.fullmatch(cert_sha) is None:
|
|
raise ActivationError("APPROVAL_CERT_SHA_INVALID")
|
|
if not cert_path.is_file() or sha256_file(cert_path) != cert_sha:
|
|
raise ActivationError("APPROVAL_CERT_SHA_MISMATCH")
|
|
|
|
domain = validate_domain(value.get("domain"))
|
|
owner = value.get("owner")
|
|
if not isinstance(owner, str) or not owner.strip() or len(owner) > 255:
|
|
raise ActivationError("APPROVAL_OWNER_INVALID")
|
|
|
|
issued_at = parse_time(value.get("issued_at"), field="issued_at")
|
|
not_before = parse_time(value.get("not_before"), field="not_before")
|
|
expires_at = parse_time(value.get("expires_at"), field="expires_at")
|
|
current = now or datetime.now(timezone.utc)
|
|
if not (issued_at <= not_before <= expires_at):
|
|
raise ActivationError("APPROVAL_TIME_ORDER_INVALID")
|
|
if current < not_before:
|
|
raise ActivationError("APPROVAL_NOT_YET_VALID")
|
|
if current >= expires_at:
|
|
raise ActivationError("APPROVAL_EXPIRED")
|
|
if expires_at - issued_at > __import__("datetime").timedelta(days=7):
|
|
raise ActivationError("APPROVAL_VALIDITY_TOO_LONG")
|
|
|
|
return cls(
|
|
approval_id=approval_id,
|
|
candidate_oid=candidate_oid,
|
|
domain=domain,
|
|
bind_host=PUBLIC_HOST,
|
|
port=PUBLIC_PORT,
|
|
tls_cert_sha256=cert_sha,
|
|
owner=owner.strip(),
|
|
issued_at=issued_at,
|
|
not_before=not_before,
|
|
expires_at=expires_at,
|
|
raw_sha256=sha256_file(path),
|
|
)
|
|
|
|
|
|
def validate_release(release_root: Path) -> dict[str, Path]:
|
|
if release_root.is_symlink() or not release_root.is_dir():
|
|
raise ActivationError("IMMUTABLE_RELEASE_INVALID")
|
|
edge = release_root / "active-runtime/app/aios_gpt_https_edge.py"
|
|
controller = release_root / "active-runtime/app/aios_gpt_https_edge_activation.py"
|
|
for path in (edge, controller):
|
|
if path.is_symlink() or not path.is_file():
|
|
raise ActivationError("IMMUTABLE_RELEASE_INVALID")
|
|
return {"edge": edge, "controller": controller}
|
|
|
|
|
|
def render_dropin(approval: Approval, release_root: Path) -> str:
|
|
app = release_root / "active-runtime/app/aios_gpt_https_edge.py"
|
|
return (
|
|
"[Service]\n"
|
|
f"Environment=AIOS_GPT_EDGE_HOST={approval.bind_host}\n"
|
|
f"Environment=AIOS_GPT_EDGE_PORT={approval.port}\n"
|
|
"Environment=AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1\n"
|
|
f"Environment=AIOS_GPT_EDGE_PUBLIC_DOMAIN={approval.domain}\n"
|
|
"ExecStart=\n"
|
|
f"ExecStart=/usr/bin/python3 -B {app}\n"
|
|
f"ReadOnlyPaths={release_root}\n"
|
|
)
|
|
|
|
|
|
def atomic_write(path: Path, data: str, mode: int = 0o600) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, name = tempfile.mkstemp(prefix="." + path.name + ".", dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, mode)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(name, path)
|
|
finally:
|
|
if os.path.exists(name):
|
|
os.unlink(name)
|
|
|
|
|
|
class Systemd:
|
|
def run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["/usr/bin/systemctl", *args],
|
|
check=check,
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=30,
|
|
)
|
|
|
|
def active(self, unit: str) -> bool:
|
|
return self.run("is-active", "--quiet", unit, check=False).returncode == 0
|
|
|
|
def enabled(self, unit: str) -> bool:
|
|
return self.run("is-enabled", "--quiet", unit, check=False).returncode == 0
|
|
|
|
def show(self, unit: str, prop: str) -> str:
|
|
return self.run("show", unit, f"--property={prop}", "--value").stdout.strip()
|
|
|
|
|
|
def require_ingress_ready(systemd: Systemd) -> None:
|
|
if not systemd.active(INGRESS_UNIT):
|
|
raise ActivationError("INGRESS_SERVICE_NOT_ACTIVE")
|
|
try:
|
|
with urllib.request.urlopen(INGRESS_HEALTH, timeout=5) as response:
|
|
if response.status != 200:
|
|
raise ActivationError("INGRESS_HEALTH_FAILED")
|
|
value = json.loads(response.read().decode())
|
|
except ActivationError:
|
|
raise
|
|
except Exception as exc:
|
|
raise ActivationError("INGRESS_HEALTH_FAILED") from exc
|
|
if value.get("status") != "ok":
|
|
raise ActivationError("INGRESS_HEALTH_FAILED")
|
|
|
|
|
|
def tls_probe(domain: str, cert_path: Path, *, port: int = PUBLIC_PORT) -> None:
|
|
context = ssl.create_default_context(cafile=str(cert_path))
|
|
context.check_hostname = True
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
with context.wrap_socket(sock, server_hostname=domain) as tls_sock:
|
|
if tls_sock.version() not in {"TLSv1.2", "TLSv1.3"}:
|
|
raise ActivationError("EDGE_TLS_VERSION_INVALID")
|
|
except ActivationError:
|
|
raise
|
|
except Exception as exc:
|
|
raise ActivationError("EDGE_TLS_PROBE_FAILED") from exc
|
|
|
|
|
|
def receipt_for(
|
|
approval: Approval,
|
|
release_root: Path,
|
|
*,
|
|
state: str,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"schema": STATE_SCHEMA,
|
|
"state": state,
|
|
"approval_id": approval.approval_id,
|
|
"approval_sha256": approval.raw_sha256,
|
|
"candidate_oid": approval.candidate_oid,
|
|
"domain": approval.domain,
|
|
"bind_host": approval.bind_host,
|
|
"port": approval.port,
|
|
"tls_cert_sha256": approval.tls_cert_sha256,
|
|
"release_root": str(release_root),
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
|
|
|
|
def activate(
|
|
*,
|
|
approval: Approval,
|
|
release_root: Path,
|
|
cert_path: Path,
|
|
key_path: Path,
|
|
dropin_path: Path,
|
|
state_path: Path,
|
|
systemd: Systemd,
|
|
) -> dict[str, Any]:
|
|
if os.geteuid() != 0:
|
|
raise ActivationError("ROOT_REQUIRED")
|
|
release = validate_release(release_root)
|
|
app = release["edge"]
|
|
require_ingress_ready(systemd)
|
|
validate_tls_pair(cert_path, key_path, approval.domain)
|
|
|
|
if state_path.is_file():
|
|
try:
|
|
existing = json.loads(state_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
existing = {}
|
|
if (
|
|
existing.get("schema") == STATE_SCHEMA
|
|
and existing.get("state") == "ACTIVE"
|
|
and existing.get("approval_sha256") == approval.raw_sha256
|
|
and existing.get("candidate_oid") == approval.candidate_oid
|
|
and systemd.active(EDGE_UNIT)
|
|
):
|
|
return existing
|
|
|
|
dropin = render_dropin(approval, release_root)
|
|
modified = False
|
|
try:
|
|
atomic_write(dropin_path, dropin, mode=0o644)
|
|
modified = True
|
|
systemd.run("daemon-reload")
|
|
systemd.run("enable", EDGE_UNIT)
|
|
systemd.run("restart", EDGE_UNIT)
|
|
if not systemd.active(EDGE_UNIT):
|
|
raise ActivationError("EDGE_SERVICE_NOT_ACTIVE")
|
|
exec_start = systemd.show(EDGE_UNIT, "ExecStart")
|
|
environment = systemd.show(EDGE_UNIT, "Environment")
|
|
if str(app) not in exec_start:
|
|
raise ActivationError("EDGE_EFFECTIVE_EXECSTART_INVALID")
|
|
for expected in (
|
|
f"AIOS_GPT_EDGE_HOST={approval.bind_host}",
|
|
f"AIOS_GPT_EDGE_PORT={approval.port}",
|
|
"AIOS_GPT_EDGE_PUBLIC_BIND_APPROVED=1",
|
|
f"AIOS_GPT_EDGE_PUBLIC_DOMAIN={approval.domain}",
|
|
):
|
|
if expected not in environment:
|
|
raise ActivationError("EDGE_EFFECTIVE_ENVIRONMENT_INVALID")
|
|
tls_probe(approval.domain, cert_path, port=approval.port)
|
|
receipt = receipt_for(approval, release_root, state="ACTIVE")
|
|
atomic_write(state_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
|
return receipt
|
|
except Exception:
|
|
if modified:
|
|
rollback(
|
|
dropin_path=dropin_path,
|
|
state_path=state_path,
|
|
systemd=systemd,
|
|
reason="activation_failed",
|
|
)
|
|
raise
|
|
|
|
|
|
def rollback(
|
|
*,
|
|
dropin_path: Path,
|
|
state_path: Path,
|
|
systemd: Systemd,
|
|
reason: str,
|
|
) -> dict[str, Any]:
|
|
if os.geteuid() != 0:
|
|
raise ActivationError("ROOT_REQUIRED")
|
|
systemd.run("stop", EDGE_UNIT, check=False)
|
|
systemd.run("disable", EDGE_UNIT, check=False)
|
|
dropin_path.unlink(missing_ok=True)
|
|
systemd.run("daemon-reload")
|
|
if systemd.active(EDGE_UNIT):
|
|
raise ActivationError("EDGE_ROLLBACK_SERVICE_STILL_ACTIVE")
|
|
previous: dict[str, Any] = {}
|
|
if state_path.is_file():
|
|
try:
|
|
previous = json.loads(state_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
previous = {}
|
|
receipt = {
|
|
"schema": STATE_SCHEMA,
|
|
"state": "ROLLED_BACK",
|
|
"reason": reason[:255],
|
|
"candidate_oid": previous.get("candidate_oid"),
|
|
"approval_id": previous.get("approval_id"),
|
|
"domain": previous.get("domain"),
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
atomic_write(state_path, json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
|
return receipt
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("command", choices=("check", "activate", "rollback"))
|
|
parser.add_argument("--candidate")
|
|
parser.add_argument("--approval", default="/etc/aios-gitops/gpt-https-edge.approved")
|
|
parser.add_argument("--tls-cert", default="/etc/aios-gitops/gpt-https-edge-tls.crt")
|
|
parser.add_argument("--tls-key", default="/etc/aios-gitops/gpt-https-edge-tls.key")
|
|
parser.add_argument("--release-root")
|
|
parser.add_argument(
|
|
"--dropin",
|
|
default=f"/etc/systemd/system/{EDGE_UNIT}.d/{DROPIN_NAME}",
|
|
)
|
|
parser.add_argument(
|
|
"--state",
|
|
default="/var/lib/aios-gpt-https-edge-activation/state.json",
|
|
)
|
|
parser.add_argument("--reason", default="owner_requested")
|
|
args = parser.parse_args()
|
|
|
|
systemd = Systemd()
|
|
if args.command == "rollback":
|
|
value = rollback(
|
|
dropin_path=Path(args.dropin),
|
|
state_path=Path(args.state),
|
|
systemd=systemd,
|
|
reason=args.reason,
|
|
)
|
|
print(json.dumps(value, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
candidate = str(args.candidate or "")
|
|
if OID_RE.fullmatch(candidate) is None:
|
|
raise SystemExit("CANDIDATE_REQUIRED")
|
|
release_root = (
|
|
Path(args.release_root)
|
|
if args.release_root
|
|
else Path(f"/opt/aios/releases/{candidate}-gpt-https-edge-public")
|
|
)
|
|
cert_path = Path(args.tls_cert)
|
|
key_path = Path(args.tls_key)
|
|
approval = Approval.load(
|
|
Path(args.approval),
|
|
candidate_oid=candidate,
|
|
cert_path=cert_path,
|
|
)
|
|
tls = validate_tls_pair(cert_path, key_path, approval.domain)
|
|
release = validate_release(release_root)
|
|
require_ingress_ready(systemd)
|
|
|
|
if args.command == "check":
|
|
value = {
|
|
"status": "READY",
|
|
"candidate_oid": candidate,
|
|
"approval_id": approval.approval_id,
|
|
"approval_sha256": approval.raw_sha256,
|
|
"domain": approval.domain,
|
|
"bind_host": approval.bind_host,
|
|
"port": approval.port,
|
|
"edge_sha256": sha256_file(release["edge"]),
|
|
"controller_sha256": sha256_file(release["controller"]),
|
|
**tls,
|
|
"public_network_mutation": False,
|
|
}
|
|
print(json.dumps(value, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
value = activate(
|
|
approval=approval,
|
|
release_root=release_root,
|
|
cert_path=cert_path,
|
|
key_path=key_path,
|
|
dropin_path=Path(args.dropin),
|
|
state_path=Path(args.state),
|
|
systemd=systemd,
|
|
)
|
|
print(json.dumps(value, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|