Add encrypted configuration backup and planned restore
This commit is contained in:
@@ -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.'}
|
||||
Reference in New Issue
Block a user