#!/usr/bin/env python3 """Apply the reviewed public static overlay with per-file backups and readback.""" import hashlib import json import os from pathlib import Path, PurePosixPath import shutil import socket import stat import subprocess import tempfile STAGE = Path('/var/lib/framebridge-release-publication/20261028-r2') ORIGIN = Path('/srv/geranas-services/framebridge-public') MANIFEST_SHA = '6bbdcacd9745af413546074b80619590a3b0982119c4fbf5586ff9e806794289' COMMIT = 'a52625219e5d7eeadcc0c91d1d0fd92108e6b51a' JOURNAL = STAGE / 'ACTIVATION-RECEIPT.json' BACKUP = STAGE / 'pre-activation-backup' def require(value, message): if not value: raise ValueError(message) def digest(path): h = hashlib.sha256() with path.open('rb') as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b''): h.update(chunk) return h.hexdigest() def path_safe(path): require(path.is_absolute(), 'Absolute path required') for parent in (path, *path.parents): if parent.exists() or parent.is_symlink(): s = parent.lstat() require(not stat.S_ISLNK(s.st_mode), 'Symlink path rejected') require(s.st_uid == 0 and not s.st_mode & 0o022, 'Untrusted writable path') def verify(path, record): require(path.is_file() and not path.is_symlink(), 'Expected regular public file') require(path.stat().st_size == record['bytes'] and digest(path) == record['sha256'], 'Public file content mismatch: ' + str(path)) def save(state): temporary = JOURNAL.with_suffix('.partial') with temporary.open('x') as stream: json.dump(state, stream, sort_keys=True, indent=2) stream.write('\n') stream.flush() os.fsync(stream.fileno()) temporary.replace(JOURNAL) def priority(name): if name.startswith('debian/dists/') and name.endswith(('/InRelease', '/Release', '/Release.gpg')): return 2 if name.startswith('debian/dists/'): return 1 if name.startswith('site/'): return 3 return 0 def main(): require(os.geteuid() == 0 and socket.gethostname().lower() == 'geranas', 'GERANAS root context required') os.umask(0o077) for path in (STAGE, ORIGIN): path_safe(path) manifest_path = STAGE / 'PUBLIC-OVERLAY-MANIFEST.json' require(digest(manifest_path) == MANIFEST_SHA, 'Reviewed manifest changed') manifest = json.loads(manifest_path.read_text()) require(manifest['source_commit'] == COMMIT and manifest['version'] == '0.3.3', 'Wrong release') require(len(manifest['files']) == 70, 'Unexpected overlay inventory') require(not JOURNAL.exists() and not BACKUP.exists(), 'Existing transaction; inspect before replay') entries = {} for name, record in manifest['files'].items(): p = PurePosixPath(name) require(not p.is_absolute() and '..' not in p.parts and p.parts[0] in ('site','downloads','debian'), 'Unsafe manifest path') source, destination = STAGE / 'overlay-files' / name, ORIGIN / name path_safe(source) path_safe(destination) verify(source, record) entries[name] = {'new': record, 'old': None} if destination.exists(): require(destination.is_file(), 'Non-file destination') s = destination.stat() entries[name]['old'] = {'bytes': s.st_size, 'sha256': digest(destination), 'uid': s.st_uid, 'gid': s.st_gid, 'mode': stat.S_IMODE(s.st_mode)} for name, record in manifest['preserve_required'].items(): verify(ORIGIN / name, record) master = subprocess.run(['systemctl','show','framebridge-web.service','--property=MainPID','--value'], check=True, capture_output=True, text=True).stdout.strip() state = {'schema': 1, 'phase': 'PREPARED', 'source_commit': COMMIT, 'version': '0.3.3', 'manifest_sha256': MANIFEST_SHA, 'entries': entries, 'applied': [], 'master_pid': master, 'backup_directory': str(BACKUP), 'other_services_changed': False} descriptor = os.open(JOURNAL, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) os.close(descriptor) save(state) BACKUP.mkdir(mode=0o700) for name, entry in entries.items(): if entry['old'] is not None: backup = BACKUP / name backup.parent.mkdir(parents=True, exist_ok=True, mode=0o700) shutil.copy2(ORIGIN / name, backup) verify(backup, entry['old']) state['phase'] = 'BACKUP_VERIFIED' save(state) for name in sorted(entries, key=lambda n:(priority(n),n)): source, destination = STAGE / 'overlay-files' / name, ORIGIN / name old = entries[name]['old'] if old is not None: verify(destination, old) else: require(not destination.exists(), 'Destination created since guard') missing = [] parent = destination.parent while not parent.exists(): missing.append(parent) parent = parent.parent for directory in reversed(missing): directory.mkdir(mode=0o755) directory.chmod(0o755) fd, temporary = tempfile.mkstemp(prefix='.fb-release-', dir=destination.parent) try: with os.fdopen(fd, 'wb') as stream, source.open('rb') as inp: shutil.copyfileobj(inp, stream) stream.flush() os.fsync(stream.fileno()) os.chmod(temporary, old['mode'] if old else 0o644) if old: os.chown(temporary, old['uid'], old['gid']) verify(Path(temporary), entries[name]['new']) os.replace(temporary, destination) state['applied'].append(name) state['phase'] = 'APPLYING' save(state) finally: if os.path.exists(temporary): os.unlink(temporary) for name, entry in entries.items(): verify(ORIGIN / name, entry['new']) for name, record in manifest['preserve_required'].items(): verify(ORIGIN / name, record) after = subprocess.run(['systemctl','show','framebridge-web.service','--property=MainPID','--value'], check=True, capture_output=True, text=True).stdout.strip() require(master == after, 'Unexpected web master change') state.update(phase='COMPLETE', files_verified=70, dependency_sources_preserved=True, web_master_preserved=True, old_release_files_removed=False) save(state) print(json.dumps({k:v for k,v in state.items() if k not in ('entries','applied')}, sort_keys=True)) if __name__ == '__main__': main()