#!/usr/bin/env python3 """Silent attended transfer of a real encrypted backup for isolated recovery.""" import argparse from datetime import datetime, timezone import base64 import urllib.request import shutil import hashlib import json import zipfile import os from pathlib import Path import subprocess from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, NoRedirect, quota, require from state_hub_preflight_lane import bao, data, LaneError def run(source, directory, receipt): require(source.is_file() and source.name.endswith('.zip.age'), 'encrypted_backup_required') entry = data(bao(['read', '-format=json', OPERATOR_PATH]))['data']['data'] require(entry['BACKUP_USERNAME'] == 'Backup', 'backup_owner_required') auth = (entry['BACKUP_USERNAME'], entry['BACKUP_PASSWORD']) receipt['quota_before'] = quota(auth) lane = data(bao(['read', '-format=json', LANE]))['data'] values = lane['data'] require(source.stat().st_size < receipt['quota_before']['available_bytes'], 'insufficient_quota') name = 'wp0029-recovery-' + source.name receipt.update(artifact=name, source_backup=source.name, kv_version=lane['metadata']['version']) directory.mkdir(mode=0o700) encrypted = directory/'fetched.zip.age' def stream(url, method, credentials, source_file=None, target=None): require(url.startswith(HOST+'/'), 'unapproved_provider_origin') headers={'Authorization':'Basic '+base64.b64encode((credentials[0]+':'+credentials[1]).encode()).decode()} if source_file: headers.update({'Content-Length':str(source.stat().st_size),'Content-Type':'application/octet-stream','If-None-Match':'*'}) req=urllib.request.Request(url,data=source_file,method=method,headers=headers) with urllib.request.build_opener(NoRedirect()).open(req,timeout=1200) as response: if target: with target.open('xb') as out: target.chmod(0o600) shutil.copyfileobj(response,out,1024*1024) else: response.read() return response.status with source.open('rb') as payload: code=stream(values['NC_WEBDAV_URL']+'/forgejo/'+name,'PUT', (values['NC_WEBDAV_TOKEN'],''),source_file=payload) require(code == 201, 'new_offsite_upload_required') receipt['upload_http_status']=code code=stream(HOST+'/remote.php/dav/files/Backup/railiance-backups/forgejo/'+name,'GET',auth,target=encrypted) def sha(path): with path.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest() digest=sha(encrypted) require(code==200 and digest==sha(source), 'offsite_download_mismatch') receipt.update(download_http_status=code,ciphertext_bytes=encrypted.stat().st_size,ciphertext_sha256=digest) plain = directory/'fetched.zip' with plain.open('xb') as output: plain.chmod(0o600) result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)], input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200) require(result.returncode == 0, 'offsite_decryption_failed') with zipfile.ZipFile(plain) as archive: profile=json.loads(archive.read('backup-manifest.json'))['profile'] if 'backup-manifest.json' in archive.namelist() else 'full' require(profile in ('full','essentials'), 'unknown_archive_profile') receipt.update(archive_profile=profile,decrypted=True, plaintext_sha256=sha(plain), quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore') def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--source',required=True,type=Path) p.add_argument('--directory',required=True,type=Path) p.add_argument('--receipt',required=True) a=p.parse_args() fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) receipt={'schema':'platform.real-offsite-recovery.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat()} try: run(a.source,a.directory,receipt) except Exception as error: receipt['error']=str(error) if isinstance(error,LaneError) else 'internal_error' finally: receipt['finished_at']=datetime.now(timezone.utc).isoformat() with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2) return int(receipt['status']=='failed') if __name__=='__main__': raise SystemExit(main())