#!/usr/bin/env python3 """Root-owned local helper. Only inventory and fresh Docker install; no generic RPC.""" import argparse import re import urllib.request import urllib.error import json import os from pathlib import Path import shutil import socket import socketserver import struct import subprocess import threading import time MANAGED_LABEL='io.athena-deck.managed=true' ROLE_LABEL='io.athena-deck.role=application' PACKAGES=['docker-ce','docker-ce-cli','containerd.io','docker-buildx-plugin','docker-compose-plugin'] CONFLICTS=['docker.io','docker-compose','docker-doc','docker-buildx','podman-docker','containerd','runc'] ENV={'PATH':'/usr/sbin:/usr/bin:/sbin:/bin','HOME':'/var/lib/athena-deck-docker','LANG':'C.UTF-8','DEBIAN_FRONTEND':'noninteractive','NEEDRESTART_MODE':'l'} def run(args,timeout=15): return subprocess.run(args,capture_output=True,text=True,timeout=timeout,env=ENV) def release(): return dict(line.split('=',1) for line in Path('/etc/os-release').read_text().replace('"','').splitlines() if '=' in line) class Manager: def __init__(self,state,allow_install=False): self.state=Path(state);self.allow_install=allow_install;self.lock=threading.RLock();self.job=None if (self.state/'job.json').exists(): self.job=json.loads((self.state/'job.json').read_text()) if self.job['state']=='running':self.job.update(state='interrupted',phase='Installation unterbrochen. Hostzustand prüfen; vorhandene Pakete werden nicht automatisch ersetzt.') def inventory(self): # Filter at the daemon before retrieving rows. No inspect, env, mounts or logs. result=run(['/usr/bin/docker','ps','-a','--no-trunc','--filter','label='+MANAGED_LABEL,'--filter','label='+ROLE_LABEL,'--format','{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}']) if result.returncode:raise ValueError('Docker-Daemon nicht erreichbar.') rows=[] for line in result.stdout.splitlines()[:500]: fields=line.split('\t') if len(fields)==5:rows.append(dict(zip(('id','name','image','state','status'),fields))) return rows def preflight(self): info=release() if info.get('ID')!='debian' or info.get('VERSION_ID') not in ('12','13'):return False,'Unterstützt werden Debian 12 und 13.' if Path('/usr/bin/docker').exists() or Path('/var/run/docker.sock').exists():return False,'Docker ist vorhanden. Keine automatische Neuinstallation oder Aktualisierung.' if not self.allow_install:return False,'Docker-Erstinstallation ist für diesen Helfer nicht freigegeben.' if any(Path(p).exists() for p in ('/var/lib/docker','/var/lib/containerd','/etc/docker')):return False,'Vorhandene Containerdaten oder Konfiguration erkannt. Manuelle Prüfung erforderlich.' for package in CONFLICTS+PACKAGES: value=run(['/usr/bin/dpkg-query','-W','-f=${Status}',package]) if value.returncode==0 and 'install ok installed' in value.stdout:return False,'Vorhandene Containerpakete erkannt. Keine automatische Entfernung oder Ersetzung.' if shutil.disk_usage('/var').free<3*1024**3:return False,'Mindestens 3 GiB freier Speicher erforderlich.' return True,'Docker Engine, Compose und Buildx aus dem offiziellen Docker-Repository installieren. Docker richtet einen Systemdienst sowie eigene Netzwerk-/Firewallregeln ein. Keine NVIDIA-Treiberinstallation und kein Host-Neustart.' def status(self): installed=Path('/usr/bin/docker').exists();reachable=False;version=None;services=[] if installed: try: version_result=run(['/usr/bin/docker','version','--format','{{.Server.Version}}']) if version_result.returncode==0:version=version_result.stdout.strip();services=self.inventory();reachable=True except (OSError,ValueError,subprocess.SubprocessError):pass supported,message=self.preflight() with self.lock:job=dict(self.job) if self.job else None return dict(installed=installed,reachable=reachable,version=version,services=services,install_supported=supported,message=message,job=job,labels=[MANAGED_LABEL,ROLE_LABEL]) def _phase(self,text,state='running'): with self.lock: self.job.update(state=state,phase=text);self.state.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.state/'job.tmp';temp.write_text(json.dumps(self.job));temp.replace(self.state/'job.json') def install(self): with self.lock: if self.job and self.job['state']=='running':raise ValueError('Docker-Installation läuft bereits.') allowed,message=self.preflight() if not allowed:raise ValueError(message) self.job=dict(state='running',phase='Docker-Erstinstallation wird vorbereitet',started_at=time.time()) self._phase(self.job['phase']);threading.Thread(target=self._install,daemon=True).start() return {'started':True} def _checked(self,args): result=run(args,timeout=1200) if result.returncode:raise ValueError('Installationsschritt fehlgeschlagen. Paketverwaltung und Internetverbindung auf dem Host prüfen.') def _install(self): try: info=release();codename={'12':'bookworm','13':'trixie'}[info['VERSION_ID']] arch=run(['/usr/bin/dpkg','--print-architecture']).stdout.strip() if arch not in ('amd64','arm64'):raise ValueError('Diese Architektur wird vom Deck-Installer noch nicht unterstützt.') self._phase('Paketquellen prüfen und Download-Werkzeuge installieren') self._checked(['/usr/bin/apt-get','update']) self._checked(['/usr/bin/apt-get','install','-y','--no-remove','ca-certificates','curl']) key=Path('/etc/apt/keyrings/athena-deck-docker.asc');source=Path('/etc/apt/sources.list.d/athena-deck-docker.sources') key.parent.mkdir(mode=0o755,exist_ok=True) self._checked(['/usr/bin/curl','--fail','--silent','--show-error','--proto','=https','--tlsv1.2','--max-time','60','https://download.docker.com/linux/debian/gpg','-o',str(key)]) key.chmod(0o644) source.write_text(f'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: {codename}\nComponents: stable\nArchitectures: {arch}\nSigned-By: {key}\n');source.chmod(0o644) self._phase('Docker Engine, Compose und Buildx installieren') self._checked(['/usr/bin/apt-get','update']) self._checked(['/usr/bin/apt-get','install','-y','--no-remove',*PACKAGES]) self._phase('Docker-Systemdienst starten und Erreichbarkeit prüfen') self._checked(['/usr/bin/systemctl','enable','--now','docker.service']) self._checked(['/usr/bin/docker','version','--format','{{.Server.Version}}']) self._checked(['/usr/bin/docker','compose','version','--short']) self._phase('Docker bereit. Keine Anwendungen oder Modelle installiert.','complete') except Exception as exc:self._phase(str(exc) if isinstance(exc,ValueError) else 'Docker-Installation fehlgeschlagen. Hostzustand vor erneutem Versuch prüfen.','failed') def video_configs(self): path=self.state/'video-services.json' if not path.exists():return [] if path.is_symlink() or path.stat().st_uid!=0 or path.stat().st_mode&0o022:raise ValueError('Videodienste müssen durch root eingerichtet werden.') configs=json.loads(path.read_text()) if not isinstance(configs,list) or len(configs)>20:raise ValueError('Ungültige Dienstliste.') ids=set() for config in configs: if not re.fullmatch('[a-z0-9-]{1,40}',config.get('id','')) or config['id'] in ids or not re.fullmatch('[a-f0-9]{64}',config.get('container_id','')):raise ValueError('Ungültige Dienstzuordnung.') ids.add(config['id']) return configs def video_status(self,config): result=run(['/usr/bin/docker','inspect','--format','{{.State.Running}}',config['container_id']]) if result.returncode:return dict(id=config['id'],name=config['name'],running=None,ready=False,api_url=config['api_url'],message='Zugeordneter Container fehlt.') running=result.stdout.strip()=='true';ready=False if running: class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self,*args,**kwargs):return None try: headers={} if config.get('token_file'):headers['Authorization']='Bearer '+Path(config['token_file']).read_text().strip() request=urllib.request.Request(config['health_url'],headers=headers) with urllib.request.build_opener(NoRedirect).open(request,timeout=2) as response:ready=response.status==200 except (OSError,ValueError,urllib.error.URLError):pass return dict(id=config['id'],name=config['name'],running=running,ready=ready,api_url=config['api_url'],message=config.get('message','Original-API des Dienstes.')) def video_inventory(self):return {'services':[self.video_status(config) for config in self.video_configs()]} def video_action(self,ident,start): with self.lock: configs=self.video_configs();config=next((c for c in configs if c['id']==ident),None) if config is None:raise ValueError('Videodienst nicht freigegeben.') status=self.video_status(config) if start and status['running']:return status if start: if any(self.video_status(c)['running'] is not False for c in configs if c['id']!=ident):raise ValueError('Ein anderer Videodienst ist noch aktiv oder nicht prüfbar.') probe=run(['/usr/bin/nvidia-smi','--query-compute-apps=pid','--format=csv,noheader,nounits']) if probe.returncode or probe.stdout.strip():raise ValueError('GPU durch einen anderen Dienst belegt. Deck beendet keine fremden Modelle. Alten Router zuerst freigeben.') result=run(['/usr/bin/docker','start',config['container_id']] if start else ['/usr/bin/docker','stop','--time','30',config['container_id']],timeout=45) if result.returncode:raise ValueError('Videodienst konnte nicht gestartet oder beendet werden.') return self.video_status(config) def dispatch(self,data): if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.') if set(data)=={'action','service'} and data['action'] in ('video-start','video-stop'): return self.video_action(data['service'],data['action']=='video-start') if set(data)!={'action'}:raise ValueError('Ungültige Helferanfrage.') if data['action']=='video-status':return self.video_inventory() if data['action']=='status':return self.status() if data['action']=='install':return self.install() raise ValueError('Aktion nicht erlaubt.') class Handler(socketserver.StreamRequestHandler): def handle(self): self.connection.settimeout(20) try: _,uid,_=struct.unpack('3i',self.connection.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12)) if uid not in (0,self.server.client_uid):raise ValueError('Client nicht erlaubt.') raw=self.rfile.readline(4097) if len(raw)>4096:raise ValueError('Anfrage zu groß.') result=self.server.manager.dispatch(json.loads(raw)) except Exception as exc:result={'error':str(exc) if isinstance(exc,ValueError) else 'Docker-Systemhelfer nicht verfügbar.'} self.wfile.write(json.dumps(result).encode()+b'\n') class Server(socketserver.ThreadingUnixStreamServer): daemon_threads=True def main(): parser=argparse.ArgumentParser();parser.add_argument('--client-uid',type=int,required=True);parser.add_argument('--client-gid',type=int,required=True);parser.add_argument('--allow-install',action='store_true');parser.add_argument('--state',default='/var/lib/athena-deck-docker');args=parser.parse_args() socket_path=Path('/run/athena-deck-docker/control.sock');socket_path.parent.mkdir(mode=0o755,exist_ok=True);socket_path.unlink(missing_ok=True) with Server(str(socket_path),Handler) as server: os.chown(socket_path,0,args.client_gid);socket_path.chmod(0o660);server.client_uid=args.client_uid;server.manager=Manager(args.state,args.allow_install);server.serve_forever() if __name__=='__main__':main()