"""Configuration-only backup of labelled apps; no Docker socket exposed to the GUI. Imported containers never get privileged mode, devices, Docker sockets or host filesystem mounts. """ import base64,json,os,re,shutil,subprocess,socket,time from pathlib import Path,PurePosixPath MAX_FILE=1024*1024 APP='io.athena-deck.application' LABELS={'io.athena-deck.managed':'true','io.athena-deck.role':'application'} def command(args,timeout=60): r=subprocess.run(['/usr/bin/docker',*args],capture_output=True,text=True,timeout=timeout) if r.returncode:raise ValueError('Docker-Schritt fehlgeschlagen; Image-Zugang, Port und Docker prüfen.') return r.stdout.strip() def blob(path): if path.is_symlink() or not path.is_file() or path.stat().st_size>MAX_FILE:raise ValueError('Dienstkonfiguration nicht sicher lesbar oder größer als 1 MiB.') return base64.b64encode(path.read_bytes()).decode() def config_files(source,destination): p=Path(source) if destination.startswith('/run/secrets/'): return {'file':blob(p)} if p.exists() else {} if destination.endswith('/Data') and p.is_dir(): files={} for name in ('Settings.fds','Backends.fds'): if (p/name).is_file():files[name]=blob(p/name) return files return {} def app_uid(container,user): if not user:return 0 if user.split(':')[0].isdigit():return int(user.split(':')[0]) with __import__('tempfile').TemporaryDirectory() as d: p=Path(d)/'passwd';command(['cp',container+':/etc/passwd',str(p)]) row=next((x.split(':') for x in p.read_text().splitlines() if x.split(':')[0]==user.split(':')[0]),None) if not row:raise ValueError('Containerbenutzer nicht auflösbar.') return int(row[2]) def export(manager): policy=manager.state/'backup-policy.json' deck=json.loads(policy.read_text())['deck_state'] if policy.exists() else None result=[] for row in manager.inventory(): x=json.loads(command(['inspect',row['id']]))[0];c=x['Config'];h=x['HostConfig'];image=json.loads(command(['image','inspect',x['Image']]))[0] registry=next((d for d in image.get('RepoDigests',[]) if '/' in d.split('@')[0] and ('.' in d.split('/')[0] or ':' in d.split('/')[0])),None) if not registry and row['name']!='athena-swarm-ui':registry=next(iter(image.get('RepoDigests',[])),None) mounts=[] for i,m in enumerate(x['Mounts']): source=m['Source'];relative=None if deck: try:relative=str(Path(source).relative_to(deck)) except ValueError:pass mounts.append(dict(index=i,target=m['Destination'],read_only=not m['RW'],deck_path=relative,files=config_files(source,m['Destination']),was_file=Path(source).is_file())) result.append(dict(name=row['name'],image=c['Image'],registry=registry,application=c.get('Labels',{}).get(APP,''),labels={k:v for k,v in (c.get('Labels') or {}).items() if k.startswith('io.athena-deck.')},env=c.get('Env') or [],entrypoint=c.get('Entrypoint'),cmd=c.get('Cmd'),uid=app_uid(row['id'],c.get('User') or ''),user=c.get('User') or '',workdir=c.get('WorkingDir') or '',network=h['NetworkMode'],ports=h.get('PortBindings') or {},restart=h.get('RestartPolicy',{}).get('Name') or 'no',memory=h.get('Memory') or 0,nanocpus=h.get('NanoCpus') or 0,running=x['State']['Running'],mounts=mounts,build_recipe='swarm-ui' if row['name']=='athena-swarm-ui' else None,unsupported=bool(h.get('Privileged') or h.get('DeviceRequests') or h.get('Devices') or h.get('CapAdd')))) return {'services':result,'configured':bool(deck)} def validate(c): if not isinstance(c,dict) or not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}',c.get('name','')):raise ValueError('Ungültiger Containername.') if any(c.get('labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Nur ausdrücklich Deck zugeordnete Anwendungscontainer erlaubt.') if c.get('unsupported'):raise ValueError('GPU-/privilegierter Container benötigt manuelle Einrichtung.') if c.get('network') not in ('bridge','default','host'):raise ValueError('Benutzerdefiniertes Docker-Netzwerk benötigt manuelle Einrichtung.') if c.get('network')=='host' and c['name'] not in ('athena-swarm-ui','ltx-deskweb'):raise ValueError('Host-Netzwerk nur für bekannte Deck-Oberflächen erlaubt.') if c.get('build_recipe') not in (None,'swarm-ui') or c.get('build_recipe')=='swarm-ui' and (c['name']!='athena-swarm-ui' or c.get('image')!='athena-swarm-ui:de7b834'):raise ValueError('Unbekanntes Build-Rezept.') ref=c.get('registry') or c.get('image','') if not isinstance(ref,str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._/@:-]{0,300}',ref):raise ValueError('Ungültige Image-Referenz.') if c.get('restart') not in ('no','always','unless-stopped','on-failure'):raise ValueError('Ungültige Neustartregel.') for field in ('env','entrypoint','cmd'): v=c.get(field) if v is not None and (not isinstance(v,list) or len(v)>200 or any(not isinstance(t,str) or len(t)>16384 or '\x00' in t for t in v)):raise ValueError('Ungültige Containerparameter.') for field in ('user','workdir'): if not isinstance(c.get(field,''),str) or len(c.get(field,''))>512 or '\x00' in c.get(field,''):raise ValueError('Ungültige Containerparameter.') if type(c.get('uid',0)) is not int or not 0<=c.get('uid',0)<=4294967294:raise ValueError('Ungültige Anwendungs-UID.') if type(c.get('running')) is not bool or not isinstance(c.get('labels'),dict) or len(c['labels'])>30 or any(not isinstance(k,str) or not k.startswith('io.athena-deck.') or not isinstance(v,str) or len(v)>512 for k,v in c['labels'].items()):raise ValueError('Ungültige Container-Einstellungen.') for field in ('memory','nanocpus'): if type(c.get(field)) is not int or not 0<=c[field]<=1024**5:raise ValueError('Ungültige Ressourcenbegrenzung.') ports=c.get('ports') if not isinstance(ports,dict) or len(ports)>20:raise ValueError('Ungültige Ports.') for container,bindings in ports.items(): if not re.fullmatch(r'\d{1,5}/(?:tcp|udp)',container) or not 1<=int(container.split('/')[0])<=65535 or not isinstance(bindings,list):raise ValueError('Ungültige Ports.') for binding in bindings: if binding.get('HostIp') not in ('127.0.0.1','::1'):raise ValueError('Restore veröffentlicht Containerports nur auf Loopback.') if not str(binding.get('HostPort','')).isdigit() or not 1024<=int(binding['HostPort'])<=65535:raise ValueError('Ungültiger Host-Port.') mounts=c.get('mounts') if not isinstance(mounts,list) or len(mounts)>30:raise ValueError('Ungültige Volumes.') targets=set() for i,m in enumerate(mounts): target=m.get('target','');parts=PurePosixPath(target).parts if not target.startswith('/') or '..' in parts or ',' in target or ':' in target or target in targets or target.startswith(('/proc','/sys','/dev','/etc','/var/run','/run/athena')) or 'docker.sock' in target:raise ValueError('Unsicheres Volume-Ziel.') targets.add(target) if m.get('index')!=i or type(m.get('read_only')) is not bool or type(m.get('was_file')) is not bool:raise ValueError('Ungültige Volume-Einstellung.') rel=m.get('deck_path') media={'video/original-work/remote-inputs/deskweb':('/data/inputs',False),'video/original-work/outputs':('/data/outputs',True)} native_media=rel in media and c['name']=='ltx-deskweb' and (target,m['read_only'])==media[rel] and not m['was_file'] if rel is not None and not native_media and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Volume nicht freigegeben; nur bekannte Modell-/Tokenpfade und LTX-Medienordner erlaubt.') for name,data in m.get('files',{}).items(): if name not in ('file','Settings.fds','Backends.fds'):raise ValueError('Unbekannte Konfigurationsdatei.') if not isinstance(data,str) or len(base64.b64decode(data,validate=True))>MAX_FILE:raise ValueError('Ungültige Konfigurationsdatei.') return c def restore(manager,c): validate(c) policy=manager.state/'backup-policy.json' if not policy.is_file():raise ValueError('Deck-Zustandsverzeichnis muss im Systemhelfer registriert sein.') if policy.is_symlink() or policy.stat().st_uid!=0 or policy.stat().st_mode&0o022:raise ValueError('Systemhelfer-Registrierung nicht vertrauenswürdig.') deck=Path(json.loads(policy.read_text())['deck_state']).resolve();name=c['name'] existing=subprocess.run(['/usr/bin/docker','inspect',name],capture_output=True,text=True) if existing.returncode==0: x=json.loads(existing.stdout)[0] if any(x['Config'].get('Labels',{}).get(k)!=v for k,v in LABELS.items()):raise ValueError('Containername ist durch einen fremden Dienst belegt.') # Never replace or restart an existing service during a restore. if x['Config']['Image'] not in (c['image'],c.get('registry')):raise ValueError('Vorhandener Container verwendet ein anderes Image; manuell prüfen.') return {'state':'reused','message':'Vorhandener markierter Container unverändert übernommen.'} if c.get('build_recipe')=='swarm-ui': source=Path(__file__).parent/'swarm-ui' if not (source/'Dockerfile').is_file():raise ValueError('Gepinntes Swarm-Build-Rezept fehlt im Systemhelfer.') command(['build','-t',c['image'],str(source)],1800) else: if not c.get('registry'):raise ValueError('Lokales Image hat keine Registry-Quelle und kein bekanntes Build-Rezept.') command(['pull',c['registry']],1800) if c.get('build_recipe')=='swarm-ui': nodes=deck/'video/swarm-comfy-nodes';nodes.mkdir(parents=True,exist_ok=True) temporary=command(['create',c['image']]) try:command(['cp',temporary+':/swarm/src/BuiltinExtensions/ComfyUIBackend/ExtraNodes/.',str(nodes)]) finally:command(['rm',temporary]) for p in [nodes,*nodes.rglob('*')]:os.chown(p,65534,65534) work=deck/'video/comfy-work/models' for folder in ('diffusion_models','text_encoders','vae','latent_upscale_models','loras','Stable-Diffusion','Lora','VAE','Embeddings','controlnet','model_patches','clip','clip_vision','upscale_models','tensorrt','unet'): (work/folder).mkdir(parents=True,exist_ok=True);os.chown(work/folder,65534,65534) for meta in (deck/'models').glob('*/entry.json'): item=json.loads(meta.read_text());f=PurePosixPath(item['file']);source=meta.parent/('model'+f.suffix) if item.get('repo')=='Lightricks/LTX-2.5' and f.parts[0] in ('diffusion_models','text_encoders','vae','latent_upscale_models') and source.is_file(): link=work/f.parts[0]/f.name if not link.exists() and not link.is_symlink():link.symlink_to('/var/lib/deck/models/'+meta.parent.name+'/model'+f.suffix) base=manager.state/'services'/name if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.') base.mkdir(parents=True,exist_ok=True,mode=0o700) args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256'] if c['memory']:args+=['--memory',str(c['memory'])] if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)] for k,v in c['labels'].items(): if not isinstance(k,str) or not k.startswith('io.athena-deck.') or not isinstance(v,str) or len(v)>512:raise ValueError('Ungültige Labels.') args+=['--label',k+'='+v] for p,bindings in c['ports'].items(): for b in bindings:args+=['-p',b['HostIp']+':'+str(b['HostPort'])+':'+p] for value in c['env']:args+=['-e',value] if c['user']:args+=['--user',c['user']] if c['workdir']:args+=['--workdir',c['workdir']] for i,m in enumerate(c['mounts']): source=deck/m['deck_path'] if m['deck_path'] is not None else base/str(i) if source.is_symlink():raise ValueError('Unsicheres Volume-Verzeichnis.') if m['deck_path']: if m['deck_path']=='video/comfy-client-token' and not source.exists(): import secrets source.parent.mkdir(parents=True,exist_ok=True);source.write_text(secrets.token_urlsafe(48)+'\n');source.chmod(0o600);os.chown(source,65534,65534) elif m['deck_path']!='video/comfy-client-token': source.mkdir(parents=True,exist_ok=True) if m['deck_path'].startswith('video/original-work/'): is_input=m['deck_path'].endswith('/deskweb');os.chown(source,1000 if is_input else 65534,65534);source.chmod(0o2770 if is_input else 0o2750) elif m['was_file']: if 'file' not in m['files']:raise ValueError('Benötigte Volume-Datei fehlt im Backup.') source.write_bytes(base64.b64decode(m['files']['file']));source.chmod(0o600) else: source.mkdir(exist_ok=True) for filename,data in m['files'].items(): target=source/filename;target.write_bytes(base64.b64decode(data));target.chmod(0o600) # Application UIDs commonly used by these rootless interfaces. uid=c.get('uid',int(c['user'].split(':')[0]) if c['user'].split(':')[0].isdigit() else 0) if m['deck_path'] is None: os.chown(source,uid,uid) if source.is_dir(): for p in source.iterdir():os.chown(p,uid,uid) args+=['--mount',f'type=bind,src={source},dst={m["target"]}'+(',readonly' if m['read_only'] else '')] if c.get('entrypoint'): args+=['--entrypoint',c['entrypoint'][0]];entry_tail=c['entrypoint'][1:] else:entry_tail=[] args+=[c['image'] if c.get('build_recipe') else c['registry'],*entry_tail,*(c.get('cmd') or [])] command(args) if c['running']: command(['start',name]);time.sleep(1) if command(['inspect','--format','{{.State.Running}}',name])!='true':raise ValueError('Wiederhergestellter Container ist nicht gestartet geblieben.') return {'state':'complete','message':'Image bereit, Konfiguration und Container wiederhergestellt.'}