Add encrypted configuration backup and planned restore

This commit is contained in:
Mikei386
2026-09-30 14:20:21 +02:00
parent 18adaf4a52
commit e7a1deaab0
22 changed files with 844 additions and 28 deletions
+4 -2
View File
@@ -11,11 +11,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt accelerate==1.15.0 \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+4 -2
View File
@@ -1,11 +1,13 @@
ARG DECK_RUNTIME_IMAGE
FROM ${DECK_RUNTIME_IMAGE}
USER root
RUN apt-get update && apt-get install -y --no-install-recommends python3-cryptography && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY backup.py backup_codec.py audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py video_comfy.py video_comfy_node.py video_comfy_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY backup-ui.js video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css themes.css login.html login.js access-ui.js network-ui.js /app/
COPY deploy/docker_backup.py /app/deploy/docker_backup.py
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+176
View File
@@ -0,0 +1,176 @@
"""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')
if rel is not None and (rel not in ('models','video/comfy-work/models','video/comfy-client-token') or not m['read_only']):raise ValueError('Deck-Dateien dürfen nur als bekannte, lesende Modell-/Token-Volumes eingebunden werden.')
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)
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.'}
+8 -2
View File
@@ -143,6 +143,12 @@ class Manager:
return self.video_status(config)
def dispatch(self,data):
if not isinstance(data,dict):raise ValueError('Ungültige Helferanfrage.')
if data.get('action')=='backup-export' and set(data)=={'action'}:
import docker_backup
return docker_backup.export(self)
if data.get('action')=='backup-restore' and set(data)=={'action','container'}:
import docker_backup
return docker_backup.restore(self,data['container'])
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.')
@@ -197,8 +203,8 @@ class Handler(socketserver.StreamRequestHandler):
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ß.')
raw=self.rfile.readline(67108865)
if len(raw)>67108864:raise ValueError('Anfrage zu groß.')
data=json.loads(raw)
if isinstance(data,dict) and data.get('action')=='video-http':
self.connection.settimeout(3600);video_http(self.server.manager,data,self.rfile,self.wfile);return
+3 -3
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock']
FILES = ['backup.py','backup_codec.py','backup-ui.js','deploy/docker_backup.py','deploy/swarm-ui/Dockerfile','deploy/swarm-ui/patch-source.py','deploy/swarm-ui/proxy.py','deploy/swarm-ui/entrypoint.py','deploy/Dockerfile.app-update','video_comfy.py','video_comfy_node.py','video_comfy_proxy.py','prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','themes.css','deploy/import_dashboard_history.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','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False):
@@ -175,7 +175,7 @@ def install(args):
(base/sub).mkdir(mode=0o700)
os.chown(base/'state',65534,65534)
config=dict(owner=LABEL,base=str(base),name=args.name,port=args.port,api_ports=api_ports,image=image,previous_image=None,gpu_telemetry=args.gpu_telemetry,image_runtime=True,docker_helper=True)
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534')
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534','--deck-state',str(base/'state'))
write_manifest(base,config)
provision(config)
try:
@@ -255,7 +255,7 @@ def main():
config=load_manifest(args.directory)
item=owned(config['name'],args.directory)
if args.docker_helper:
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534')
run(sys.executable,str(ROOT/'deploy/setup_docker_helper.py'),'--client-uid','65534','--client-gid','65534','--deck-state',str(args.directory/'state'))
config['docker_helper']=True;write_manifest(args.directory,config);update(args.directory,force=True)
elif args.status:
print('Deck: '+('running' if item and item['State']['Running'] else 'stopped / absent'))
+13 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Provision Deck's fixed Unix-socket helper; does not install Docker itself."""
import argparse
import json
import os
from pathlib import Path
import shutil
@@ -9,7 +10,7 @@ import subprocess
MARKER='# Athena Deck Docker helper — managed by setup_docker_helper.py'
def main():
p=argparse.ArgumentParser();p.add_argument('--client-uid',type=int,required=True);p.add_argument('--client-gid',type=int,required=True);p.add_argument('--allow-install',action='store_true',help='Erlaubt Erstinstallation über die authentifizierte Deck-GUI auf einem unbenutzten Debian-Host.');args=p.parse_args()
p=argparse.ArgumentParser();p.add_argument('--client-uid',type=int,required=True);p.add_argument('--client-gid',type=int,required=True);p.add_argument('--allow-install',action='store_true',help='Erlaubt Erstinstallation über die authentifizierte Deck-GUI auf einem unbenutzten Debian-Host.');p.add_argument('--deck-state');args=p.parse_args()
if os.geteuid()!=0 or not Path('/run/systemd/system').is_dir():raise SystemExit('Als root auf dem Debian-Zielserver mit systemd ausführen.')
if args.client_uid<1 or args.client_gid<1:raise SystemExit('Eigene unprivilegierte Deck-UID und GID erforderlich.')
base=Path('/opt/athena-deck-docker-helper');unit=Path('/etc/systemd/system/athena-deck-docker-helper.service')
@@ -20,6 +21,15 @@ def main():
target=base/'docker_helper.py'
if target.is_symlink():raise SystemExit('Symlink-Ziel nicht zulässig.')
shutil.copyfile(Path(__file__).with_name('docker_helper.py'),target);os.chown(target,0,0);target.chmod(0o644);(base/'managed-by-deck').touch()
shutil.copyfile(Path(__file__).with_name('docker_backup.py'),base/'docker_backup.py')
(base/'swarm-ui').mkdir(exist_ok=True)
for name in ('Dockerfile','patch-source.py','proxy.py','entrypoint.py'):
shutil.copyfile(Path(__file__).parent/'swarm-ui'/name,base/'swarm-ui'/name)
if args.deck_state:
registered=Path(args.deck_state).resolve()
if not registered.is_dir():raise SystemExit('Deck-Zustandsverzeichnis fehlt.')
state=Path('/var/lib/athena-deck-docker');state.mkdir(exist_ok=True)
config=state/'backup-policy.json';config.write_text(json.dumps({'deck_state':str(registered)}));config.chmod(0o600)
command=f'/usr/bin/python3 {target} --client-uid {args.client_uid} --client-gid {args.client_gid}'+(' --allow-install' if args.allow_install else '')
unit.write_text(MARKER+f'''
[Unit]
@@ -38,6 +48,7 @@ NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ProtectSystem={'false' if args.allow_install else 'strict'}
ReadWritePaths=-{args.deck_state or '/var/lib/athena-deck'}
[Install]
WantedBy=multi-user.target
''')
@@ -45,5 +56,5 @@ WantedBy=multi-user.target
subprocess.run(['/usr/bin/systemctl','daemon-reload'],check=True)
subprocess.run(['/usr/bin/systemctl','enable','athena-deck-docker-helper.service'],check=True,capture_output=True)
subprocess.run(['/usr/bin/systemctl','restart','athena-deck-docker-helper.service'],check=True)
print('Deck-Docker-Helfer eingerichtet. Installationsrecht: '+('Erstinstallation erlaubt' if args.allow_install else 'nur lesender Bestand'))
print('Deck-Docker-Helfer eingerichtet. Installationsrecht: '+('Erstinstallation erlaubt' if args.allow_install else 'Bestand und begrenzter Backup-Restore; keine Paketinstallation'))
if __name__=='__main__':main()