Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
176 lines
8.6 KiB
Python
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())
|