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
+35 -1
View File
@@ -136,6 +136,8 @@ class Server(ThreadingHTTPServer):
session=self.sessions.get(token)
return bool(record and session and session['expires']>time.monotonic() and secrets.compare_digest(session['revision'],record['password']['hash']))
self.endpoint.video_browser_auth=video_browser_auth
from backup import Backup
self.backup=Backup(self)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
@@ -288,7 +290,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
routes = {'/backup-ui.js':('backup-ui.js','text/javascript'),'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/themes.css':('themes.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
if self.path in routes:
name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime)
@@ -297,6 +299,7 @@ class Handler(BaseHTTPRequestHandler):
public_endpoint={key:endpoint[key] for key in ('state','port','counts','active_requests')}
return self.respond(dict(name='Athena Deck', version='0.7.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), endpoint=public_endpoint))
if self.path == '/api/v1/auto-tests':return self.respond(self.server.auto_tests.status())
if self.path == '/api/v1/backup':return self.respond(self.server.backup.status())
if self.path == '/api/v1/chat-tests':return self.respond(self.server.chat_tests.status())
if self.path == '/api/v1/endpoint':return self.respond(self.server.endpoint.status())
if self.path == '/api/v1/docker':return self.respond(self.server.docker.status())
@@ -396,6 +399,36 @@ class Handler(BaseHTTPRequestHandler):
return self.login()
if not self.authenticated():
return self.respond({'error':'Anmeldung oder gültiger API-Token erforderlich.'},401)
if self.server.backup.busy() and not self.path.startswith('/api/v1/backup/') and self.path != '/api/v1/logout':
return self.respond({'error':'Wiederherstellung läuft. Änderungen und Tests sind vorübergehend gesperrt.'},409)
if self.path.startswith('/api/v1/backup/'):
try:
if not self.authenticated(session_only=True):return self.respond({'error':'Administratorsitzung erforderlich.'},401)
action=self.path.rsplit('/',1)[1]
if action=='export':
data=self.read_json()
if set(data)!={'password','theme','history'} or type(data['history']) is not bool:raise ValueError('Backup-Kennwort, Theme und History-Auswahl erforderlich.')
body=self.server.backup.export(data['password'],data['theme'],data['history'])
self.send_response(200);self.send_header('Content-Type','application/octet-stream');self.send_header('Content-Length',str(len(body)));self.send_header('Content-Disposition','attachment; filename="athena-deck-'+time.strftime('%Y%m%d-%H%M%S')+'.adbackup"');self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body);return
if action=='inspect':
from backup_codec import MAX
self.connection.settimeout(60)
length=int(self.headers.get('Content-Length','0'))
if self.headers.get('Transfer-Encoding') or self.headers.get('Content-Type')!='application/octet-stream' or not 0<length<=MAX:raise ValueError('Backup-Datei erwartet; maximal 64 MiB.')
raw=self.rfile.read(length)
if len(raw)!=length:raise ValueError('Backup-Upload unvollständig.')
return self.respond(self.server.backup.inspect(raw,__import__('base64').b64decode(self.headers.get('X-Backup-Passphrase',''),validate=True).decode('utf-8')))
if action=='checkpoint':
data=self.read_json()
if set(data)!={'name'}:raise ValueError('Rückfallstand erforderlich.')
return self.respond(self.server.backup.inspect_checkpoint(data['name']))
if action=='restore':return self.respond(self.server.backup.start(self.read_json()),202)
if action=='cancel':
if self.read_json():raise ValueError('Keine Parameter erwartet.')
return self.respond(self.server.backup.stop())
raise ValueError('Unbekannte Backup-Aktion.')
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'Backup-Aktion fehlgeschlagen. Dateiformat, freien Speicher und Systemhelfer prüfen.'},503)
if self.path == '/api/v1/audio-policy':
try:return self.respond(self.server.audio_policy.configure(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -603,6 +636,7 @@ def main():
try:
server.serve_forever()
finally:
server.backup.stop()
server.dashboard.close()
server.auto_tests.stop()
server.chat_tests.stop()