Files
EscapistsReintroduced/tools/dedicated_native.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

198 lines
11 KiB
Python

#!/usr/bin/env python3
"""Bounded Linux server diagnostic: isolated network, frozen sources, no clients."""
import argparse
import fcntl
import hashlib
import json
import os
import re
import stat
from pathlib import Path
import shutil
import signal
import subprocess
import time
PROBE = '''import json,os,socket,sys
state={"netns":os.readlink("/proc/self/ns/net"),"interfaces":socket.if_nameindex(),"routes":open("/proc/net/route").read(),"ipv6Routes":open("/proc/net/ipv6_route").read()}
print("ERR ISOLATION "+json.dumps(state),flush=True)
assert state["netns"] != sys.argv[1], "network namespace was not isolated"
assert [name for _,name in state["interfaces"]] == ["lo"], "non-loopback interface present"
assert len(state["routes"].splitlines()) == 1, "IPv4 route present"
assert all(line.split()[-1] == "lo" for line in state["ipv6Routes"].splitlines()), "non-loopback IPv6 route present"
if len(sys.argv)>2: os.execv(sys.argv[2],sys.argv[2:])
'''
def manifest(source):
result = {}
for path in sorted(source.rglob('*')):
if path.is_symlink():
raise ValueError('Linked source entry is not allowed: ' + str(path))
if path.is_file():
result[str(path.relative_to(source))] = hashlib.sha256(path.read_bytes()).hexdigest()
return result
def sandbox(runtime, source, run):
return ['bwrap', '--unshare-net', '--unshare-pid', '--unshare-ipc', '--unshare-uts',
'--die-with-parent', '--cap-drop', 'ALL', '--clearenv',
'--ro-bind', '/', '/', '--tmpfs', '/home', '--tmpfs', '/tmp', '--tmpfs', '/run',
'--proc', '/proc', '--dev', '/dev', '--ro-bind', str(runtime), '/tmp/runtime',
'--bind', str(source), '/tmp/source', '--bind', str(run), '/tmp/work',
'--setenv', 'HOME', '/tmp/work/home', '--setenv', 'TMPDIR', '/tmp/work/tmp',
'--setenv', 'XDG_CONFIG_HOME', '/tmp/work/home/.config',
'--setenv', 'XDG_DATA_HOME', '/tmp/work/home/.local/share',
'--setenv', 'XDG_CACHE_HOME', '/tmp/work/home/.cache',
'--setenv', 'PATH', '/usr/bin:/bin', '--setenv', 'LANG', 'C.UTF-8',
'--chdir', '/tmp/runtime']
PROJECTS = {
'core': ('2528466DE220D91C', '{AD62322D5213C440}Missions/ERR_Everon_Base.conf'),
'chat': ('2528466DE220D91C', '{C1E20667DCE324ED}Missions/ERR_Everon.conf'),
}
ADDON_GUIDS = {'core': PROJECTS['core'][0]}
WORLD = '{B6C516F86302C679}Worlds/ERR_Everon.ent'
SYSTEMS = '{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf'
def engine_args(route, project='core'):
if route not in ('header', 'diag-world', 'vanilla-everon') or project not in PROJECTS:
raise ValueError('Unsupported diagnostic route or project')
if route == 'vanilla-everon' and project != 'core':
raise ValueError('vanilla-everon cannot be combined with --project chat')
args = ['/tmp/runtime/ArmaReforgerServer' + ('Diag' if route == 'diag-world' else ''),
'-profile', '/tmp/work/profile', '-noBackend', '-noSound', '-noSplash', '-noThrow',
'-bindIP', '127.0.0.1', '-bindPort', '2001', '-maxFPS', '30', '-logStats', '5000']
if route == 'vanilla-everon':
return args + ['-server', 'Missions/21_GM_Eden.conf']
addon, header = PROJECTS[project]
args += ['-addonsDir', '/tmp/source/addons', '-addons', addon]
if route == 'header':
return args + ['-server', header]
return args + ['-server', WORLD, '-worldSystemsConfig', SYSTEMS]
def preflight_addons(source, route, project='core'):
"""Presence/identity checks only; native loading must validate RDB contents."""
engine_args(route, project) # also reject incompatible direct helper calls
if route == 'vanilla-everon':
return
addons = source / 'addons'
if addons.is_symlink() or not addons.is_dir():
raise ValueError('Expected a regular addons directory in diagnostic source')
closure = ('core',) if project == 'core' else tuple(ADDON_GUIDS)
for addon in closure:
folder = addons / addon
if folder.is_symlink() or not folder.is_dir():
raise ValueError('Missing or linked required addon directory: ' + addon)
for name in ('addon.gproj', 'resourceDatabase.rdb'):
path = folder / name
try:
info = path.lstat()
except FileNotFoundError as error:
raise ValueError('Missing required addon input: ' + str(path)) from error
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0:
raise ValueError('Required addon input must be a nonempty regular file: ' + str(path))
project_file = folder / 'addon.gproj'
if project_file.stat().st_size > 65536:
raise ValueError('Unsupported oversized addon project: ' + str(project_file))
try:
definition = project_file.read_text(encoding='utf-8')
except UnicodeDecodeError as error:
raise ValueError('Addon project is not UTF-8: ' + str(project_file)) from error
if re.findall(r'\bGUID\s+"([A-Fa-f0-9]{16})"', definition) != [ADDON_GUIDS[addon]]:
raise ValueError('Required addon identity mismatch: ' + str(project_file))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, required=True)
parser.add_argument('--runtime', type=Path, default=Path('/tmp/erri-dedicated-runtime/server'))
parser.add_argument('--archive', type=Path, required=True)
parser.add_argument('--route', choices=['header', 'diag-world', 'vanilla-everon'], default='header')
parser.add_argument('--project', choices=['core', 'chat'], default='core',
help='Diagnostic scenario header; both select the combined addon')
parser.add_argument('--seconds', type=int, default=90)
args = parser.parse_args()
if args.route == 'vanilla-everon' and args.project != 'core':
parser.error('vanilla-everon cannot be combined with --project chat')
if not 1 <= args.seconds <= 110:
parser.error('Runtime bound must be 1..110 seconds (plus at most 10 seconds owned cleanup)')
source, runtime, archive = args.source.resolve(), args.runtime.resolve(), args.archive.resolve()
if not source.is_relative_to('/tmp') or not runtime.is_relative_to('/tmp') or not archive.is_relative_to('/tmp'):
parser.error('Sources, runtime and archive must be isolated /tmp paths')
if archive == source or source in archive.parents or archive in source.parents or archive == runtime or runtime in archive.parents or archive in runtime.parents:
parser.error('Source, archive and runtime must not overlap')
if archive.exists() or archive.is_symlink():
parser.error('Archive must be new')
try:
preflight_addons(source, args.route, args.project)
except (ValueError, OSError) as error:
parser.error(str(error))
origin = runtime.parent / 'runtime-origin.json'
if not origin.is_file() or json.loads(origin.read_text()).get('app_id') != 1874900:
parser.error('Stable private runtime origin missing')
binary = runtime / Path(engine_args(args.route, args.project)[0]).name
with binary.open('rb') as stream:
elf = stream.read(20)
if binary.is_symlink() or len(elf) != 20 or elf[:6] != b'\x7fELF\x02\x01' or int.from_bytes(elf[18:20], 'little') != 62:
parser.error('Expected real native x86-64 ELF server')
archive.mkdir(mode=0o700, parents=True)
(archive / 'runner.py').write_bytes(Path(__file__).read_bytes())
for child in ['home', 'profile', 'tmp']:
(archive / child).mkdir()
# Reject links before copytree follows them into the isolated source snapshot.
manifest(source)
frozen = archive / 'source'
shutil.copytree(source, frozen, ignore=shutil.ignore_patterns('.git', '__pycache__', 'evidence', 'generated'))
preflight_addons(frozen, args.route, args.project)
initial = manifest(frozen)
(archive / 'source-manifest.json').write_text(json.dumps(initial, indent=2)+'\n')
base = sandbox(runtime, frozen, archive)
netns = os.readlink('/proc/self/ns/net')
check = base + ['/usr/bin/python3', '-c', PROBE, netns]
preflight = subprocess.run(check, capture_output=True, text=True, timeout=15)
(archive / 'isolation-preflight.json').write_text(json.dumps({'command':check,'exit':preflight.returncode,'stdout':preflight.stdout,'stderr':preflight.stderr}, indent=2)+'\n')
if preflight.returncode:
print('Isolation refused; no server launched:', archive)
return 2
command = base + ['/usr/bin/python3', '-c', PROBE, netns] + engine_args(args.route, args.project)
record = {'command': command, 'engine': engine_args(args.route, args.project), 'seconds':args.seconds,
'route': args.route, 'project': None if args.route == 'vanilla-everon' else args.project,
'binarySHA256':hashlib.sha256(binary.read_bytes()).hexdigest(),
'serverLaunched':False,'realPlayers':0,'multiplayerProven':False,
'scope':'vanilla Everon control; no successor loaded' if args.route == 'vanilla-everon' else f'successor {args.project} empty-server startup'}
(archive / 'command.json').write_text(json.dumps(record, indent=2)+'\n')
lock_fd = os.open('/tmp/erri-dedicated-native-diagnostic.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
with os.fdopen(lock_fd, 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with (archive / 'stdout.log').open('wb') as log:
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
record['serverLaunched'] = True
record['supervisorPid'] = process.pid
record['startedUTC'] = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
try:
record['exit'] = process.wait(timeout=args.seconds)
record['timeout'] = False
except subprocess.TimeoutExpired:
record['timeout'] = True
finally:
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
try: process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
record['exit'] = process.returncode
record['finishedUTC'] = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime())
(archive / 'result.json').write_text(json.dumps(record, indent=2)+'\n')
(archive / 'source-manifest-after.json').write_text(json.dumps(manifest(frozen), indent=2)+'\n')
print('Diagnostic archived; inspect actual compilation/header/systems/errors:',archive)
return 0
if __name__ == '__main__':
raise SystemExit(main())