Record guarded public overlay activation with verified backups
This commit is contained in:
1 parent
efd67fcbfb
commit
7b80c0d8cd
1 file changed
+151
@@ -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()
|
||||
Reference in new issue
Block a user