Audit backup recovery coverage and preserve separator container isolation

This commit is contained in:
Mikei386 committed 2026-10-01 17:10:17 +02:00
1 parent bd5aea7656
commit 356abbeb9d
4 files changed
+118 -2

No files matched your search

+1 -1
View File
@@ -192,7 +192,7 @@ class Backup:
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['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','separator')}!=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.') if versions and {k:v for k,v in versions.items() if k not in ('ltx_original','audio_cpp','separator')}!=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']: 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.') if not c.get('registry') and not c.get('build_recipe'):warnings.append(c['name']+': Image nur lokal vorhanden; Registry-/Buildquelle fehlt.')
ports=s.endpoint.allowed_ports 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.') 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')) 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'))
+2 -1
View File
@@ -142,7 +142,8 @@ def restore(manager,c):
if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.') if base.is_symlink():raise ValueError('Unsicheres Dienstverzeichnis.')
base.mkdir(parents=True,exist_ok=True,mode=0o700) 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'] args=['create','--name',name,'--network',c['network'],'--restart',c['restart'],'--cap-drop','ALL','--security-opt','no-new-privileges:true','--pids-limit','256']
if c['name']=='athena-ladypoly':args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size=128m,uid=65534,gid=65534'] if c['name'] in ('athena-ladypoly','athena-separator-web'):
args+=['--read-only','--tmpfs','/tmp:rw,noexec,nosuid,size='+('1g' if c['name']=='athena-separator-web' else '128m')+',uid=65534,gid=65534']
if c['memory']:args+=['--memory',str(c['memory'])] if c['memory']:args+=['--memory',str(c['memory'])]
if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)] if c['nanocpus']:args+=['--cpus',str(c['nanocpus']/1e9)]
for k,v in c['labels'].items(): for k,v in c['labels'].items():
+68
View File
@@ -0,0 +1,68 @@
# Backup-/Restore-Prüfung — 2026-10-01
## Ergebnis am laufenden Athena
Lesende Prüfung der realen Metadaten: 30 Bibliotheksdateien, 10 Profile,
keine lokale Datei ohne belegte Downloadquelle. Alle vorhandenen Einstellungen,
Profilparameter und Komponentenreferenzen bestehen die Backup-Schemavalidierung.
Keine Chats, Prompts, Nutzmedien oder Anwendungslogs wurden gelesen.
Die drei markierten Zusatzdienste bestehen die Container-Validierung:
| Dienst | Wiederbeschaffung | Gesicherte Verbindung |
|---|---|---|
| Athena Separator Web | Build-Rezept im Deck-Repository | deck-connection.json |
| Athena LadyPoly | Build-Rezept im Deck-Repository | deck-connection.json |
| LTX DeskWEB | Registry-Image mit Digest | Token-/Kennwortdateien, Umgebungsvariablen, Mount-Ziele |
## Umfang
- Profile aller Kategorien einschließlich Komponenten, GPU-Zuordnung, Sampling,
MTP, KV-Cache und API-Freigaben.
- API-Port und Autostart; TTS/STT-Ladepolitik, sofern eingestellt.
- Administratorzugang und API-Token; Hugging-Face-Token; Deck-WireGuard-Konfiguration,
sofern das Deck-Netzwerkmodul angebunden ist.
- Modellkatalog mit Revision und SHA-256; lokale Herkunft zusätzlich mit belegter
Originalquelle. Restore erhält bestehende IDs und damit Profilreferenzen.
- llama.cpp-Builds, Bild-, TTS-, audio.cpp-, LTX- und Separator-Laufzeitrezepte,
Prompt-Enhancer und unterstützte Zusatznodes. Laufzeiten werden installiert/gebaut;
ihre Binärdateien gehören nicht in den Export.
- Separator-Pakete einschließlich YAML/Ensemble-Dateien aus geprüften Quellen.
- Markierte Anwendungscontainer einschließlich Konfiguration, Ports, Labels,
Ressourcenlimits und Startzustand. Bilder werden gepullt oder nach Rezept gebaut.
- Dashboard-History und das beim Export ausgewählte Theme.
Exportdateien sind passwortverschlüsselt. Das Exportkennwort separat aufbewahren.
Gewichte, Image-Layer, Projekte, Ergebnisse, Uploads, Chats und Logs sind ausgeschlossen.
Download-Verläufe, Warteschlangen und laufende Jobs werden nicht wieder aufgenommen.
Die zuletzt aktive GPU-Betriebsart wird nicht automatisch wieder gestartet.
## Grenzen eines neuen Servers
Der alte WireGuard-Gateway und unmarkierte alte Router-Container sind ausdrücklich
kein Teil des Deck-Backups. Docker-Registry-Anmeldungen des Hosts sind ebenfalls
nicht enthalten. Private Images können eine erneute Anmeldung benötigen.
Debian, Treiber, Build-Voraussetzungen, Systemhelfer und veröffentlichte API-Ports
müssen zunächst über den passenden Deck-Installer eingerichtet sein.
Die Restore-Vorschau prüft Quellen, Referenzen und Laufzeitversionen. Sie ist kein
Nachweis, dass externe Quellen in Zukunft erreichbar bleiben oder Zugänge weiterhin
berechtigt sind. Der Platzbedarf der Vorschau zählt fehlende Kataloggewichte;
zusätzliche Laufzeit-/Build- und Separator-Pakete brauchen weiteren Speicher.
Versionabweichungen verlangen eine passende Deck-Version oder ein geprüftes Upgrade.
Vorhandene Container werden nicht überschrieben; ihre Einstellungen bleiben erhalten.
## Prüfung und Korrekturen
Isolierte Tests mit temporären Zustandsverzeichnissen prüfen verschlüsselten Export,
Manipulationserkennung, Restore auf ein leeres Ziel mit simuliertem Download,
Prüfsummenfehler, lokale Originalquelle bei unveränderten Profil-IDs, alle vier
Separator-Pakete, Container-Schutz und nachbaubare lokale Images.
Live wurden die tatsächlichen Profil-/Quellenmetadaten und Container-Exports geprüft.
Ein vollständiger Restore mit echten Downloads auf einem frisch installierten Server
wurde nicht durchgeführt. Der laufende Server wurde nicht neu gestartet/restauriert.
Korrigiert: falsche Warnung über fehlende Quellen bei lokalen Build-Rezepten;
Separator-Web erhält beim Restore wieder eine schreibgeschützte Rootfs und seine
beschreibbare 1-GiB-Tmpfs. Änderungen benötigen zur Live-Aktivierung das nächste
Deck-App-Update und die Aktualisierung des Docker-Systemhelfers.
+47
View File
@@ -73,6 +73,43 @@ class RestoreTests(unittest.TestCase):
with patch.object(self.server.catalog,'start',side_effect=download): with patch.object(self.server.catalog,'start',side_effect=download):
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=True));job=self.wait() summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=True));job=self.wait()
self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['model_id'],ident) self.assertEqual(job['state'],'complete',job);self.assertEqual(self.server.profiles.rows[0]['model_id'],ident)
def test_all_separator_packages_have_pinned_sources_and_restore(self):
from separator_runtime import SOURCES
doc=self.server.backup.snapshot(include_history=False)
doc['runtimes']['separator']={'installed':True,'models':list(SOURCES),'sources':copy.deepcopy(SOURCES)}
validate(doc)
runtime=Mock();runtime.job=None;runtime.status.return_value={'installed':True,'version':'0.47.0','job':{'state':'complete'},'models':[]};runtime.model_ready.return_value=False
self.server.separator_runtime=runtime
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password')
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job)
self.assertEqual([c.args[0] for c in runtime.download.call_args_list],list(SOURCES))
bad=copy.deepcopy(doc);name=next(iter(SOURCES));bad['runtimes']['separator']['sources'][name]['recipe_version']=999
with self.assertRaises(ValueError):validate(bad)
def test_local_source_restore_preserves_profile_reference(self):
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False)
model=doc['models'][0];data=(self.root/'models'/ident/'model.gguf').read_bytes()
model.update(repo='local/athena-router',revision=model['sha256'],source='local',download_source={'repo':'test/original','revision':'b'*40,'file':'original.gguf'})
import shutil
shutil.rmtree(self.root/'models'/ident)
source_id=hashlib.sha256(('test/original'+'b'*40+'original.gguf').encode()).hexdigest()
def download(repo,filename,revision,kind):
self.assertEqual((repo,filename,revision),('test/original','original.gguf','b'*40))
p=self.root/'models'/source_id;p.mkdir();(p/'model.gguf').write_bytes(data)
entry=dict(repo=repo,revision=revision,file=filename,size=len(data),sha256=model['sha256'],kind=kind,state='downloaded');(p/'entry.json').write_text(json.dumps(entry));self.server.catalog.job={'state':'complete'}
with patch.object(self.server.catalog,'start',side_effect=download):
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password');self.assertFalse(summary['blockers'])
self.server.backup.start(dict(id=summary['id'],services=[],confirm=True,restore_credentials=False));job=self.wait()
self.assertEqual(job['state'],'complete',job)
self.assertEqual(self.server.profiles.rows[0]['model_id'],ident)
self.assertEqual(json.loads((self.root/'models'/ident/'entry.json').read_text())['download_source'],model['download_source'])
def test_buildable_local_container_does_not_warn_about_missing_source(self):
doc=self.server.backup.snapshot(include_history=False);c=ContainerTests().config()
c.update(name='athena-ladypoly',image='athena-ladypoly:3960afc-deck1',registry=None,network='host',build_recipe='ladypoly')
doc['services']=[c]
summary=self.server.backup.inspect(seal(doc,'backup secure password'),'backup secure password')
self.assertTrue(summary['services'][0]['restorable'])
self.assertFalse(any('Buildquelle fehlt' in w for w in summary['warnings']))
def test_rejects_unsafe_paths_and_bad_references_before_changes(self): def test_rejects_unsafe_paths_and_bad_references_before_changes(self):
ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False) ident=self.model();self.profile(ident);doc=self.server.backup.snapshot(include_history=False)
for change in (lambda d:d['models'][0].update(file='../secret'),lambda d:d['settings']['profiles.json'][0].update(model_id='f'*64),lambda d:d.update(version=99)): for change in (lambda d:d['models'][0].update(file='../secret'),lambda d:d['settings']['profiles.json'][0].update(model_id='f'*64),lambda d:d.update(version=99)):
@@ -100,6 +137,16 @@ class ContainerTests(unittest.TestCase):
for change in (lambda x:x.update(name='other-ui'),lambda x:x.update(image='untrusted:1'),lambda x:x['mounts'][0].update(target='/other')): for change in (lambda x:x.update(name='other-ui'),lambda x:x.update(image='untrusted:1'),lambda x:x['mounts'][0].update(target='/other')):
bad=copy.deepcopy(c);change(bad) bad=copy.deepcopy(c);change(bad)
with self.assertRaises(ValueError):validate_container(bad) with self.assertRaises(ValueError):validate_container(bad)
def test_separator_restore_keeps_readonly_root_and_writable_tmpfs(self):
c=self.config();c.update(name='athena-separator-web',image='athena-separator-web:0.1.0',registry=None,network='host',ports={},build_recipe='separator-web',running=False)
with tempfile.TemporaryDirectory() as d:
root=Path(d);policy=root/'backup-policy.json';policy.write_text(json.dumps({'deck_state':d}))
calls=[]
with patch('deploy.docker_backup.subprocess.run',return_value=Mock(returncode=1)),patch('deploy.docker_backup.command',side_effect=lambda args,*extra,**kw:calls.append(args) or ''),patch('deploy.docker_backup.Path.stat',return_value=Mock(st_uid=0,st_mode=0o100600)):
result=restore_container(Mock(state=root),c)
self.assertEqual(result['state'],'complete')
create=next(args for args in calls if args[0]=='create')
self.assertIn('--read-only',create);self.assertIn('/tmp:rw,noexec,nosuid,size=1g,uid=65534,gid=65534',create)
def test_never_replaces_existing_foreign_container(self): def test_never_replaces_existing_foreign_container(self):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d})) manager=Mock(state=Path(d));(Path(d)/'backup-policy.json').write_text(json.dumps({'deck_state':d}))