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
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 ) :
validate_archive ( archive )
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 ) :
r = subprocess . run ( args , input = payload , capture_output = True , env = env , timeout = 180 )
if check and r . returncode : 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
[ 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 ) )
for _ in range ( 30 ) :
if docker ( ' exec ' , db , ' pg_isready ' , ' -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 ' , ' -v ' , ' ON_ERROR_STOP=1 ' , payload = sql )
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 )
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 )
2026-09-05 22:21:54 +02:00
p . add_argument ( ' --transfer-receipt ' , required = True , type = Path )
2026-09-05 22:18:05 +02:00
a = p . parse_args ( ) ; receipt = { ' schema ' : ' platform.forgejo-isolated-restore.v1 ' , ' status ' : ' failed ' , ' images ' : [ FORGE , POSTGRES ] }
fd = os . open ( a . receipt , os . O_WRONLY | os . O_CREAT | os . O_EXCL , 0o600 )
2026-09-05 22:21:54 +02:00
try :
transfer = json . loads ( a . transfer_receipt . read_text ( ) )
if ( transfer . get ( ' status ' ) != ' offsite_fetched_pending_isolated_restore '
or transfer . get ( ' download_http_status ' ) != 200
or transfer . get ( ' plaintext_sha256 ' ) != file_digest ( a . archive ) ) :
raise ValueError ( ' verified_offsite_provenance_required ' )
receipt [ ' offsite_artifact ' ] = transfer [ ' artifact ' ]
receipt [ ' ciphertext_sha256 ' ] = transfer [ ' ciphertext_sha256 ' ]
run ( a . archive , receipt )
2026-09-05 22:18:05 +02:00
except Exception : receipt [ ' error ' ] = ' isolated_restore_failed '
finally :
with os . fdopen ( fd , ' w ' ) as f : json . dump ( receipt , f , indent = 2 )
return int ( receipt [ ' status ' ] != ' restored ' or not receipt . get ( ' cleanup ' ) )
if __name__ == ' __main__ ' : raise SystemExit ( main ( ) )