152 lines
6.5 KiB
Python
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()
|