railiance-platform/scripts/migrate_nextcloud_backup_account.py
codex 8fb4407832 Prepare dedicated Backup account cutover with quota and recovery verification
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-05 20:35:13 +02:00

176 lines
8.6 KiB
Python

#!/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'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/><d:quota-used-bytes/></d:prop></d:propfind>'
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,'publicUpload':'true','label':LABEL,'sendMail':'false'})
created = True
share_id = str(share['id'])
receipt['share_id'] = share_id
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())