net-kingdom/tools/bootstrap-recovery/identity-files.py
tegwick 994d11c869
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
feat(recovery): prepare full identity backup and bootstrap handoff
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 01:37:11 +02:00

109 lines
4.6 KiB
Python

#!/usr/bin/env python3
"""Private in-helper PVC capture. Stdout is a secret-bearing tar: parent MUST capture.
Sources are mounted read-only; SQLite uses its online backup API, never raw copy
or immutable=1. No production file is generated, replaced, chmod'd or removed.
"""
import hashlib
import io
import os
from pathlib import Path
import signal
import sqlite3
import stat
import sys
import tarfile
import tempfile
MAX_FILE=256*1024*1024
MAX_TOTAL=512*1024*1024
MAX_COUNT=2048
class Refused(Exception):pass
def read(path):
if any(p.is_symlink() for p in (path,*path.parents)):raise Refused()
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
try:
before=os.fstat(fd)
if not stat.S_ISREG(before.st_mode) or before.st_nlink!=1 or before.st_size>MAX_FILE:raise Refused()
with os.fdopen(os.dup(fd),'rb') as stream:data=stream.read(MAX_FILE+1)
after=os.stat(path,follow_symlinks=False)
if (len(data)>MAX_FILE or b'AGE-SECRET-KEY-' in data or
any(getattr(before,k)!=getattr(after,k) for k in ('st_dev','st_ino','st_size','st_mtime_ns','st_ctime_ns'))):raise Refused()
return data
finally:os.close(fd)
def tree(root,database=None,omit_notification=False,omit_backups=True):
result={}
for directory,dirs,files in os.walk(root,followlinks=False):
path=Path(directory)
if path.is_symlink():raise Refused()
# Historical local snapshots are redundant; do not recursively include
# accumulated backups. WAL/SHM are represented by the native snapshot.
if omit_backups:dirs[:]=[name for name in dirs if name!='backups']
for name in dirs:
if (path/name).is_symlink():raise Refused()
for name in files:
relative=(path/name).relative_to(root).as_posix()
if database and relative in {database,database+'-wal',database+'-shm',database+'-journal'}:continue
if omit_notification and relative=='notification.txt':continue
result[relative]=read(path/name)
if len(result)>MAX_COUNT or sum(map(len,result.values()))>MAX_TOTAL:raise Refused()
return result
def snapshot(source,target):
if source.is_symlink() or not source.is_file():raise Refused()
# A read-only URI intentionally fails if the engine needs a write to its
# production WAL/SHM. Never fall back to a raw copy or mutate the mount.
with sqlite3.connect(source.as_uri()+'?mode=ro',uri=True,timeout=10) as origin:
with sqlite3.connect(target) as dest:
origin.backup(dest,pages=128,sleep=0.05)
if dest.execute('PRAGMA integrity_check').fetchall()!=[('ok',)]:raise Refused()
return read(target)
def capture(mode,root=Path('/source'),scratch=Path('/scratch')):
values={}
with tempfile.TemporaryDirectory(prefix='snapshot-',dir=scratch) as temporary:
if mode=='sso':
for name,database in [('lldap','users.db'),('authelia','db.sqlite3'),('keycape',None)]:
source=root/name
if source.is_symlink() or not source.is_dir():raise Refused()
first=tree(source,database,name=='authelia',name!='keycape')
if database:first[database]=snapshot(source/database,Path(temporary)/(name+'.sqlite'))
second=tree(source,database,name=='authelia',name!='keycape')
if {k:v for k,v in first.items() if k!=database}!=second:raise Refused()
values.update({f'data/{name}/{k}':v for k,v in first.items()})
elif mode=='mfa':
first=tree(root/'privacyidea')
if not {'enckey','private.pem','public.pem'}<=set(first) or first!=tree(root/'privacyidea'):raise Refused()
values={f'data/privacyidea/{name}':value for name,value in first.items()}
else:raise Refused()
if len(values)>MAX_COUNT or sum(map(len,values.values()))>MAX_TOTAL:raise Refused()
return values
def emit(values,destination):
with tarfile.open(fileobj=destination,mode='w|') as archive:
for name,value in sorted(values.items()):
member=tarfile.TarInfo(name);member.size=len(value);member.mode=0o600
archive.addfile(member,io.BytesIO(value))
def interrupted(*args):raise Refused()
if __name__=='__main__':
for sig in (signal.SIGTERM,signal.SIGHUP,signal.SIGINT):signal.signal(sig,interrupted)
try:
os.umask(0o077)
if len(sys.argv)!=2:raise Refused()
emit(capture(sys.argv[1]),sys.stdout.buffer)
except Exception:
print('ERROR: private file capture refused; diagnostics suppressed.',file=sys.stderr)
raise SystemExit(1)