"""Encrypted configuration export and sequential, inspectable recovery jobs. No chats, prompts, media, logs, model weights or container layers are read into an export. """ import base64,copy,hashlib,json,os,re,secrets,shutil,sqlite3,tempfile,threading,time,zlib from pathlib import Path,PurePosixPath from auth import atomic_write,validate_record from backup_codec import seal,open_backup from catalog import repo_id,file_role,KINDS from profiles import SCHEMAS,CHAT_GPU_DEFAULTS,chat_parameters SETTINGS=('profiles.json','endpoint.json','audio-policy.json','video/comfy-selection.json','models/huggingface.json','video/comfy-client-token','video/original-work/settings.json') MAX_HISTORY=40*1024**2 VERSION=1 # Exact byte hash verified against official Hub metadata, not a filename heuristic. LOCAL_SOURCES={'83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53':dict(repo='unsloth/Qwen3.8-27B-GGUF',revision='4ca720788d1e01f1bff70c033e0d0028fd02e502',file='mmproj-BF16.gguf')} def validate(doc): if not isinstance(doc,dict) or doc.get('format')!='athena-deck' or doc.get('version')!=VERSION:raise ValueError('Backup-Version nicht unterstützt.') if set(doc)-{'format','version','created_at','settings','models','credentials','theme','network','services','runtimes','warnings','history','runtime_versions'}:raise ValueError('Unbekannte Backup-Felder.') if not isinstance(doc.get('warnings',[]),list) or len(doc.get('warnings',[]))>100 or any(not isinstance(w,str) or len(w)>1024 for w in doc.get('warnings',[])):raise ValueError('Ungültige Backup-Hinweise.') if 'history' in doc and (not isinstance(doc['history'],str) or len(doc['history'])>56*1024**2):raise ValueError('History im Backup zu groß.') models=doc.get('models');profiles=doc.get('settings',{}).get('profiles.json',[]) if not isinstance(models,list) or len(models)>4096 or not isinstance(profiles,list) or len(profiles)>1000:raise ValueError('Ungültiges Modell-/Profilverzeichnis.') ids=set() for m in models: repo_id(m.get('repo'));f=m.get('file','');parts=PurePosixPath(f).parts if not isinstance(f,str) or not parts or f.startswith('/') or '..' in parts or '\\' in f or len(f)>1024 or any(ord(c)<32 for c in f):raise ValueError('Unsicherer Modell-Dateiname.') local=m.get('source')=='local' if not re.fullmatch('[a-f0-9]{64}' if local else '[a-f0-9]{40}',m.get('revision','')) or m.get('kind') not in KINDS or type(m.get('size')) is not int or not 0params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.') if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.') components=p.get('components',{}) if not isinstance(components,dict) or any(k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler') or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.') pids.add(p['id']);names.add(name) settings=doc.get('settings') if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.') ep=settings.get('endpoint.json',{}) if not isinstance(ep,dict) or set(ep)-{'port','enabled_profiles','autostart'} or type(ep.get('port',8120)) is not int or not 1024<=ep.get('port',8120)<=65535 or type(ep.get('autostart',False)) is not bool:raise ValueError('Ungültige Endpunkt-Einstellungen.') enabled=ep.get('enabled_profiles',[]) if not isinstance(enabled,list) or any(p not in pids for p in enabled) or len(set(enabled))!=len(enabled):raise ValueError('Unbekannte Profilfreigabe.') if sum(p['kind']=='image' and p['id'] in enabled for p in profiles)>1:raise ValueError('Nur ein Bildprofil darf freigegeben sein.') policy=settings.get('audio-policy.json',{}) if not isinstance(policy,dict) or set(policy)-{'tts','stt'} or any(not isinstance(v,dict) or set(v)!={'mode','profile_id'} or v['mode'] not in ('auto','warm','per_request') or v['profile_id'] is not None and v['profile_id'] not in pids for v in policy.values()):raise ValueError('Ungültige Audio-Einstellung.') selection=settings.get('video/comfy-selection.json',{}) if isinstance(selection,dict) and selection.get('runtime','comfy') not in ('comfy','original'):raise ValueError('Unbekannte Video-Laufzeit.') if not isinstance(selection,dict) or set(selection)-{'model_id','runtime'} or selection.get('model_id') and selection['model_id'] not in ids:raise ValueError('Ungültige Video-Auswahl.') if 'models/huggingface.json' in settings: hf=settings['models/huggingface.json'] if not isinstance(hf,dict) or set(hf)!={'token'} or hf['token'] is not None and (not isinstance(hf['token'],str) or not re.fullmatch(r'hf_[A-Za-z0-9]{10,252}',hf['token'])):raise ValueError('Ungültiger Hugging-Face-Zugang.') if 'video/comfy-client-token' in settings and not re.fullmatch(r'[A-Za-z0-9_-]{32,256}',settings['video/comfy-client-token'].strip()):raise ValueError('Ungültiger Diensttoken.') if 'video/original-work/settings.json' in settings and (not isinstance(settings['video/original-work/settings.json'],dict) or len(json.dumps(settings['video/original-work/settings.json']))>65536):raise ValueError('Ungültige LTX-Einstellungen.') validate_record(doc.get('credentials')) runtimes=doc.get('runtimes') if not isinstance(runtimes,dict) or set(runtimes)-{'llama','llama_builds','image','tts','enhancers','swarm_nodes','ltx_original','audio_cpp'} or not {'llama','llama_builds','image','tts','enhancers','swarm_nodes'}<=set(runtimes):raise ValueError('Ungültiges Laufzeitrezept.') if 'audio_cpp' in runtimes and type(runtimes['audio_cpp']) is not bool:raise ValueError('Ungültige audio.cpp-Laufzeit.') if 'ltx_original' in runtimes and type(runtimes['ltx_original']) is not bool:raise ValueError('Ungültige LTX-Laufzeit.') if not isinstance(runtimes['llama_builds'],list) or len(runtimes['llama_builds'])>50 or any(not isinstance(b,dict) or set(b)!={'commit','backend'} or not re.fullmatch('[a-f0-9]{40}',b.get('commit','')) or b.get('backend') not in ('CUDA','CPU') for b in runtimes['llama_builds']):raise ValueError('Ungültige Buildrezepte.') if runtimes['llama'] is not None: item=runtimes['llama'] if not isinstance(item,dict) or not re.fullmatch('[a-f0-9]{40}',item.get('commit','')) or item.get('backend') not in ('CUDA','CPU'):raise ValueError('Ungültige llama.cpp-Quelle.') if any(type(runtimes[k]) is not bool for k in ('image','tts','swarm_nodes')) or not isinstance(runtimes['enhancers'],dict) or set(runtimes['enhancers'])-{'t2i','i2i'}:raise ValueError('Ungültiges Laufzeitrezept.') for revision in runtimes['enhancers'].values(): if not isinstance(revision,str) or not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Ungültige Aufwerterversion.') network=doc.get('network') if network is not None: if not isinstance(network,dict) or set(network)!={'config','enabled','mode'} or type(network['enabled']) is not bool or network['mode'] not in ('lan','both','tunnel'):raise ValueError('Ungültiger Netzwerkstand.') if network['config'] is not None: from network.config import parse_config parse_config(network['config']) services=doc.get('services') if not isinstance(services,list) or len(services)>50:raise ValueError('Ungültiger Containerbestand.') # Validation is shared with the root helper, which independently revalidates before Docker calls. from deploy.docker_backup import validate as validate_container seen=set() for c in services: validate_container(c) if c['name'] in seen:raise ValueError('Doppelte Containerdefinition.') seen.add(c['name']) if doc.get('theme','deck') not in ('deck','athena-classic'):raise ValueError('Unbekanntes Theme.') return doc class Backup: def __init__(self,server): self.server=server;self.root=server.catalog.root.parent;self.directory=self.root/'recovery';self.directory.mkdir(parents=True,exist_ok=True,mode=0o700) self.lock=threading.RLock();self.cancel=threading.Event();self.plan=None;self.job=None path=self.directory/'job.json' if path.exists(): self.job=json.loads(path.read_text()) if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Restore durch Neustart unterbrochen. Backup erneut importieren; vorhandene Dateien werden geprüft und wiederverwendet.');self._save() def busy(self):return bool(self.job and self.job['state']=='running') def _save(self):atomic_write(self.directory/'job.json',self.job) def status(self): with self.lock:return {'job':copy.deepcopy(self.job),'preview':copy.deepcopy(self.plan['summary']) if self.plan else None,'checkpoints':[p.name for p in sorted(self.directory.glob('before-*.adbackup'),reverse=True)]} def snapshot(self,theme='deck',include_history=True,_during_restore=False): s=self.server if self.busy() and not _during_restore:raise ValueError('Restore läuft. Export erst danach.') settings={} # Explicit allowlist: no recursive backup of process work directories. with s.profiles.lock,s.endpoint.lock,s.catalog.lock: for name in SETTINGS: p=self.root/name if p.is_file():settings[name]=p.read_text().strip() if name.endswith('comfy-client-token') else json.loads(p.read_text()) models=[] for m in s.catalog.status()['entries']: item={k:m[k] for k in ('id','repo','revision','file','size','sha256','kind')} if m['repo'].startswith('local/'): item['source']='local';item['download_source']=m.get('download_source') or LOCAL_SOURCES.get(m['sha256']) models.append(item) state=s.runtime.status();active=next((b for b in state['builds'] if b['id']==state['active']),None) llama={'commit':active['commit'],'backend':active['backend']} if active else None warnings=[];services=[] try:services=s.docker.call('backup-export')['services'] except (OSError,ValueError): if s.docker.status().get('services'):raise ValueError('Container-Konfigurationen können ohne aktualisierten Systemhelfer nicht gesichert werden.') warnings.append('Docker-Systemhelfer nicht verfügbar; keine Zusatzdienste gesichert.') network=None if not s.network.disabled: network=s.network.call('backup-export') else:warnings.append('Deck-Netzwerkmodul deaktiviert; fremder WireGuard-Gateway wird nicht gesichert.') document=dict(format='athena-deck',version=VERSION,created_at=time.time(),settings=settings,models=models,credentials=s.credentials.read(),theme=theme,network=network,services=services,runtime_versions=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']),runtimes=dict(audio_cpp=bool(getattr(s,'audio_cpp_runtime',None) and s.audio_cpp_runtime.status()['installed']),ltx_original=bool(getattr(s,'ltx_original_runtime',None) and s.ltx_original_runtime.status()['installed']),llama=llama,llama_builds=[dict(commit=b['commit'],backend=b['backend']) for b in state['builds']],image=s.image_runtime.status()['installed'],tts=s.tts_runtime.status()['installed'],enhancers={task:data['revision'] for task in ('t2i','i2i') if (data:=s.prompt_enhancer.installed(task))},swarm_nodes=(self.root/'video/swarm-comfy-nodes').is_dir()),warnings=warnings) if document['runtimes']['audio_cpp']: from audio_cpp_runtime import REVISION document['runtime_versions']['audio_cpp']=REVISION if document['runtimes']['ltx_original']: from ltx_original_runtime import DESKTOP_REV,LTX_REV document['runtime_versions']['ltx_original']=dict(desktop=DESKTOP_REV,inference=LTX_REV) if include_history: with tempfile.TemporaryDirectory(dir=self.directory) as work: out=Path(work)/'history.sqlite3' with s.dashboard.history._lock,sqlite3.connect(out) as dest:s.dashboard.history._db.backup(dest) if out.stat().st_size>MAX_HISTORY:raise ValueError('Dashboard-History größer als 40 MiB. History separat sichern oder Export ohne History wählen.') document['history']=base64.b64encode(out.read_bytes()).decode() return document def export(self,phrase,theme='deck',history=True):return seal(validate(self.snapshot(theme,history)),phrase) def inspect(self,raw,phrase): doc=validate(open_backup(raw,phrase));s=self.server;blockers=[];warnings=list(doc.get('warnings',[]));existing={m['id']:m for m in s.catalog.status()['entries']} if 'history' in doc:self._restore_history(doc['history'],validate_only=True) missing=[m for m in doc['models'] if m['id'] not in existing];need=sum(m['size'] for m in missing) if missing and need+10*1024**3>shutil.disk_usage(self.root).free:blockers.append('Zu wenig Platz für fehlende Gewichte einschließlich 10 GiB Reserve.') for m in doc['models']: if m.get('source')=='local' and not m.get('download_source'): warnings.append(m['file']+': lokale Datei ohne nachladbare Quelle; separat sichern.') if m['id'] not in existing:blockers.append(m['file']+': lokale Gewichtedatei fehlt und hat keine Downloadquelle.') if m['id'] in existing and existing[m['id']].get('sha256')!=m['sha256']:blockers.append('Vorhandene Modelldatei hat abweichende Prüfsumme: '+m['file']) if doc['runtimes']['llama']: prereq=s.runtime.prerequisites();backend=doc['runtimes']['llama']['backend'] if not prereq['cuda_ready' if backend=='CUDA' else 'cpu_ready']:blockers.append('llama.cpp-Buildwerkzeuge fehlen; Laufzeit-Voraussetzungen installieren.') if doc['network'] and doc['network'].get('config'): if s.network.disabled:blockers.append('Backup enthält WireGuard: Deck-Netzwerkmodul muss zuerst installiert/angebunden werden. Bestehender Gateway wird nicht übernommen.') elif s.network.status().get('enabled') or s.network.status().get('mode')!='lan':blockers.append('Deck-Tunnel vor Netzwerk-Restore im bestätigten LAN-Modus deaktivieren.') if doc['network']['mode']!='lan':warnings.append('Tunnelzugang wird mit Rückfallzeit aktiviert und muss über den Tunnel bestätigt werden.') versions=doc.get('runtime_versions',{}) if versions.get('audio_cpp'): from audio_cpp_runtime import REVISION if versions['audio_cpp']!=REVISION:blockers.append('Backup verlangt eine andere audio.cpp-Version. Passende Deck-Version verwenden.') if versions.get('ltx_original'): from ltx_original_runtime import DESKTOP_REV,LTX_REV if versions['ltx_original']!=dict(desktop=DESKTOP_REV,inference=LTX_REV):blockers.append('Backup verlangt eine andere originale LTX-Version. Passende Deck-Version verwenden.') if versions and {k:v for k,v in versions.items() if k not in ('ltx_original','audio_cpp')}!=dict(image=dict(comfy=s.image_runtime.status()['comfy_revision'],gguf=s.image_runtime.status()['gguf_revision']),tts=s.tts_runtime.status()['revision']):blockers.append('Backup verlangt andere Bild-/TTS-Laufzeitversionen. Passende Deck-Version oder geprüftes Runtime-Upgrade verwenden.') for c in doc['services']: if not c.get('registry') and c.get('build_recipe')!='swarm-ui':warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.') ports=s.endpoint.allowed_ports if ports and doc['settings'].get('endpoint.json',{}).get('port',8120) not in ports:blockers.append('API-Port aus Backup ist nicht veröffentlicht. Installer zuerst für diesen Port konfigurieren.') ident=secrets.token_hex(16);summary=dict(id=ident,created_at=doc.get('created_at'),models=[dict(id=m['id'],name=m['file'],bytes=m['size'],present=m['id'] in existing) for m in doc['models']],profiles=[p['name'] for p in doc['settings'].get('profiles.json',[])],services=[dict(name=c['name'],image=c['image'],registry=c.get('registry'),build_recipe=c.get('build_recipe'),restorable=bool(c.get('registry') or c.get('build_recipe')),running=c['running']) for c in doc['services']],download_bytes=need,runtimes=doc['runtimes'],wireguard=bool(doc['network'] and doc['network']['config']),warnings=warnings,blockers=blockers,theme=doc.get('theme','deck')) with self.lock: if self.busy():raise ValueError('Restore läuft bereits.') self.plan={'document':doc,'summary':summary,'expires':time.monotonic()+1800} return summary def inspect_checkpoint(self,name): if not isinstance(name,str) or not re.fullmatch(r'before-[a-f0-9]{32}\.adbackup',name):raise ValueError('Ungültiger Rückfallstand.') p=self.directory/name;key=p.with_suffix('.key') if p.is_symlink() or key.is_symlink() or not p.is_file() or not key.is_file():raise ValueError('Rückfallstand nicht verfügbar.') return self.inspect(p.read_bytes(),key.read_text()) def start(self,data): if not isinstance(data,dict) or set(data)!={'id','services','confirm','restore_credentials'} or data['confirm'] is not True or type(data['restore_credentials']) is not bool or not isinstance(data['services'],list):raise ValueError('Restore-Prüfung und ausdrückliche Bestätigung erforderlich.') with self.lock: if self.busy():raise ValueError('Restore läuft bereits.') if not self.plan or self.plan['expires']MAX_HISTORY:raise ValueError('History im Backup zu groß.') with tempfile.TemporaryDirectory(dir=self.directory) as work: path=Path(work)/'history.sqlite3';path.write_bytes(raw) with sqlite3.connect('file:'+str(path)+'?mode=ro',uri=True) as source: if source.execute('PRAGMA quick_check').fetchone()[0]!='ok':raise ValueError('Ungültige Dashboard-History.') expected={'meta','samples_raw','samples_hourly','slot_samples_raw','slot_samples_hourly','token_totals','token_hourly','model_events'} if {r[0] for r in source.execute("SELECT name FROM sqlite_master WHERE type='table'")}!=expected or source.execute("SELECT COUNT(*) FROM sqlite_master WHERE type IN ('trigger','view')").fetchone()[0]:raise ValueError('Unbekanntes History-Schema.') if not validate_only: with self.server.dashboard.history._lock:source.backup(self.server.dashboard.history._db) def stop(self): with self.lock: self.cancel.set() if self.busy(): self.server.catalog.stop();self.server.runtime.stop();self.server.image_runtime.stop();self.server.tts_runtime.stop();self.server.ltx_original_runtime.stop(); if getattr(self.server,'audio_cpp_runtime',None):self.server.audio_cpp_runtime.stop() self.server.prompt_enhancer.stop() return {'cancellation_requested':True}