#!/usr/bin/env python3 """First-run master setup and explicit SSH deployment for Arcade Escape.""" import argparse import configparser import hashlib import io import ipaddress import json import os from pathlib import Path import re import secrets import shlex import shutil import subprocess import sys import tarfile import time import urllib.request from receiver import FILES, private_write, release_id, render_config ROOT = Path(__file__).resolve().parents[2] DEFAULT_SITE = Path.home() / '.local/share/arcade-master' def catalog(): return json.loads((ROOT / 'tools/room_controller/catalog.json').read_text(encoding='utf-8')) def validate(site): if site.get('schema') != 1 or not site.get('cabinets'): raise ValueError('Expected schema 1 and at least one cabinet') tokens = [site['operator_token']] seen, targets = set(), set() for item in site['cabinets']: for key, pattern in [('id', r'[A-Za-z0-9_]+'), ('host', r'[A-Za-z0-9][A-Za-z0-9.-]*'), ('user', r'[A-Za-z_][A-Za-z0-9_-]*')]: if not isinstance(item.get(key), str) or not re.fullmatch(pattern, item[key]): raise ValueError('Invalid cabinet ' + key) if item['id'] not in catalog()['cabinets']: raise ValueError('Cabinet ID is not in the exported catalog: ' + item['id']) target = (item['host'].lower(), item.get('port', 22)) if item['id'] in seen or target in targets: raise ValueError('Duplicate cabinet ID or SSH target') seen.add(item['id']) targets.add(target) if type(target[1]) is not int or not 1 <= target[1] <= 65535: raise ValueError('Invalid SSH port') tokens.append(item['token']) render_config(dict(item, url=site['controller_url'])) if any(not isinstance(t, str) or not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', t) for t in tokens) or len(set(tokens)) != len(tokens): raise ValueError('Operator and cabinet tokens must be distinct, 16+ character URL-safe strings') from urllib.parse import urlsplit parsed = urlsplit(site['controller_url']) # This launcher hosts HTTP locally; a TLS proxy would require additional configuration. if parsed.scheme != 'http' or parsed.port != 8766: raise ValueError('Use http://MASTER_PRIVATE_IP:8766 for the cabinet controller URL') address = ipaddress.ip_address(parsed.hostname) if address.version != 4 or address.is_loopback or address.is_unspecified or address.is_multicast: raise ValueError('Use the master private-interface IPv4 address, not localhost') if any(item['host'] == str(address) for item in site['cabinets']): raise ValueError('A cabinet target cannot be the master controller IP') def load(folder): site = json.loads((folder / 'inventory.json').read_text(encoding='utf-8')) validate(site) return site def generate(folder, site): validate(site) key = folder / 'ssh/arcade_cabinets' ini = configparser.ConfigParser(interpolation=None) ini['controller'] = dict(bind='0.0.0.0', port='8766', operator_token=site['operator_token'], cabinet_tokens=json.dumps({c['id']: c['token'] for c in site['cabinets']}), catalog=str(ROOT / 'tools/room_controller/catalog.json'), database=str(folder / 'state/arcade_room.sqlite3')) ini['launcher'] = dict(logs=str(folder / 'logs')) ini['remote'] = dict(enabled='true', cabinets=json.dumps([ {k: c[k] for k in ('id', 'host', 'user', 'port')} | {'identity_file': str(key)} for c in site['cabinets']])) output = io.StringIO() ini.write(output) private_write(folder / 'controller.ini', output.getvalue()) for c in site['cabinets']: private_write(folder / 'cabinets' / (c['id'] + '.ini'), render_config(dict(c, url=site['controller_url']))) def prompt(label, default=''): value = input(label + (f' [{default}]' if default else '') + ': ').strip() return value or default def setup(folder): path = folder / 'inventory.json' if path.exists(): site = load(folder) print('Existing site loaded; credentials will be preserved.') else: site = dict(schema=1, operator_token=secrets.token_urlsafe(24), controller_url='', cabinets=[]) site['controller_url'] = prompt('Master PRIVATE URL', site['controller_url'] or 'http://192.168.77.1:8766') print('Available cabinet profiles: ' + ', '.join(catalog()['cabinets'])) existing = {c['id']: c for c in site['cabinets']} while True: cabinet = prompt('Cabinet ID to add/edit (Enter when done)') if not cabinet: break if cabinet not in catalog()['cabinets']: print('Choose an ID in the list. New IDs require an updated game export and catalog.') continue c = dict(existing.get(cabinet, dict(id=cabinet, token=secrets.token_urlsafe(24), port=22))) c['host'] = prompt('Cabinet IP or hostname', c.get('host', '')) c['user'] = prompt('Pi desktop / SSH username', c.get('user', '')) c['port'] = int(prompt('SSH port', str(c.get('port', 22)))) c['gpio'] = prompt('Enable real GPIO? yes/no', 'yes' if c.get('gpio') else 'no').lower() == 'yes' pins = prompt('Allowed BCM pins, comma-separated', ','.join(map(str, c.get('pins', [])))) c['pins'] = [int(p.strip()) for p in pins.split(',') if p.strip()] c['gpio_controller'] = prompt('GPIO controller ID from cabinet hardware profile', c.get('gpio_controller', cabinet + '_gpio')) existing[cabinet] = c site['cabinets'] = list(existing.values()) validate(site) private_write(path, json.dumps(site, indent=2)) generate(folder, site) print('Saved site inventory and generated configs at ' + str(folder)) print('Operator credential is in controller.ini. No cabinet contacted; no network settings changed.') def ssh_args(folder, c, batch=True): key = folder / 'ssh/arcade_cabinets' if not key.is_file(): raise ValueError('Run enroll first to generate and authorize the master key') return ['ssh', '-T', '-p', str(c.get('port', 22)), '-i', str(key), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'BatchMode=' + ('yes' if batch else 'no'), '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=3', c['user'] + '@' + c['host']] def enroll(folder, c): if not sys.platform.startswith('linux'): raise ValueError('Run enrollment on the master Pi (requires ssh-copy-id)') if not shutil.which('ssh-copy-id'): raise ValueError('Install openssh-client on the master') key = folder / 'ssh/arcade_cabinets' key.parent.mkdir(parents=True, exist_ok=True, mode=0o700) if not key.exists(): subprocess.run(['ssh-keygen', '-t', 'ed25519', '-N', '', '-f', str(key), '-C', 'arcade-master'], check=True) if not key.with_suffix('.pub').is_file(): raise ValueError('Private key exists but .pub is missing; refusing to replace the key') print('Verify the Pi host fingerprint when prompted. Enter its existing SSH password if requested.') subprocess.run(['ssh-copy-id', '-i', str(key) + '.pub', '-p', str(c.get('port', 22)), '-o', 'StrictHostKeyChecking=ask', c['user'] + '@' + c['host']], check=True) subprocess.run(ssh_args(folder, c) + ['true'], check=True, timeout=30) print(c['id'] + ': key authentication verified') def remote(folder, c, action, source=None, release=None): code = Path(__file__).with_name('receiver.py').read_text(encoding='utf-8') command = 'python3 -c ' + shlex.quote(code) + ' ' + shlex.quote(action) if release is not None: command += ' --release ' + shlex.quote(release_id(release)) subprocess.run(ssh_args(folder, c) + [command], stdin=source or subprocess.DEVNULL, check=True) def bundle(export, output, release): release_id(release) paths = {name: (export / name if name.startswith('ArcadeEscape.') else ROOT / name) for name in FILES} with paths['ArcadeEscape.arm64'].open('rb') as binary: header = binary.read(20) if header[:6] != b'\x7fELF\x02\x01' or header[18:20] != b'\xb7\x00': raise ValueError('Expected a little-endian ARM64 Linux game export') with paths['ArcadeEscape.pck'].open('rb') as pack: if pack.read(4) != b'GDPC': raise ValueError('Invalid Godot PCK') manifest = dict(release=release, files={name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()}) encoded = json.dumps(manifest).encode() # Exclusive creation prevents silently replacing a released bundle. with output.open('xb') as raw, tarfile.open(fileobj=raw, mode='w:gz') as archive: info = tarfile.TarInfo('manifest.json') info.size = len(encoded) info.mode = 0o600 archive.addfile(info, io.BytesIO(encoded)) for name, path in paths.items(): info = archive.gettarinfo(str(path), arcname=name) info.uid = info.gid = 0 info.uname = info.gname = '' info.mode = 0o700 if name.endswith('.arm64') else 0o600 with path.open('rb') as content: archive.addfile(info, content) print('Created release bundle: ' + str(output)) def master_package(output): # Explicit allowlist: never ship a developer's INI, credentials, database or keys. output.mkdir(parents=True, exist_ok=False) names = [name for name in FILES if not name.startswith('ArcadeEscape.')] names += ['tools/deploy/deploy.py', 'tools/deploy/receiver.py', 'tools/deploy/README.md'] for name in names: target = output / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(ROOT / name, target) print('Portable master software: ' + str(output)) def install_master(folder): if not sys.platform.startswith('linux') or os.getuid() == 0: raise ValueError('Install the master service as the normal master Pi user, without sudo') sys.path.insert(0, str(ROOT / 'tools/launch')) from pi_service import quoted, working_directory path = Path.home() / '.config/systemd/user/arcade-master.service' marker = '# Managed by Arcade Escape deploy.py' if path.exists() and not path.read_text(encoding='utf-8').startswith(marker): raise ValueError('Refusing to overwrite an unrelated master service') unit = f'''{marker} [Unit] Description=Arcade room master controller [Service] Type=exec WorkingDirectory={working_directory(ROOT)} ExecStart=:{quoted(sys.executable)} {quoted(ROOT / 'tools/launch/launch.py')} controller --config {quoted(folder / 'controller.ini')} UnsetEnvironment=ARCADE_ROOM_URL ARCADE_CABINET_TOKEN ARCADE_OPERATOR_TOKEN ARCADE_CABINET_TOKENS Restart=on-failure RestartSec=10 KillMode=mixed TimeoutStopSec=15 [Install] WantedBy=default.target ''' private_write(path, unit) subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True) result = subprocess.check_output(['systemctl', '--user', 'show', 'arcade-master.service', '--property=LoadState', '--value'], text=True) if result.strip() != 'loaded': raise ValueError('systemd rejected the master unit; inspect systemctl --user status arcade-master.service') subprocess.run(['systemctl', '--user', 'enable', 'arcade-master.service'], check=True) print('Master service installed/enabled, not started. For startup before login run:') print('sudo loginctl enable-linger ' + shlex.quote(os.environ.get('USER', 'YOUR_MASTER_USERNAME'))) print('Then: systemctl --user start arcade-master.service') def health(site): request = urllib.request.Request('http://127.0.0.1:8766/operator/state', data=b'{}', headers={'Authorization': 'Bearer ' + site['operator_token']}) with urllib.request.urlopen(request, timeout=5) as response: state = json.load(response) for c in site['cabinets']: result = state.get('cabinets', {}).get(c['id'], {}) print(c['id'] + ': ' + ('ONLINE' if result.get('online') else 'OFFLINE')) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--site', type=Path, default=DEFAULT_SITE) sub = parser.add_subparsers(dest='action', required=True) sub.add_parser('setup', help='Interactive inventory/configuration wizard; no remote changes') sub.add_parser('generate', help='Validate edited inventory and regenerate configs') sub.add_parser('install-master') sub.add_parser('health') master = sub.add_parser('master-package') master.add_argument('--output', type=Path, required=True) pack = sub.add_parser('bundle') pack.add_argument('--export', type=Path, required=True) pack.add_argument('--output', type=Path, required=True) pack.add_argument('--release', required=True) for action in ('enroll', 'preflight', 'stage', 'activate', 'start', 'stop', 'status', 'logs'): command = sub.add_parser(action) command.add_argument('cabinet', help='One registered cabinet ID; no implicit fleet-wide mutations') if action == 'stage': command.add_argument('--bundle', type=Path, required=True) if action == 'activate': command.add_argument('--release', required=True) args = parser.parse_args() folder = args.site.expanduser().resolve() try: if args.action == 'master-package': master_package(args.output.resolve()) return 0 if args.action == 'bundle': bundle(args.export.resolve(), args.output.resolve(), args.release) return 0 if args.action == 'setup': setup(folder) return 0 site = load(folder) if args.action == 'generate': generate(folder, site) elif args.action == 'install-master': install_master(folder) elif args.action == 'health': health(site) else: c = next((c for c in site['cabinets'] if c['id'] == args.cabinet), None) if c is None: raise ValueError('Cabinet not registered in this site') if args.action == 'enroll': enroll(folder, c) elif args.action == 'preflight': remote(folder, c, 'preflight') elif args.action == 'stage': with args.bundle.open('rb') as source: remote(folder, c, 'stage', source) elif args.action == 'activate': import tempfile with tempfile.TemporaryFile() as source: source.write(json.dumps(dict(c, url=site['controller_url'])).encode()) source.seek(0) remote(folder, c, 'activate', source, args.release) else: command = ['journalctl', '--user', '-u', 'arcade-cabinet.service', '-n', '60', '--no-pager'] if args.action == 'logs' else ['systemctl', '--user', args.action, 'arcade-cabinet.service', '--no-pager'] subprocess.run(ssh_args(folder, c) + command, check=True, timeout=45) return 0 except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError) as exc: # CalledProcessError's command can contain configuration input only on stdin, # but suppress the huge receiver source from diagnostics as well. if isinstance(exc, subprocess.CalledProcessError): print('Command failed (exit ' + str(exc.returncode) + '); see diagnostics above.', file=sys.stderr) else: print('Setup failed: ' + str(exc), file=sys.stderr) return 1 if __name__ == '__main__': sys.exit(main())