Files
framebridge/tools/activate_public_overlay.py
T

152 lines
6.5 KiB
Python

#!/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()