Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
160 lines
7.8 KiB
Python
160 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Seal the accepted recovery point for founder-only workstation transfer.
|
|
|
|
Run by the founder through the remote wrapper. Reads only this completed
|
|
capture; writes an age-encrypted packet and private founder handoff files.
|
|
No Kubernetes, database, OpenBao or provider access.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import pwd
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
from vergabe_pilot_recovery_store import Refused, digest, private_directory, require
|
|
|
|
RUN_ID = "20261105T233543Z-89bfbe04dece"
|
|
BASE = Path("/var/lib/vergabe-pilot-recovery")
|
|
NAME = "vergabe-pilot-" + RUN_ID
|
|
# Public recipient declared by railiance-platform/lib/railiance-backup-common.sh.
|
|
RECIPIENT = "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz"
|
|
EXPECTED = {
|
|
"database.dump": (169232, "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c"),
|
|
"stores.tar.gz": (3489, "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66"),
|
|
}
|
|
IMAGE = ("forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:"
|
|
"a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91")
|
|
|
|
|
|
def regular(path, owner):
|
|
info = path.lstat()
|
|
require(stat.S_ISREG(info.st_mode) and info.st_uid == owner
|
|
and not info.st_mode & 0o077, "private_regular_file_required")
|
|
return info
|
|
|
|
|
|
def capture_files(directory):
|
|
private_directory(directory)
|
|
files = {}
|
|
for name in [*EXPECTED, "manifest.private.json", "receipt.json"]:
|
|
path = directory / name
|
|
info = regular(path, os.geteuid())
|
|
require(0 < info.st_size <= 1024 * 1024, "capture_file_bound")
|
|
files[name] = {"bytes": info.st_size, "sha256": digest(path)}
|
|
if name in EXPECTED:
|
|
require((info.st_size, files[name]["sha256"]) == EXPECTED[name], "capture_hash_mismatch")
|
|
receipt = json.loads((directory / "receipt.json").read_text())
|
|
require(receipt["run_id"] == RUN_ID and receipt["status"] == "captured_and_resumed"
|
|
and receipt["helm_revision"] == 4 and receipt["database"] == "vergabe_demo_company"
|
|
and all(receipt.get(key) is True for key in ["app_resumed", "writers_quiesced",
|
|
"helper_cleanup_confirmed", "restart_fixture_verified"]), "capture_not_accepted")
|
|
for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]:
|
|
require(receipt[prefix + "_sha256"] == files[name]["sha256"]
|
|
and receipt[prefix + "_bytes"] == files[name]["bytes"], "receipt_hash_mismatch")
|
|
manifest = json.loads((directory / "manifest.private.json").read_text())
|
|
require(manifest["image"] == IMAGE and manifest["database"] == "vergabe_demo_company",
|
|
"capture_binding_mismatch")
|
|
return files
|
|
|
|
|
|
def seal(directory, age):
|
|
files = capture_files(directory)
|
|
metadata = {"schema": "railiance.vergabe-pilot-recovery-packet.v1", "run_id": RUN_ID,
|
|
"database": "vergabe_demo_company", "image": IMAGE, "files": files}
|
|
ciphertext = directory / "recovery-point.zip.age"
|
|
seal_receipt = directory / "seal.json"
|
|
if ciphertext.exists() or seal_receipt.exists():
|
|
regular(ciphertext, os.geteuid())
|
|
regular(seal_receipt, os.geteuid())
|
|
result = json.loads(seal_receipt.read_text())
|
|
require(result["packet"] == metadata and result["recipient"] == RECIPIENT
|
|
and result["ciphertext_sha256"] == digest(ciphertext)
|
|
and result["ciphertext_bytes"] == ciphertext.stat().st_size,
|
|
"existing_seal_mismatch")
|
|
return ciphertext, result
|
|
with tempfile.TemporaryDirectory(prefix="seal-", dir=directory) as temporary:
|
|
packet = Path(temporary) / "recovery-point.zip"
|
|
with zipfile.ZipFile(packet, "x", compression=zipfile.ZIP_STORED) as archive:
|
|
for name in files:
|
|
archive.write(directory / name, name)
|
|
archive.writestr("packet.json", json.dumps(metadata, sort_keys=True))
|
|
packet.chmod(0o600)
|
|
# Recheck frozen source hashes after packing, before encryption/publication.
|
|
require(capture_files(directory) == files, "capture_changed_during_export")
|
|
encrypted = Path(temporary) / "packet.age"
|
|
fd = os.open(encrypted, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, "wb") as output:
|
|
result = subprocess.run([age, "-r", RECIPIENT, str(packet)], stdout=output,
|
|
stderr=subprocess.DEVNULL, timeout=60)
|
|
require(result.returncode == 0, "encryption_failed")
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
require(encrypted.stat().st_size <= 4 * 1024 * 1024, "encrypted_packet_bound")
|
|
with encrypted.open("rb") as source:
|
|
require(source.read(22) == b"age-encryption.org/v1\n", "age_header_required")
|
|
result = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": RUN_ID,
|
|
"recipient": RECIPIENT, "ciphertext_sha256": digest(encrypted),
|
|
"ciphertext_bytes": encrypted.stat().st_size, "packet": metadata,
|
|
"provider_publication_verified": False, "restore_verified": False}
|
|
fd = os.open(seal_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, "w") as output:
|
|
json.dump(result, output, indent=2)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
# No overwrite, including after an interrupted export. A partial pair
|
|
# requires review rather than silently replacing an earlier ciphertext.
|
|
os.link(encrypted, ciphertext)
|
|
return ciphertext, result
|
|
|
|
|
|
def handoff(source, directory, name, uid, gid):
|
|
target = directory / name
|
|
if target.exists() or target.is_symlink():
|
|
regular(target, uid)
|
|
require(digest(target) == digest(source), "existing_handoff_mismatch")
|
|
return
|
|
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
with os.fdopen(fd, "wb") as output, source.open("rb") as stream:
|
|
shutil.copyfileobj(stream, output)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
os.fchown(output.fileno(), uid, gid)
|
|
|
|
|
|
def main():
|
|
require(os.geteuid() == 0, "founder_node_sudo_required")
|
|
account = pwd.getpwnam("tegwick")
|
|
require(os.environ.get("SUDO_USER") == account.pw_name
|
|
and os.environ.get("SUDO_UID") == str(account.pw_uid) and account.pw_uid > 0,
|
|
"founder_ssh_account_required")
|
|
home = Path(account.pw_dir)
|
|
info = home.lstat()
|
|
require(str(home) == "/home/tegwick" and stat.S_ISDIR(info.st_mode)
|
|
and info.st_uid == account.pw_uid and not info.st_mode & 0o022,
|
|
"founder_home_required")
|
|
age = next((str(p) for p in [Path("/usr/bin/age"), Path("/usr/local/bin/age"),
|
|
Path("/opt/railiance-platform/tools/vendor/bin/age"),
|
|
home / "railiance-platform/tools/vendor/bin/age"] if p.is_file() and os.access(p, os.X_OK)), None)
|
|
require(age, "age_unavailable")
|
|
private_directory(BASE)
|
|
encrypted, result = seal(BASE / RUN_ID, age)
|
|
handoff(encrypted, home, NAME + ".zip.age", account.pw_uid, account.pw_gid)
|
|
handoff(encrypted.parent / "seal.json", home, NAME + ".transfer.json", account.pw_uid, account.pw_gid)
|
|
print(json.dumps({"status": "encrypted_handoff_ready", "run_id": RUN_ID,
|
|
"ciphertext_sha256": result["ciphertext_sha256"],
|
|
"ciphertext_bytes": result["ciphertext_bytes"], "application_changed": False}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
os.umask(0o077)
|
|
try:
|
|
main()
|
|
except Exception as error:
|
|
print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused)
|
|
else "export_command_failed", "details_suppressed": True}))
|
|
raise SystemExit(1)
|