95 lines
4.4 KiB
Python
95 lines
4.4 KiB
Python
#!/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'])
|