railiance-platform/scripts/verify_nextcloud_offsite_restore.py

83 lines
4.3 KiB
Python
Raw Normal View History

#!/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())