#!/usr/bin/env python3 """Attended Backup-account cutover. Provider credentials never leave captured memory.""" import argparse import base64 import json import os from pathlib import Path import re import secrets import subprocess import tempfile import urllib.error import urllib.parse import urllib.request import xml.etree.ElementTree as ET from state_hub_preflight_lane import ROOT, LaneError, bao, data HOST = 'https://nx4069.your-storageshare.de' OPERATOR_PATH = 'operators/data/nextcloud/backup' LANE = 'platform/data/workloads/railiance/backup/offsite-lane' FOLDER = '/railiance-backups' LABEL = 'railiance-backup-upload-only' QUOTA = 10 * 1024**3 PREFIXES = ['forgejo', 'core-apps-pg', 'core-gitea-db', 'core-net-kingdom-pg', 'core-state-hub-db', 'r01-forgejo-db', 'r01-platform-pg', 'r01-net-kingdom-pg', 'r01-state-hub-db', 'r01-user-engine-pg'] def require(ok, message): if not ok: raise LaneError(message) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *args, **kwargs): return None def request(url, method='GET', body=None, auth=None, headers=None): require(url.startswith(HOST + '/'), 'unapproved_provider_origin') headers = dict(headers or {}) if auth: headers['Authorization'] = 'Basic ' + base64.b64encode((auth[0] + ':' + auth[1]).encode()).decode() req = urllib.request.Request(url, data=body, method=method, headers=headers) try: with urllib.request.build_opener(NoRedirect()).open(req, timeout=60) as response: return response.status, response.read() except urllib.error.HTTPError as error: return error.code, b'' def quota(auth): body = b'' status, result = request(HOST + '/remote.php/dav/files/Backup/', 'PROPFIND', body, auth, {'Depth':'0','Content-Type':'application/xml'}) require(status == 207, 'backup_account_auth_or_quota_failed') root = ET.fromstring(result) available = int(root.findtext('.//{DAV:}quota-available-bytes')) used = int(root.findtext('.//{DAV:}quota-used-bytes')) require(available >= 0 and used >= 0 and available + used == QUOTA, 'quota_contract_mismatch') return {'quota_bytes': QUOTA, 'used_bytes': used, 'available_bytes': available} def ocs(auth, suffix='', method='GET', fields=None): body = urllib.parse.urlencode(fields).encode() if fields is not None else None code, content = request(HOST + '/ocs/v2.php/apps/files_sharing/api/v1/shares' + suffix, method, body, auth, {'OCS-APIRequest':'true','Accept':'application/json', 'Content-Type':'application/x-www-form-urlencoded'}) require(code in (200, 201), 'share_api_failed') response = json.loads(content)['ocs'] require(response['meta']['statuscode'] in (100, 200), 'share_operation_rejected') return response['data'] def subprocess_bytes(argv, value): result = subprocess.run(argv, input=value, capture_output=True, timeout=60) require(result.returncode == 0, 'protected_crypto_failed') return result.stdout def run(args, receipt): identity = data(bao(['token', 'lookup', '-format=json']))['data'] require('platform-admin' in identity['policies'] and 'root' not in identity['policies'], 'attended_platform_admin_required') entry = data(bao(['read', '-format=json', OPERATOR_PATH]))['data']['data'] require(entry['BACKUP_USERNAME'] == 'Backup' and bool(entry['BACKUP_PASSWORD']), 'operator_account_contract_mismatch') auth = (entry['BACKUP_USERNAME'], entry['BACKUP_PASSWORD']) receipt['quota_before'] = quota(auth) old = data(bao(['read', '-format=json', LANE]))['data'] expected_version = old['metadata']['version'] if args.expected_version is not None: require(expected_version == args.expected_version, 'kv_version_changed') receipt['previous_kv_version'] = expected_version values = old['data'] require(bool(values.get('AGE_PRIVATE_KEY')), 'recovery_escrow_missing') recipient = re.search(r'age1[0-9a-z]+', (ROOT / 'lib/railiance-backup-common.sh').read_text()).group(0) owner_root = HOST + '/remote.php/dav/files/Backup' + FOLDER for folder in [FOLDER] + [FOLDER + '/' + name for name in PREFIXES]: code, _ = request(HOST + '/remote.php/dav/files/Backup' + folder, 'MKCOL', auth=auth) require(code in (201, 405), 'backup_folder_creation_failed') shares = ocs(auth, '?path=' + urllib.parse.quote(FOLDER, safe='')) matches = [s for s in shares if s.get('label') == LABEL] require(len(matches) <= 1, 'ambiguous_existing_share') created = False if matches: share = matches[0] else: share = ocs(auth, method='POST', fields={'path':FOLDER,'shareType':3,'permissions':4,'label':LABEL,'sendMail':'false'}) created = True share_id = str(share['id']) receipt['share_id'] = share_id receipt['observed_permissions'] = int(share['permissions']) receipt['owner_matches'] = share.get('uid_owner') == 'Backup' receipt['path_matches'] = share.get('path') == FOLDER if share.get('uid_owner') != 'Backup' or share.get('path') != FOLDER or int(share['permissions']) != 4: if created: ocs(auth, '/' + share_id, 'DELETE') raise LaneError('create_only_share_contract_failed') receipt.update(share_owner='Backup', share_permissions=4) token = share['token'] # Existing backup clients already implement this file-drop endpoint and auth. upload_root = HOST + '/public.php/dav/filesdrop/' + token filename = 'account-acceptance-' + secrets.token_hex(12) + '.age' receipt['fixture_name'] = filename fixture_path = '/forgejo/' + filename plain = b'railiance-backup-account-acceptance-v1\n' + secrets.token_bytes(64) encrypted = subprocess_bytes(['age', '-r', recipient], plain) require(len(encrypted) < receipt['quota_before']['available_bytes'], 'insufficient_quota') code, _ = request(upload_root + fixture_path, 'PUT', encrypted, (token, ''), {'Content-Type':'application/octet-stream','If-None-Match':'*'}) receipt['upload_http_status'] = code require(code in (200, 201, 204), 'upload_fixture_failed') code, downloaded = request(owner_root + fixture_path, auth=auth) require(code == 200 and downloaded == encrypted, 'owner_download_mismatch') with tempfile.TemporaryDirectory(prefix='backup-account-acceptance-') as tmp: cipher = Path(tmp) / 'fixture.age' cipher.write_bytes(downloaded) restored = subprocess_bytes(['age', '-d', '-i', '/dev/stdin', str(cipher)], (values['AGE_PRIVATE_KEY'].strip() + '\n').encode()) require(restored == plain, 'decrypted_fixture_mismatch') for method in ['GET', 'DELETE']: code, _ = request(HOST + '/public.php/webdav' + fixture_path, method, auth=(token, '')) require(code in (401, 403, 404), 'upload_grant_exceeds_create_only') code, present = request(owner_root + fixture_path, auth=auth) require(code == 200 and present == encrypted, 'fixture_not_preserved_by_negative_check') receipt.update(encrypted_upload=True, owner_download=True, escrow_decryption=True, runtime_read_denied=True, runtime_delete_denied=True) # Only replace upload coordinates; preserve recovery escrow and other fields. updated = dict(values, NC_WEBDAV_TOKEN=token, NC_WEBDAV_URL=upload_root) result = data(bao(['write', '-format=json', LANE, '-'], payload={'options':{'cas':expected_version}, 'data':updated})) receipt['kv_version'] = result['data']['version'] receipt['escrow_preserved'] = True code, _ = request(owner_root + fixture_path, 'DELETE', auth=auth) require(code == 204, 'fixture_cleanup_failed') receipt['fixture_removed'] = True receipt['quota_after'] = quota(auth) receipt['status'] = 'account_cutover_verified_pending_consumer_refresh' def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--expected-version', type=int) p.add_argument('--receipt', required=True) p.add_argument('--confirm', required=True) args = p.parse_args() receipt = {'schema':'platform.nextcloud-backup-account-cutover.v1', 'status':'failed'} fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) try: require(args.confirm == 'MIGRATE TO Backup', 'confirmation_mismatch') run(args, receipt) except Exception as error: receipt['error'] = str(error) if isinstance(error, LaneError) else 'internal_error' finally: with os.fdopen(fd, 'w') as out: json.dump(receipt, out, indent=2) out.write('\n') return 0 if receipt['status'] != 'failed' else 1 if __name__ == '__main__': raise SystemExit(main())