2026-09-05 22:18:05 +02:00
#!/usr/bin/env python3
""" Restore a fetched Forgejo archive on a disposable, internal Docker network. """
import argparse
2026-09-06 14:53:15 +02:00
from datetime import datetime , timezone
2026-09-05 22:18:05 +02:00
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 ( )
2026-09-06 09:25:26 +02:00
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
2026-09-05 22:18:05 +02:00
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 = [ ]
2026-09-06 01:16:43 +02:00
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 ' ) ,
2026-09-06 01:23:02 +02:00
( b ' duplicate key ' , ' duplicate_database_key ' ) ,
( b ' server closed the connection ' , ' database_connection_closed ' ) ,
( b ' connection to server ' , ' database_connection_failed ' ) ] :
2026-09-06 01:16:43 +02:00
if marker in r . stderr . lower ( ) :
receipt [ ' failure_kind ' ] = category
break
raise ValueError ( ' isolated_command_failed ' )
2026-09-05 22:18:05 +02:00
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
2026-09-06 09:25:26 +02:00
[ packages ]
ENABLED = { " false " if profile == " essentials " else " true " }
2026-09-05 22:18:05 +02:00
[ 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 ) )
2026-09-06 01:25:14 +02:00
# Initialization uses a temporary socket-only server; wait for final TCP.
# Then use the disposable container's trusted local socket for import.
2026-09-05 22:18:05 +02:00
for _ in range ( 30 ) :
2026-09-06 01:23:02 +02:00
if docker ( ' exec ' , db , ' pg_isready ' , ' -h ' , ' 127.0.0.1 ' , ' -U ' , ' forgejo ' , check = False ) . returncode == 0 : break
2026-09-05 22:18:05 +02:00
time . sleep ( 2 )
else : raise ValueError ( ' database_not_ready ' )
sql = ( staging / ' forgejo-db.sql ' ) . read_bytes ( )
2026-09-06 01:25:14 +02:00
docker ( ' exec ' , ' -i ' , db , ' psql ' , ' -U ' , ' forgejo ' , ' -d ' , ' forgejo ' , ' --single-transaction ' , ' -v ' , ' ON_ERROR_STOP=1 ' , payload = sql , timeout = 900 )
2026-09-05 22:18:05 +02:00
receipt [ ' database_import ' ] = True
2026-09-06 01:25:14 +02:00
counts = docker ( ' exec ' , db , ' psql ' , ' -U ' , ' forgejo ' , ' -d ' , ' forgejo ' , ' -Atqc ' ,
2026-09-05 22:18:05 +02:00
' 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.
2026-09-06 01:25:14 +02:00
rows = docker ( ' exec ' , db , ' psql ' , ' -U ' , ' forgejo ' , ' -d ' , ' forgejo ' , ' -Atqc ' ,
2026-09-05 22:18:05 +02:00
' 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 )
2026-09-06 09:25:26 +02:00
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
2026-09-05 22:18:05 +02:00
receipt [ ' stage ' ] = ' package_blob_recovery '
# Each stored blob must survive extraction and match its database digest.
2026-09-06 01:25:14 +02:00
rows = docker ( ' exec ' , db , ' psql ' , ' -U ' , ' forgejo ' , ' -d ' , ' forgejo ' , ' -Atqc ' , ' SELECT hash_sha256 FROM package_blob; ' ) . stdout . decode ( ) . splitlines ( )
2026-09-05 22:18:05 +02:00
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 )
2026-09-06 09:25:26 +02:00
p . add_argument ( ' --profile ' , choices = [ ' full ' , ' essentials ' ] , default = ' full ' )
2026-09-05 22:21:54 +02:00
p . add_argument ( ' --transfer-receipt ' , required = True , type = Path )
2026-09-06 14:53:15 +02:00
a = p . parse_args ( ) ; receipt = { ' schema ' : ' platform.forgejo-isolated-restore.v1 ' , ' status ' : ' failed ' , ' started_at ' : datetime . now ( timezone . utc ) . isoformat ( ) , ' images ' : [ FORGE , POSTGRES ] }
2026-09-05 22:18:05 +02:00
fd = os . open ( a . receipt , os . O_WRONLY | os . O_CREAT | os . O_EXCL , 0o600 )
2026-09-05 22:21:54 +02:00
try :
2026-09-06 14:53:15 +02:00
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 '
2026-09-06 09:25:26 +02:00
and transfer . get ( ' download_http_status ' ) == 200 )
2026-09-06 14:53:15 +02:00
primary = ( transfer . get ( ' schema ' ) in ( ' platform.forgejo-primary-archive.v1 ' , ' platform.forgejo-primary-decryption.v1 ' )
and transfer . get ( ' status ' ) == ' primary_fetched_pending_application_restore '
2026-09-06 09:25:26 +02:00
and transfer . get ( ' download_hash_matches ' ) is True and transfer . get ( ' decrypted ' ) is True )
if ( not ( nextcloud or primary )
2026-09-05 22:21:54 +02:00
or transfer . get ( ' plaintext_sha256 ' ) != file_digest ( a . archive ) ) :
raise ValueError ( ' verified_offsite_provenance_required ' )
2026-09-06 09:25:26 +02:00
receipt [ ' source_provider ' ] = ' Scaleway ' if primary else ' Nextcloud '
receipt [ ' offsite_artifact ' ] = transfer [ ' destination ' ] if primary else transfer [ ' artifact ' ]
2026-09-05 22:21:54 +02:00
receipt [ ' ciphertext_sha256 ' ] = transfer [ ' ciphertext_sha256 ' ]
2026-09-06 09:25:26 +02:00
if transfer . get ( ' archive_profile ' , ' full ' ) != a . profile : raise ValueError ( ' profile_provenance_mismatch ' )
run ( a . archive , receipt , a . profile )
2026-09-06 14:53:15 +02:00
if receipt . get ( ' cleanup ' ) is not True :
raise ValueError ( ' restore_cleanup_incomplete ' )
2026-09-06 09:25:26 +02:00
except Exception :
receipt [ ' status ' ] = ' failed '
receipt [ ' error ' ] = ' isolated_restore_failed '
2026-09-05 22:18:05 +02:00
finally :
2026-09-06 14:53:15 +02:00
receipt [ ' finished_at ' ] = datetime . now ( timezone . utc ) . isoformat ( )
2026-09-05 22:18:05 +02:00
with os . fdopen ( fd , ' w ' ) as f : json . dump ( receipt , f , indent = 2 )
2026-09-06 09:25:26 +02:00
return int ( receipt [ ' status ' ] not in ( ' restored ' , ' restored_essentials ' ) or not receipt . get ( ' cleanup ' ) )
2026-09-05 22:18:05 +02:00
if __name__ == ' __main__ ' : raise SystemExit ( main ( ) )