#!/usr/bin/env python3 """Standalone Debian installer. Explicit allowlist; never manages production services.""" import argparse import hashlib import json import os from pathlib import Path import shutil import socket import subprocess import sys import time import urllib.request ROOT = Path(__file__).resolve().parent.parent LABEL = 'de.casaderoll.athena-deck.standalone' FILES = ['catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile'] def run(*args, check=True, interactive=False): result = subprocess.run(args, text=True, capture_output=not interactive) if check and result.returncode: raise RuntimeError('Befehl fehlgeschlagen: '+args[0]+'. Vorhandene Dienste bleiben unangetastet.') return result def inspect(name): result = run('docker','inspect',name,check=False) return json.loads(result.stdout)[0] if result.returncode == 0 else None def owned(name, base): value = inspect(name) if value and value['Config'].get('Labels',{}).get(LABEL) != str(base): raise RuntimeError('Containername ist bereits anderweitig belegt. Keine Änderung ausgeführt.') return value def preflight(base, port): if sys.platform != 'linux' or os.geteuid() != 0: raise RuntimeError('Auf dem Debian-Zielserver mit sudo ausführen.') release = dict(line.split('=',1) for line in Path('/etc/os-release').read_text().splitlines() if '=' in line) if release.get('ID','').strip('"') != 'debian' or release.get('VERSION_ID','').strip('"') not in ('12','13'): raise RuntimeError('Unterstützt werden Debian 12 und 13.') if not shutil.which('docker'): raise RuntimeError('Docker fehlt. Zuerst nach der offiziellen Debian-Anleitung installieren: https://docs.docker.com/engine/install/debian/ . Der Installer verändert keine Host-Pakete.') version = run('docker','version','--format','{{.Server.Version}}').stdout.strip() if int(version.split('.')[0]) < 28: raise RuntimeError('Docker Engine >= 28 erforderlich. Kein automatisches Upgrade bestehender Docker-Installationen.') if not 1024 <= port <= 65535: raise RuntimeError('Port muss zwischen 1024 und 65535 liegen.') if not base.is_absolute() or base in (Path('/'),Path('/opt'),Path('/srv'),Path('/var/lib')) or base != base.resolve(): raise RuntimeError('Eigenes absolutes Installationsverzeichnis ohne Symlinks erforderlich.') for name in FILES + ['deploy/provision.py']: if not (ROOT/name).is_file(): raise RuntimeError('Unvollständiger Checkout: '+name) def free_port(port): with socket.socket() as sock: try: sock.bind(('127.0.0.1',port)) except OSError: raise RuntimeError('Der gewünschte Loopback-Port ist bereits belegt.') from None def build(): digest = hashlib.sha256() for name in FILES: digest.update(name.encode());digest.update((ROOT/name).read_bytes()) tag = 'athena-deck-standalone:'+digest.hexdigest()[:16] print('Eigenes Deck-Image bauen (keine Modelle oder llama.cpp-Builds).',flush=True) result = run('docker','build','--label',LABEL+'=image','-t',tag,'-f',str(ROOT/'deploy/Dockerfile'),str(ROOT),interactive=True) return tag def write_manifest(base, config): temp=base/'installation.tmp' fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600) with os.fdopen(fd,'w') as stream: json.dump(config,stream,indent=2) os.replace(temp,base/'installation.json') def load_manifest(base): config=json.loads((base/'installation.json').read_text()) if config.get('owner') != LABEL or config.get('base') != str(base): raise RuntimeError('Kein gültiger eigener Installationsstand.') return config def launch(config): base=Path(config['base']) args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base), '--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true', '--pids-limit','128','--memory','512m','--cpus','1', '--tmpfs','/tmp:rw,nosuid,nodev,size=16m', '-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']), '-p','127.0.0.1:'+str(config['port'])+':8108', '--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"', '--health-interval','30s','--health-timeout','5s','--health-retries','3'] if config.get('development_setup'): args[args.index('--health-cmd')+1] = 'python3 -c "import urllib.request; urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3)"' # Explicit isolated development bootstrap, reachable only through host loopback/SSH. args += ['-e','DECK_REQUIRE_SETUP=0'] if config['gpu_telemetry']: args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=utility'] args.append(config['image']) run(*args) def ready(config): # Verify the new container itself as well as the published port. for _ in range(30): item=owned(config['name'],Path(config['base'])) if item and item['State']['Running']: try: with urllib.request.urlopen('http://127.0.0.1:'+str(config['port'])+'/api/v1/auth/status',timeout=2) as r: if json.load(r).get('initialized') is True or config.get('development_setup'): return except (OSError, ValueError): pass time.sleep(1) raise RuntimeError('Neue Deck-Instanz wurde nicht bereit.') def provision(config): base=Path(config['base']) if (base/'state/auth.json').exists(): raise RuntimeError('Zugangsdaten existieren bereits; keine Überschreibung.') os.chown(base/'bootstrap',65534,65534) run('docker','run','--rm','-it','--network','none','--user','65534:65534','--cap-drop','ALL', '--security-opt','no-new-privileges:true','--read-only','--tmpfs','/tmp:rw,nosuid,nodev,size=8m', '-v',str(base/'state')+':/var/lib/deck:rw','-v',str(base/'bootstrap')+':/bootstrap:rw', '-v',str(ROOT/'deploy/provision.py')+':/provision.py:ro','-e','PYTHONPATH=/app',config['image'], 'python3','/provision.py',interactive=True) os.chown(base/'state/auth.json',65534,65534) def install(args): base=args.directory preflight(base,args.port) if owned(args.name,base): raise RuntimeError('Deck-Container existiert bereits. Für Updates --update verwenden.') if base.exists(): raise RuntimeError('Installationsverzeichnis existiert bereits. Es wird nicht überschrieben.') free_port(args.port) parent=base.parent while not parent.exists():parent=parent.parent if shutil.disk_usage(parent).free < 2*1024**3: raise RuntimeError('Mindestens 2 GiB freier Speicher für Installation erforderlich.') if not sys.stdin.isatty(): raise RuntimeError('Interaktives Terminal für Zugangseinrichtung benötigt (über SSH: ssh -t).') image=build() base.mkdir(parents=True,mode=0o700) for sub in ('state','models','backups','bootstrap'): (base/sub).mkdir(mode=0o700) os.chown(base/'state',65534,65534) config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry) write_manifest(base,config) provision(config) try: launch(config);ready(config) except Exception: item=owned(config['name'],base) if item:run('docker','rm','-f',config['name']) raise RuntimeError('Deck-Start fehlgeschlagen. Nur der neue Deck-Container wurde entfernt; Zugangsdaten und Installationsstand bleiben für --start erhalten.') from None print('Deck ist bereit: http://127.0.0.1:'+str(args.port)) if (base/'bootstrap/api-token.txt').exists(): print('Generierter API-Token: geschützte Datei '+str(base/'bootstrap/api-token.txt')+'. In Passwortmanager übernehmen und Datei danach entfernen.') def update(base, rollback=False): config=load_manifest(base) previous=owned(config['name'],base) if not previous: raise RuntimeError('Kein installierter Deck-Container vorhanden.') image=config.get('previous_image') if rollback else build() if not image:raise RuntimeError('Kein vorheriger Build gespeichert.') if image==config['image']: print('Dieser Quellstand ist bereits installiert.');return backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') stamp=str(time.time_ns()) shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models')) was_running=previous['State']['Running'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) new=dict(config,image=image,previous_image=config['image']) try: launch(new);ready(new);write_manifest(base,new) except Exception: item=owned(config['name'],base) if item:run('docker','rm','-f',config['name']) run('docker','rename',backup_name,config['name']) if was_running:run('docker','start',config['name']) raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None run('docker','rm',backup_name) print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.') def main(): parser=argparse.ArgumentParser(description='Athena Deck auf Debian 12/13 getrennt installieren. Kein WireGuard, keine Host-Paket- oder Treiberänderungen.') actions=parser.add_mutually_exclusive_group(required=True) for action in ('check','install','setup','start','stop','status','update','rollback'): actions.add_argument('--'+action,action='store_true') parser.add_argument('--directory',type=Path,default=Path('/opt/athena-deck-standalone')) parser.add_argument('--name',default='athena-deck-standalone') parser.add_argument('--port',type=int,default=8110) parser.add_argument('--gpu-telemetry',action='store_true',help='Vorhandenes NVIDIA Container Toolkit nur für Messwerte nutzen; installiert keine Treiber.') args=parser.parse_args() import re if not re.fullmatch(r'athena-deck-[a-z0-9-]{1,40}',args.name): parser.error('Name muss mit athena-deck- beginnen und nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.') preflight(args.directory,args.port) if args.check: item=owned(args.name,args.directory) if not item:free_port(args.port) print('Vorprüfung erfolgreich. Keine Dateien, Images oder Dienste geändert.') elif args.install: install(args) else: config=load_manifest(args.directory) item=owned(config['name'],args.directory) if args.status: print('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent')) print('URL: http://127.0.0.1:'+str(config['port'])) elif args.setup: provision(config) print('Zugang eingerichtet. Jetzt --start ausführen.') elif args.stop: if item:run('docker','stop','--time','15',config['name']) elif args.start: if not (args.directory/'state/auth.json').exists() and not config.get('development_setup'): raise RuntimeError('Zugangseinrichtung unvollständig. Siehe INSTALL.md zur Wiederaufnahme.') if (args.directory/'state/auth.json').exists(): os.chown(args.directory/'state/auth.json',65534,65534) if item:run('docker','start',config['name']) else:launch(config) ready(config);print('Deck bereit.') elif args.update or args.rollback:update(args.directory,args.rollback) if __name__=='__main__': try:main() except (RuntimeError,OSError,ValueError,KeyError) as exc: print('Installation/Betrieb abgebrochen: '+str(exc),file=sys.stderr) sys.exit(1)