#!/usr/bin/env python3 """Restore a fetched Forgejo archive on a disposable, internal Docker network.""" import argparse from datetime import datetime, timezone import configparser import hashlib import json import os from pathlib import Path, PurePosixPath import secrets import shutil import stat import subprocess import time import zipfile from capture_forgejo_archive import validate_archive FORGE='code.forgejo.org/forgejo/forgejo@sha256:e2684fd8707d486329084a695ed91999a4072a798e5409d45c1eb8a2911ff4b9' POSTGRES='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70' def file_digest(path): with path.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest() def run(archive, receipt, profile="full"): validate_archive(archive,profile) if profile == "essentials": with zipfile.ZipFile(archive) as bundle: manifest=json.loads(bundle.read("backup-manifest.json")) if manifest.get("profile")!="essentials" or manifest.get("package_registry_available") is not False: raise ValueError("essentials_contract_required") receipt["archive_profile"]=profile prefix='wp0029-'+secrets.token_hex(5) network=prefix+'-net'; db=prefix+'-db'; app=prefix+'-app' staging=archive.parent/(prefix+'-staging') staging.mkdir(mode=0o700) resources=[] def command(args, payload=None, env=None, check=True, timeout=180): try: r=subprocess.run(args,input=payload,capture_output=True,env=env,timeout=timeout) except subprocess.TimeoutExpired: receipt['failure_kind']='command_timeout' raise ValueError('isolated_command_timeout') from None if check and r.returncode: receipt['failure_kind']='command_nonzero' receipt['command_exit_code']=r.returncode # Classify only fixed diagnostics; never publish captured output. for marker, category in [(b'out of memory', 'memory_limit'), (b'syntax error', 'sql_syntax'), (b'does not exist', 'missing_database_object'), (b'duplicate key', 'duplicate_database_key'), (b'server closed the connection', 'database_connection_closed'), (b'connection to server', 'database_connection_failed')]: if marker in r.stderr.lower(): receipt['failure_kind']=category break raise ValueError('isolated_command_failed') return r def docker(*args, **kw): return command(['docker',*args],**kw) try: receipt['stage']='archive_validation' with zipfile.ZipFile(archive) as z: if sum(i.file_size for i in z.infolist()) > 20*1024**3: raise ValueError('archive_too_large') for info in z.infolist(): p=PurePosixPath(info.filename) if p.is_absolute() or '..' in p.parts or stat.S_ISLNK(info.external_attr>>16): raise ValueError('unsafe_archive_member') z.extractall(staging) # Build an independent configuration; never reuse production app.ini. password=secrets.token_urlsafe(32) config=f'''APP_NAME = Isolated WP-0029 recovery RUN_USER = git RUN_MODE = prod WORK_PATH = /data/gitea [database] DB_TYPE = postgres HOST = {db}:5432 NAME = forgejo USER = forgejo PASSWD = {password} SSL_MODE = disable [repository] ROOT = /data/git/repositories [server] APP_DATA_PATH = /data/gitea DOMAIN = localhost ROOT_URL = http://localhost:3000/ HTTP_PORT = 3000 DISABLE_SSH = true LFS_START_SERVER = false OFFLINE_MODE = true [security] INSTALL_LOCK = true SECRET_KEY = {secrets.token_urlsafe(32)} [service] DISABLE_REGISTRATION = true REQUIRE_SIGNIN_VIEW = false [mailer] ENABLED = false [packages] ENABLED = {"false" if profile == "essentials" else "true"} [actions] ENABLED = false [webhook] ALLOWED_HOST_LIST = loopback [cron] ENABLED = false [log] MODE = console LEVEL = Error ''' ini=staging/'isolated.ini'; ini.write_text(config);ini.chmod(0o600) receipt['stage']='isolated_database' docker('network','create','--internal',network);resources.append(('network',network)) env=os.environ.copy();env['POSTGRES_PASSWORD']=password docker('run','-d','--name',db,'--network',network,'--log-driver','none','--memory','1g', '-e','POSTGRES_PASSWORD','-e','POSTGRES_USER=forgejo','-e','POSTGRES_DB=forgejo',POSTGRES,env=env) resources.append(('container',db)) # Initialization uses a temporary socket-only server; wait for final TCP. # Then use the disposable container's trusted local socket for import. for _ in range(30): if docker('exec',db,'pg_isready','-h','127.0.0.1','-U','forgejo',check=False).returncode==0: break time.sleep(2) else: raise ValueError('database_not_ready') sql=(staging/'forgejo-db.sql').read_bytes() docker('exec','-i',db,'psql','-U','forgejo','-d','forgejo','--single-transaction','-v','ON_ERROR_STOP=1',payload=sql,timeout=900) receipt['database_import']=True counts=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc', 'SELECT (SELECT count(*) FROM repository),(SELECT count(*) FROM "user"),(SELECT count(*) FROM package_blob);').stdout.decode().strip() numbers=[int(n) for n in counts.split('|')] if min(numbers[:2]) <= 0: raise ValueError('empty_database') receipt['database_counts']=dict(zip(['repositories','users','package_blobs'],numbers)) receipt['stage']='isolated_application' docker('run','-d','--name',app,'--network',network,'--log-driver','none','--memory','1g', '--entrypoint','sleep',FORGE,'7200');resources.append(('container',app)) docker('exec',app,'mkdir','-p','/data/gitea','/data/git/repositories') docker('cp',str(staging/'data')+'/.',app+':/data/gitea/') docker('cp',str(staging/'repos')+'/.',app+':/data/git/repositories/') docker('cp',str(ini),app+':/data/isolated.ini') docker('exec',app,'chown','-R','1000:1000','/data') docker('exec','-d','--user','1000:1000',app,'forgejo','--config','/data/isolated.ini','web') for _ in range(60): r=docker('exec',app,'wget','-qO-','http://127.0.0.1:3000/api/healthz',check=False) if r.returncode==0: break time.sleep(2) else: raise ValueError('application_not_ready') receipt['application_health']=True receipt['stage']='repository_recovery' # Select restored public repositories only, keeping payloads out of evidence. rows=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc', 'SELECT u.lower_name || \'/\' || r.lower_name FROM repository r JOIN "user" u ON u.id=r.owner_id WHERE NOT r.is_private AND NOT r.is_empty LIMIT 2;').stdout.decode().splitlines() if not rows: raise ValueError('public_repository_fixture_missing') receipt['repositories_verified']=[] for index,repo in enumerate(rows): if any(c not in 'abcdefghijklmnopqrstuvwxyz0123456789-_./' for c in repo): raise ValueError('unsafe_repository_name') response=docker('exec',app,'wget','-qO-','http://127.0.0.1:3000/api/v1/repos/'+repo).stdout if json.loads(response)['full_name'].lower()!=repo: raise ValueError('repository_metadata_mismatch') target='/tmp/restore-clone-'+str(index) docker('exec','--user','1000:1000',app,'git','clone','--quiet','http://127.0.0.1:3000/'+repo+'.git',target) docker('exec','--user','1000:1000',app,'git','-C',target,'fsck','--full') receipt['repositories_verified'].append(repo) if profile == 'essentials': if any(p.is_file() for p in (staging/'data'/'packages').rglob('*')): raise ValueError('unexpected_package_payload') receipt.update(status='restored_essentials',package_registry_available=False, primary_storage_accessed=False) return receipt['stage']='package_blob_recovery' # Each stored blob must survive extraction and match its database digest. rows=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc','SELECT hash_sha256 FROM package_blob;').stdout.decode().splitlines() files=list((staging/'data'/'packages').rglob('*')) if (staging/'data'/'packages').exists() else [] available={p.name:p for p in files if p.is_file()} verified=0 for digest in rows: matches=[p for name,p in available.items() if digest in name] if len(matches)!=1 or file_digest(matches[0])!=digest: raise ValueError('package_blob_missing_or_corrupt') verified+=1 receipt['package_blobs_verified']=verified receipt['status']='restored' finally: receipt['cleanup']=True for kind,name in reversed(resources): args=('rm','-f','-v',name) if kind=='container' else ('network','rm',name) if docker(*args,check=False).returncode: receipt['cleanup']=False shutil.rmtree(staging) def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True) p.add_argument('--profile',choices=['full','essentials'],default='full') p.add_argument('--transfer-receipt',required=True,type=Path) a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'images':[FORGE,POSTGRES]} fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) try: raw=a.transfer_receipt.read_bytes() transfer=json.loads(raw) receipt['transfer_receipt_sha256']=hashlib.sha256(raw).hexdigest() nextcloud = (transfer.get('schema') == 'platform.real-offsite-recovery.v1' and transfer.get('decrypted') is True and transfer.get('upload_http_status') == 201 and transfer.get('status') == 'offsite_fetched_pending_isolated_restore' and transfer.get('download_http_status') == 200) primary = (transfer.get('schema') in ('platform.forgejo-primary-archive.v1', 'platform.forgejo-primary-decryption.v1') and transfer.get('status') == 'primary_fetched_pending_application_restore' and transfer.get('download_hash_matches') is True and transfer.get('decrypted') is True) if (not (nextcloud or primary) or transfer.get('plaintext_sha256') != file_digest(a.archive)): raise ValueError('verified_offsite_provenance_required') receipt['source_provider']='Scaleway' if primary else 'Nextcloud' receipt['offsite_artifact']=transfer['destination'] if primary else transfer['artifact'] receipt['ciphertext_sha256']=transfer['ciphertext_sha256'] if transfer.get('archive_profile','full')!=a.profile: raise ValueError('profile_provenance_mismatch') run(a.archive,receipt,a.profile) if receipt.get('cleanup') is not True: raise ValueError('restore_cleanup_incomplete') except Exception: receipt['status']='failed' receipt['error']='isolated_restore_failed' 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'] not in ('restored','restored_essentials') or not receipt.get('cleanup')) if __name__=='__main__': raise SystemExit(main())