#!/usr/bin/env python3 """Download a private backup and publish its filename only after full verification.""" import base64 import datetime import hashlib import json import os from pathlib import Path import re import shlex import subprocess import sys import tarfile import tempfile import uuid def verify(path): hashes = {} documents = {} with tarfile.open(path, mode='r|gz') as archive: for entry in archive: name = entry.name if (not entry.isfile() or name in hashes or name in {'data/.', 'data/..'} or not (name in {'backup.json', 'release/manifest.json', 'release/werkjournal.jar'} or re.fullmatch(r'data/[A-Za-z0-9_.-]+', name))): raise ValueError('Unexpected or duplicate backup member') with archive.extractfile(entry) as stream: if name in {'backup.json', 'release/manifest.json'}: if entry.size > 1024 * 1024: raise ValueError('Backup metadata is too large') content = stream.read() documents[name] = json.loads(content) hashes[name] = hashlib.sha256(content).hexdigest() else: hashes[name] = hashlib.file_digest(stream, 'sha256').hexdigest() metadata = documents.get('backup.json', {}) release = documents.get('release/manifest.json', {}) hashes.pop('backup.json', None) if (metadata.get('schemaVersion') != 1 or metadata.get('application') != 'werkjournal' or metadata.get('files') != hashes or metadata.get('release') != release or not {'data/werkjournal.mv.db', 'release/manifest.json', 'release/werkjournal.jar'} <= hashes.keys() or release.get('schemaVersion') != 1 or release.get('runtime') != 'java-25' or not re.fullmatch('[0-9a-f]{40}', release.get('commit', '')) or hashes.get('release/werkjournal.jar') != release.get('sha256') or release.get('files') != {'werkjournal.jar': release.get('sha256')}): raise ValueError('Backup contents or checksums do not match its metadata') return metadata def download(target, destination): destination = Path(destination) if destination.exists() or destination.is_symlink(): raise FileExistsError('Backup destination already exists') destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) root = Path(__file__).parent helper = base64.b64encode((root / 'deploy-update-remote.py').read_bytes()).decode('ascii') backup = base64.b64encode((root / 'backup-remote.py').read_bytes()).decode('ascii') program = ("import base64; scope={'__name__':'backup_helpers'}; " "exec(compile(base64.b64decode('" + helper + "'),'deployment','exec'),scope); " "scope['__name__']='__main__'; " "exec(compile(base64.b64decode('" + backup + "'),'backup','exec'),scope)") fd, temporary = tempfile.mkstemp(prefix='.werkjournal-backup-', dir=destination.parent) try: with os.fdopen(fd, 'wb') as output: result = subprocess.run(['ssh', '-o', 'BatchMode=yes', target, shlex.join(['python3', '-c', program])], stdout=output) output.flush() os.fsync(output.fileno()) if result.returncode != 0: raise RuntimeError('Remote backup failed; no local backup was published') metadata = verify(temporary) # Atomic and never overwrites a backup created meanwhile by another process. os.link(temporary, destination) directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(directory) finally: os.close(directory) return metadata finally: Path(temporary).unlink(missing_ok=True) if __name__ == '__main__': stamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') destination = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).resolve().parents[1] / 'backups' / f'werkjournal-{stamp}-{uuid.uuid4().hex[:8]}.tgz' try: metadata = download(sys.argv[1], destination) except (OSError, RuntimeError, ValueError, tarfile.TarError) as error: print('Backup failed: ' + str(error), file=sys.stderr) raise SystemExit(1) print('Verified private backup: ' + str(destination)) print('Application commit: ' + metadata['release']['commit'])