net-kingdom/tools/bootstrap-recovery/install-identity-backup.py
tegwick a17aa7edc1
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix(recovery): bind admitted host and record live provider scope approval
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 07:44:36 +02:00

145 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Render/install a source-bound node runtime; activation needs actual recovery.
Install does not enable timers. Owner applies the separately reviewed delivery
and capture-isolation changes, performs first capture and independent recovery,
then explicitly activates. No credential values enter this package.
"""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import uuid
ROOT=Path(__file__).resolve().parents[2]
HERE=Path(__file__).resolve().parent
FILES=[f'tools/bootstrap-recovery/{name}' for name in ['capture-identity.py','identity-files.py','identity-bundle.py',
'identity-runtime.py','identity-offsite.py','identity-retention.py','identity-backup-profile.json',
'prepare-identity-coverage.py','install-identity-backup.py']]+['sso-mfa/k8s/privacyidea/key-custody-remote.py']
STATE='/var/lib/net-kingdom-backup'
KUBECONFIG='/etc/rancher/k3s/k3s.yaml'
class Refused(Exception):pass
def render(decision):
uuid.UUID(decision)
files=[{'path':name,'sha256':hashlib.sha256((ROOT/name).read_bytes()).hexdigest()} for name in FILES]
release=hashlib.sha256(json.dumps(files,sort_keys=True).encode()).hexdigest()
directory='/opt/net-kingdom-backup/releases/'+release
runtime=directory+'/tools/bootstrap-recovery/identity-runtime.py'
common=f' --kubeconfig {KUBECONFIG} --state {STATE}'
base=('User=root\nGroup=root\nUMask=0077\nStateDirectory=net-kingdom-backup\nStateDirectoryMode=0700\n'
'NoNewPrivileges=true\nPrivateTmp=true\nPrivateDevices=true\nProtectSystem=strict\nProtectHome=true\n'
f'ReadWritePaths={STATE}\nCapabilityBoundingSet=\nRestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\n'
'ProtectKernelTunables=true\nProtectKernelModules=true\nProtectControlGroups=true\n'
'MemoryMax=3G\nCPUQuota=200%\nTasksMax=128\nTimeoutStartSec=45min\nTimeoutStopSec=5min\n')
command='/usr/bin/python3 -I '+runtime
service=('[Unit]\nDescription=Encrypted full identity backup and verified Storage Share readback\nAfter=network-online.target k3s.service\n'
f'ConditionPathExists={STATE}/accepted-recovery.json\n[Service]\nType=oneshot\n'+base+
f'ExecStartPre={command} retention{common}\nExecStart={command} scheduled{common} --decision-id {decision}\n'
f'ExecStartPost={command} retention{common}\n')
health=('[Unit]\nDescription=Identity backup age check with durable failure reporting\nAfter=network-online.target k3s.service\n'
'[Service]\nType=oneshot\n'+base+f'ExecStart={command} status{common}\n')
units={'net-kingdom-identity-backup.service':service,
'net-kingdom-identity-backup.timer':'[Unit]\nDescription=Daily full identity recovery bundle\n[Timer]\nOnCalendar=*-*-* 03:45:00 UTC\nPersistent=true\nRandomizedDelaySec=5min\nUnit=net-kingdom-identity-backup.service\n[Install]\nWantedBy=timers.target\n',
'net-kingdom-identity-backup-health.service':health,
'net-kingdom-identity-backup-health.timer':'[Unit]\nDescription=Hourly identity backup freshness check\n[Timer]\nOnCalendar=hourly\nPersistent=true\nUnit=net-kingdom-identity-backup-health.service\n[Install]\nWantedBy=timers.target\n'}
return {'schema':'netkingdom.identity-runtime-install.v1','status':'prepared_not_installed','decision_ref':decision,
'release_id':release,'release_directory':directory,'runtime_files':files,'units':units,
'timers_automatically_enabled':False,'first_capture_command':command+' first-capture'+common+' --decision-id '+decision,
'activation_requires_independent_recovery':True,'source_contains_credentials':False}
def run(args):
r=subprocess.run(args,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,timeout=120)
if r.returncode:raise Refused()
return r.stdout
def module(name):
spec=importlib.util.spec_from_file_location(name,HERE/(name+'.py'));m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m);return m
def owner():
module('identity-bundle').require_runtime_host()
capture=module('capture-identity');capture.KUBE=['kubectl','--kubeconfig',KUBECONFIG]
if capture.get(None,'namespace','kube-system')['metadata']['uid']!=capture.prepare.CLUSTER_UID:raise Refused()
def install(packet):
owner();expected=render(packet['decision_ref'])
if packet!=expected:raise Refused()
destination=Path(packet['release_directory'])
# Refuse differing prior units before changing files. Installation never
# overwrites a running/unknown version as an implicit upgrade.
for name,value in packet['units'].items():
path=Path('/etc/systemd/system')/name
if path.is_symlink() or (path.exists() and path.read_text()!=value):raise Refused()
destination.mkdir(mode=0o700,parents=True,exist_ok=True)
for row in packet['runtime_files']:
target=destination/row['path'];target.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
if target.exists():
if target.is_symlink() or hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
else:
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
with os.fdopen(fd,'wb') as out:out.write((ROOT/row['path']).read_bytes())
if hashlib.sha256(target.read_bytes()).hexdigest()!=row['sha256']:raise Refused()
for name,value in packet['units'].items():
path=Path('/etc/systemd/system')/name
if not path.exists():
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o644)
with os.fdopen(fd,'w') as out:out.write(value)
run(['systemd-analyze','verify',*[str(Path('/etc/systemd/system')/name) for name in packet['units']]])
run(['systemctl','daemon-reload'])
def activate(proof):
owner();runtime=module('identity-runtime');r=runtime.accepted(proof)
state=Path(STATE);runtime.capture.bundle.private(state,True)
last=json.loads((state/'last-success.json').read_text())
if last.get('offsite_verified') is not True:raise Refused()
# Restore proof names the exact protected artifact; require its local
# off-site receipt too, not a synthetic JSON worksheet.
path=state/'runs'/r['artifact']/'offsite.json'
runtime.capture.bundle.private(path);transfer=json.loads(path.read_text())
if transfer.get('status')!='readback_verified' or transfer.get('ciphertext_sha256')!=r['ciphertext_sha256']:raise Refused()
target=state/'accepted-recovery.json'
if target.exists():
if target.is_symlink() or json.loads(target.read_text())!=r:raise Refused()
else:
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
run(['systemctl','start','net-kingdom-identity-backup-health.service'])
run(['systemctl','enable','--now','net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer'])
for unit in ['net-kingdom-identity-backup.timer','net-kingdom-identity-backup-health.timer']:
if run(['systemctl','is-active',unit]).strip()!=b'active':raise Refused()
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('action',choices=['render','install','activate'])
p.add_argument('--decision-id');p.add_argument('--packet');p.add_argument('--packet-sha256');p.add_argument('--accepted-receipt')
p.add_argument('--receipt',required=True);a=p.parse_args();os.umask(0o077)
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600);r={'status':'failed'}
try:
if a.action=='render':r=render(a.decision_id)
elif a.action=='install':
raw=Path(a.packet).read_bytes()
if hashlib.sha256(raw).hexdigest()!=a.packet_sha256:raise Refused()
packet=json.loads(raw);install(packet)
r={'status':'installed_timers_disabled','release_id':packet['release_id']}
else:
activate(Path(a.accepted_receipt));r={'status':'timers_active_after_independent_recovery'}
except Exception:r={'status':'failed','error':'owner_install_or_activation_refused_private_diagnostics_suppressed'}
finally:
with os.fdopen(fd,'w') as out:json.dump(r,out,indent=2);out.write('\n')
return int(r['status']=='failed')
if __name__=='__main__':raise SystemExit(main())