From 7b80c0d8cdc7b234fc54402b0cc240c4f3d1ee81 Mon Sep 17 00:00:00 2001 From: FrameBridge Date: Wed, 28 Oct 2026 13:58:24 -0400 Subject: [PATCH] Record guarded public overlay activation with verified backups --- tools/activate_public_overlay.py | 151 +++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tools/activate_public_overlay.py diff --git a/tools/activate_public_overlay.py b/tools/activate_public_overlay.py new file mode 100644 index 0000000..83499b5 --- /dev/null +++ b/tools/activate_public_overlay.py @@ -0,0 +1,151 @@ +#!/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()