From 3d528f271671830fddfadecb97d6d9acf956c9cd Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:48:41 +0200 Subject: [PATCH] Add encrypted bare-metal recovery workflow --- README.md | 9 +- docs/BARE_METAL_RECOVERY.md | 121 +++++++++++++++++++ docs/DISASTER_RECOVERY.md | 7 ++ docs/INSTALLATION.md | 5 + docs/RECOVERY_REQUIREMENTS.md | 11 +- install.sh | 5 +- platform/mcp/verify-navidrome.sh | 96 +++++++++++++++ platform/recovery/create-recovery-bundle.sh | 78 ++++++++++++ platform/recovery/restore-recovery-bundle.sh | 91 ++++++++++++++ 9 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 docs/BARE_METAL_RECOVERY.md create mode 100755 platform/mcp/verify-navidrome.sh create mode 100755 platform/recovery/create-recovery-bundle.sh create mode 100755 platform/recovery/restore-recovery-bundle.sh diff --git a/README.md b/README.md index 720922d..6621622 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ WireGuard-Isolation. 80:20); etwa 68 Token/s und erfolgreicher 220K-Prompt-Fülltest - Open WebUI als einzige normale Oberfläche - SearXNG/Web-MCP ohne externen API-Schlüssel -- zentrale MCP-Werkzeugebene: getrennte Container für Web, HA, ARR, Unraid - und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients +- zentrale MCP-Werkzeugebene: getrennte Container für Web, HA, ARR, Unraid, + Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients - KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar - KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed - keine Secrets, Chats, Logs oder Modelldateien im Repository @@ -55,12 +55,12 @@ Neustart an; danach wird derselbe Befehl erneut ausgeführt. | XTTS-v2 | nur Docker-intern, RTX 3060 | primäre mehrsprachige Sprachausgabe | | TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback | | Piper | nur Docker-intern, CPU | ausfallsichere deutsche Ersatzstimme | -| MCP-Tool-Stack | nur Docker-intern | Web, Home Assistant, ARR und Unraid | +| MCP-Tool-Stack | nur Docker-intern | Web, Home Assistant, ARR, Unraid und Navidrome | XTTS-v2, TTS-Gateway, Piper-Fallback und der FLUX.2-Klein-Hot-Swap sind reproduzierbare Kerndienste; STT bleibt optional. Web-, Home-Assistant-, -ARR- und Unraid-Werkzeuge besitzen dagegen bereits getrennte Container unter +ARR-, Unraid- und Navidrome-Werkzeuge besitzen dagegen bereits getrennte Container unter `platform/mcp/`. Open WebUI erreicht sie ausschließlich über das interne `mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine Infrastruktur-Secrets. Die Bildanalyse ist Bestandteil des multimodalen @@ -86,6 +86,7 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten. - [Betrieb und Profilwechsel](docs/OPERATIONS.md) - [Sicherheitsmodell](docs/SECURITY.md) - [Disaster Recovery](docs/DISASTER_RECOVERY.md) +- [Vollständige Bare-Metal-Wiederherstellung](docs/BARE_METAL_RECOVERY.md) - [Protokoll des Athena-Leerhostaufbaus](docs/ATHENA_REBUILD_LOG.md) - [Komponenten](docs/COMPONENTS.md) diff --git a/docs/BARE_METAL_RECOVERY.md b/docs/BARE_METAL_RECOVERY.md new file mode 100644 index 0000000..86ce185 --- /dev/null +++ b/docs/BARE_METAL_RECOVERY.md @@ -0,0 +1,121 @@ +# Vollständige Bare-Metal-Wiederherstellung + +Dieses Dokument ist die verbindliche Anleitung für den Verlust der Athena- +System-SSD. Wissen aus früheren Chats ist weder Voraussetzung noch gültige +Dokumentation. + +## Was woher wiederkommt + +| Bestandteil | Quelle beim Recovery | +|---|---| +| Plattform, Router, Profile, MCP-Builds und Patches | dieses Git-Repository, exakter Commit | +| Qwen-, Projektor- und Bildmodelle | dokumentierte URLs und SHA256 in `config/install.env.example` beziehungsweise der gesicherten Installationskonfiguration | +| Navidrome-MCP 2.2.0 samt llama.cpp-Schemafix | `platform/mcp/Dockerfile.navidrome` | +| OpenWebUI-Benutzer, Chats, Arbeitsbereichsmodelle, Filter und Verbindungen | verschlüsseltes Recovery-Bundle | +| Router-, WireGuard-, HA-, ARR-, Unraid- und Navidrome-Zugangsdaten | verschlüsseltes Recovery-Bundle | +| Last.fm API-Key | `/etc/mike-ai/navidrome-mcp.env` im verschlüsselten Bundle | +| Navidrome-Bibliothek und Benutzer | bleiben auf dem separaten Unraid-Server | + +Das Last.fm Shared Secret wird nicht verwendet und daher nicht gesichert. +Modelldateien müssen nicht im Bundle liegen: Der Installer lädt sie erneut und +verifiziert jede Datei kryptografisch. Ein vorhandenes intaktes `/data` kann +den Download lediglich beschleunigen. + +## Einmalige Vorbereitung + +Die geheime age-Identität muss **außerhalb Athenas** liegen, beispielsweise +auf dem Mac und zusätzlich in einem Passwortmanager oder Offline-Datenträger: + +```bash +age-keygen -o athena-recovery.agekey +age-keygen -y athena-recovery.agekey > athena-recovery.recipient +``` + +Nur die öffentliche Zeile aus `athena-recovery.recipient` wird auf Athena als +`/etc/mike-ai/recovery.age-recipient` mit Modus `0600` abgelegt. Die Datei +`athena-recovery.agekey` darf niemals auf Athena oder im Git liegen. + +## Sicherung erzeugen + +Das Ziel muss nach einem SSD-Verlust noch existieren. Bevorzugt wird ein +gemountetes, ausschließlich für Backups beschreibbares Verzeichnis auf Unraid; +`/data` allein schützt nur vor dem Verlust der System-SSD, nicht vor Verlust +des gesamten Rechners. + +```bash +sudo /opt/mike-ai/stack/platform/recovery/create-recovery-bundle.sh \ + /PFAD/AUF/UNRAID/athena-recovery-$(date +%F).tar.age +``` + +Das Skript nimmt ausschließlich auf: + +- `/etc/mike-ai` einschließlich aller Fach-MCP-Secrets, +- `/root/mike-ai-install.env`, +- das lokale `runraid`-Binary, falls vorhanden, +- das vollständige OpenWebUI-Datenvolume, +- Prüfsummen und den eingesetzten Git-Commit. + +Der Klartext liegt nur in einem kurzlebigen root-only Verzeichnis unter +`/tmp` und wird beim Ende entfernt. Das Ergebnis ist vollständig mit age +verschlüsselt. Ohne erfolgreiche Ausgabe `RECOVERY_BUNDLE_OK` gilt die +Sicherung als fehlgeschlagen. Für ein konsistentes SQLite-Abbild stoppt das +Skript OpenWebUI kurz und startet es anschließend auch bei einem Fehler über +eine Aufräumroutine wieder. Laufende LLM-Profile und MCP-Dienste bleiben dabei +unangetastet. + +## Wiederherstellung nach SSD-Verlust + +1. Debian 12 oder 13 installieren, Netzwerk herstellen und den administrativen + Benutzer aus der Installationskonfiguration anlegen. +2. Root-SSH-Zugriff mit dem vorhandenen Schlüssel herstellen. +3. Dieses Repository klonen und exakt den in `METADATA` des Bundles genannten + Commit auschecken. Normalerweise ist das der Commit, mit dem die Sicherung + erzeugt wurde. +4. Recovery-Bundle und `athena-recovery.agekey` temporär auf den Host kopieren. +5. Einen Befehl ausführen: + +```bash +sudo ./platform/recovery/restore-recovery-bundle.sh \ + /root/athena-recovery-YYYY-MM-DD.tar.age \ + /root/athena-recovery.agekey +``` + +Der Wiederhersteller: + +1. installiert nur die zum Entschlüsseln benötigten Basispakete, +2. prüft Verschlüsselung, Archivstruktur, SHA256 und Git-Commit, +3. stellt Installationskonfiguration und Secrets ohne Ausgabe ihrer Werte her, +4. führt den idempotenten Hostinstaller aus, +5. signalisiert einen notwendigen NVIDIA-/Netzwerk-Reboot mit Status 20/21; + danach wird derselbe Befehl erneut ausgeführt, +6. sichert den vorhandenen OpenWebUI-Stand als Rückfallarchiv und stellt dann + das geprüfte OpenWebUI-Datenvolume wieder her, +7. installiert die versionierten Modelleinstellungen, Filter und MCP- + Verbindungen erneut, +8. startet alle durch vorhandene Secret-Dateien freigegebenen Toolprofile, +9. prüft Navidrome, sämtliche Werkzeug-Schemas, Last.fm und den OpenWebUI- + Verbindungseintrag ohne Musik- oder Zugangsdaten auszugeben. + +Erst die Ausgabe `BARE_METAL_RECOVERY_OK` bedeutet Erfolg. + +## Navidrome-Abnahmekriterium + +Bei vorhandenem `LASTFM_API_KEY` müssen 45 Werkzeuge erscheinen, andernfalls +38. Playback-Werkzeuge dürfen auf Athena nicht auftauchen. Sämtliche Regex- +Patterns müssen für llama.cpp vollständig mit `^…$` verankert sein. Eine +öffentliche Last.fm-Trendabfrage muss funktionieren; Bibliothek, Playlists und +Hörverlauf werden während der Abnahme nicht gelesen. + +Die Prüfung kann jederzeit wiederholt werden: + +```bash +sudo /opt/mike-ai/stack/platform/mcp/verify-navidrome.sh +``` + +## Regelmäßige Kontrolle + +Mindestens nach jeder Änderung an OpenWebUI, WireGuard oder einem MCP-Secret +wird ein neues Bundle erzeugt und **außerhalb Athenas** aufbewahrt. Quartalsweise +wird ein Restore in einer isolierten Testinstallation durchgeführt. Eine +Sicherung ohne getestete Entschlüsselung und Abnahmemarker ist nur eine +Hoffnung, kein Backup. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 6ba78ac..b5b3cf8 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -61,6 +61,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] GitHub- und Hugging-Face-Routing geprüft - [ ] Home Assistant read-only Diagnose geprüft - [ ] ARR read-only Suche geprüft +- [ ] Navidrome-MCP gesund; 38 beziehungsweise mit Last.fm 45 Werkzeuge +- [ ] Navidrome-Schemas vollständig llama.cpp-kompatibel +- [ ] Navidrome ist nicht pauschal an jedes Modellprofil gebunden - [ ] Unraid read-only Diagnose geprüft - [ ] schreibende Werkzeuge standardmäßig nicht geladen - [ ] Tool-Schemas bleiben innerhalb des festgelegten Kontextbudgets @@ -95,6 +98,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] kein allgemeiner Shell-MCP im Standardprofil - [ ] Schreibaktionen verlangen Vorschau und Approval Ticket - [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt +- [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena +- [ ] age-Identität liegt getrennt vom Bundle und nicht auf Athena ## Phase G – Fachlicher Benchmark @@ -127,3 +132,5 @@ Für jeden Wiederaufbau werden festgehalten: Erst nach Abschluss aller Pflichtpunkte darf der alte Host gelöscht oder als Fallback außer Betrieb genommen werden. + +Der ausführbare Ablauf steht in [BARE_METAL_RECOVERY.md](BARE_METAL_RECOVERY.md). diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 8ddfc25..dfd3fd2 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -180,6 +180,11 @@ Kein MCP-Port wird auf dem Host veröffentlicht. Externe Clients wie Hermes benötigen später den authentifizierten WireGuard-Gateway und dürfen nicht direkt auf das interne Werkzeugnetz zugreifen. +Die vollständige Wiederherstellung einschließlich OpenWebUI, MCP-Secrets und +Navidrome/Last.fm ist unter +[BARE_METAL_RECOVERY.md](BARE_METAL_RECOVERY.md) dokumentiert und durch +ausführbare Backup-/Restore-Skripte abgebildet. + Die Standardkonfiguration lädt IQ4-MIX für Fast, IQ4_XS Pure für Medium, Large und Ultra sowie Abliterated Q4_K_M für Uncensored aus den dokumentierten Hugging-Face-Repositories. URLs, Dateinamen und SHA256 stehen vollständig in `config/install.env.example`. diff --git a/docs/RECOVERY_REQUIREMENTS.md b/docs/RECOVERY_REQUIREMENTS.md index 1eddf99..0b3d854 100644 --- a/docs/RECOVERY_REQUIREMENTS.md +++ b/docs/RECOVERY_REQUIREMENTS.md @@ -90,10 +90,16 @@ Benötigt wird ein festes Verfahren für: - Home-Assistant-Token - Sonarr-/Radarr-API-Schlüssel - Unraid-Zugang +- Navidrome-Benutzer und Last.fm API-Key - optionale GitHub-, Hugging-Face- und Brave-Schlüssel - SSH-Hostschlüssel und bekannte Hosts -Noch festzulegen: +Umgesetzt ist ein age-verschlüsseltes Bundle über +`platform/recovery/create-recovery-bundle.sh` und der zugehörige +Bare-Metal-Restore. Noch standortspezifisch festzulegen ist ausschließlich das +externe Zielverzeichnis auf Unraid. + +Verbindlich bleiben: - verschlüsseltes Backupformat, beispielsweise age oder ein Passwortmanager - Besitzer und Rechte je Environment-Datei @@ -186,7 +192,8 @@ run-acceptance-tests ``` `platform/mcp/install-tools.sh` installiert den Webbereich automatisch und -aktiviert HA, ARR und Unraid nur bei vorhandenen Secret-/Programmdateien. +aktiviert HA, ARR, Navidrome und Unraid nur bei vorhandenen +Secret-/Programmdateien. `platform/migration/restore-reference-backup.sh` importiert eine bestehende OpenWebUI-Datenbank und ausschließlich die freigegebenen Tool-Secrets, ohne experimentelle Altcontainer zurückzubringen. Der Leerhost-Probelauf wird auf diff --git a/install.sh b/install.sh index 7763cf0..7aa0f37 100755 --- a/install.sh +++ b/install.sh @@ -67,7 +67,7 @@ install_base_packages() { apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ - iproute2 pciutils rsync unattended-upgrades ethtool + iproute2 pciutils rsync unattended-upgrades ethtool age } setup_stable_network_name() { @@ -287,6 +287,9 @@ install_stack_files() { install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$XTTS_CACHE_DIR" "$STATE_DIR/backups" rsync -a --delete --exclude .git --exclude '*.local.*' \ --exclude config/install.env "$ROOT_DIR/" "$STACK_DIR/" + if git -C "$ROOT_DIR" rev-parse HEAD >/dev/null 2>&1; then + git -C "$ROOT_DIR" rev-parse HEAD >"$STACK_DIR/.mike-ai-source-commit" + fi install -d -m 0700 "$SECRETS_DIR" [[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token diff --git a/platform/mcp/verify-navidrome.sh b/platform/mcp/verify-navidrome.sh new file mode 100755 index 0000000..fc59a1b --- /dev/null +++ b/platform/mcp/verify-navidrome.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui} +ENV_FILE=${NAVIDROME_MCP_ENV_FILE:-/etc/mike-ai/navidrome-mcp.env} + +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -s $ENV_FILE ]] || die "Navidrome-Secret-Datei fehlt." +[[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-mcp-navidrome 2>/dev/null || true) == healthy ]] || \ + die "Navidrome-MCP ist nicht gesund." +[[ $(docker inspect -f '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || true) == healthy ]] || \ + die "OpenWebUI ist nicht gesund." + +expect_lastfm=false +grep -q '^LASTFM_API_KEY=..' "$ENV_FILE" && expect_lastfm=true + +result=$(docker exec -i -e EXPECT_LASTFM="$expect_lastfm" "$CONTAINER" python - <<'PY' +import asyncio +import json +import os +import sqlite3 +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +EXPECTED_LASTFM = { + "get_similar_artists", "get_similar_tracks", "get_artist_info", + "get_top_tracks_by_artist", "get_trending_music", "get_artist_albums", + "get_album_info", +} +PLAYBACK = {"play_songs", "pause", "set_volume"} + +def find_unanchored(value, path=""): + bad = [] + if isinstance(value, dict): + for key, child in value.items(): + here = f"{path}.{key}" if path else key + if key == "pattern" and ( + not isinstance(child, str) + or not child.startswith("^") + or not child.endswith("$") + ): + bad.append(here) + bad.extend(find_unanchored(child, here)) + elif isinstance(value, list): + for index, child in enumerate(value): + bad.extend(find_unanchored(child, f"{path}[{index}]")) + return bad + +async def verify(): + async with streamablehttp_client( + "http://mike-ai-mcp-navidrome:3000/mcp" + ) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.list_tools() + names = {tool.name for tool in result.tools} + bad = [] + for tool in result.tools: + bad.extend(find_unanchored(tool.inputSchema, tool.name)) + if bad: + raise SystemExit("Unverankerte JSON-Schema-Patterns: " + ", ".join(bad)) + if PLAYBACK & names: + raise SystemExit("Playback-Werkzeuge sind auf dem Headless-Host aktiv.") + expect_lastfm = os.environ.get("EXPECT_LASTFM") == "true" + if expect_lastfm and not EXPECTED_LASTFM <= names: + raise SystemExit("Last.fm-Werkzeugkatalog ist unvollständig.") + if not expect_lastfm and EXPECTED_LASTFM & names: + raise SystemExit("Last.fm-Werkzeuge sind ohne konfigurierten Schlüssel aktiv.") + if expect_lastfm: + # Public metadata only. Do not print the returned chart data. + response = await session.call_tool( + "get_trending_music", {"type": "artists", "limit": 1} + ) + if response.isError: + raise SystemExit("Öffentliche Last.fm-Testabfrage ist fehlgeschlagen.") + + con = sqlite3.connect("/app/backend/data/webui.db") + row = con.execute( + "select value from config where key=?", ("tool_server.connections",) + ).fetchone() + connections = json.loads(row[0]) if row else [] + ids = { + str((connection.get("info") or {}).get("id", "")) + for connection in connections if isinstance(connection, dict) + } + if "navidrome-local" not in ids: + raise SystemExit("OpenWebUI-Verbindung navidrome-local fehlt.") + print(f"NAVIDROME_ACCEPTANCE_OK tools={len(names)} lastfm={str(expect_lastfm).lower()}") + +asyncio.run(verify()) +PY +) +[[ $result == NAVIDROME_ACCEPTANCE_OK\ * ]] || \ + die "Navidrome-Abnahme lieferte keinen gültigen Erfolgsmarker." +printf '%s\n' "$result" diff --git a/platform/recovery/create-recovery-bundle.sh b/platform/recovery/create-recovery-bundle.sh new file mode 100755 index 0000000..710a748 --- /dev/null +++ b/platform/recovery/create-recovery-bundle.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +OUTPUT=${1:-} +RECIPIENT_FILE=${AGE_RECIPIENT_FILE:-/etc/mike-ai/recovery.age-recipient} +OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data} +OPENWEBUI_CONTAINER=${OPENWEBUI_CONTAINER:-mike-ai-open-webui} +STACK_DIR=${STACK_DIR:-/opt/mike-ai/stack} + +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -n $OUTPUT ]] || die "Aufruf: $0 /sicheres/offhost-ziel/athena-recovery-YYYYMMDD.tar.age" +[[ -s $RECIPIENT_FILE ]] || die "Age-Empfängerdatei fehlt: $RECIPIENT_FILE" +command -v age >/dev/null || die "age ist nicht installiert." +command -v docker >/dev/null || die "Docker ist nicht installiert." + +recipient=$(awk '/^age1[[:alnum:]]+$/ {print; exit}' "$RECIPIENT_FILE") +[[ -n $recipient ]] || die "Keine gültige öffentliche age-Adresse gefunden." +install -d -m 0700 "$(dirname "$OUTPUT")" +[[ ! -e $OUTPUT ]] || die "Zieldatei existiert bereits: $OUTPUT" + +stage=$(mktemp -d /tmp/mike-ai-recovery.XXXXXX) +openwebui_was_running=false +cleanup() { + if $openwebui_was_running; then + docker start "$OPENWEBUI_CONTAINER" >/dev/null 2>&1 || \ + printf 'WARNUNG: OpenWebUI konnte nach dem Backup nicht gestartet werden.\n' >&2 + fi + rm -rf "$stage" +} +trap cleanup EXIT +mkdir -p "$stage/rootfs" "$stage/payload" + +for source in \ + /etc/mike-ai \ + /root/mike-ai-install.env \ + /usr/local/bin/runraid; do + [[ -e $source ]] || continue + rsync -aR "$source" "$stage/rootfs/" +done + +tar -C "$stage/rootfs" -czf "$stage/payload/host-config.tar.gz" . +volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME") +[[ -s $volume_path/webui.db ]] || die "OpenWebUI-Datenbank fehlt oder ist leer." + +# OpenWebUI uses SQLite. A filesystem archive while the database is being +# written can be internally inconsistent even if tar itself succeeds. Keep the +# interruption short, but always restart the container through the EXIT trap. +if [[ $(docker inspect -f '{{.State.Running}}' "$OPENWEBUI_CONTAINER" 2>/dev/null || true) == true ]]; then + openwebui_was_running=true + docker stop --time 30 "$OPENWEBUI_CONTAINER" >/dev/null +fi +tar -C "$volume_path" -czf "$stage/payload/openwebui-data.tar.gz" . +if $openwebui_was_running; then + docker start "$OPENWEBUI_CONTAINER" >/dev/null + openwebui_was_running=false +fi + +source_commit=unknown +[[ ! -s $STACK_DIR/.mike-ai-source-commit ]] || source_commit=$(<"$STACK_DIR/.mike-ai-source-commit") +cat >"$stage/payload/METADATA" <SHA256SUMS + tar -czf "$stage/bundle.tar.gz" \ + host-config.tar.gz openwebui-data.tar.gz METADATA SHA256SUMS +) + +age -r "$recipient" -o "$OUTPUT.partial" "$stage/bundle.tar.gz" +mv "$OUTPUT.partial" "$OUTPUT" +chmod 0600 "$OUTPUT" +printf 'RECOVERY_BUNDLE_OK %s\n' "$OUTPUT" diff --git a/platform/recovery/restore-recovery-bundle.sh b/platform/recovery/restore-recovery-bundle.sh new file mode 100755 index 0000000..b3ad607 --- /dev/null +++ b/platform/recovery/restore-recovery-bundle.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +umask 077 + +BUNDLE=${1:-} +IDENTITY=${2:-} +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +OPENWEBUI_VOLUME=${OPENWEBUI_VOLUME:-mike-ai_open-webui-data} + +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } +log() { printf '\n==> %s\n' "$*"; } +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -s $BUNDLE ]] || die "Recovery-Bundle fehlt." +[[ -s $IDENTITY ]] || die "Age-Identität fehlt." + +if ! command -v age >/dev/null || ! command -v rsync >/dev/null; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends age rsync +fi + +stage=$(mktemp -d /tmp/mike-ai-restore.XXXXXX) +trap 'rm -rf "$stage"' EXIT +age -d -i "$IDENTITY" -o "$stage/bundle.tar.gz" "$BUNDLE" +tar -C "$stage" -xzf "$stage/bundle.tar.gz" +( + cd "$stage" + sha256sum -c SHA256SUMS +) +tar -tzf "$stage/openwebui-data.tar.gz" | grep -Eq '(^|/)webui\.db$' || \ + die "OpenWebUI-Archiv enthält keine Datenbank." + +recorded_commit=$(sed -n 's/^source_commit=//p' "$stage/METADATA" | head -n 1) +current_commit=$(git -C "$ROOT_DIR" rev-parse HEAD 2>/dev/null || true) +if [[ -n $recorded_commit && $recorded_commit != unknown && \ + $current_commit != "$recorded_commit" ]]; then + die "Repository-Commit stimmt nicht mit dem Backup überein: erwartet $recorded_commit" +fi + +log "Root-only Konfiguration und freigegebene Secrets wiederherstellen" +mkdir -p "$stage/rootfs" +tar -C "$stage/rootfs" -xzf "$stage/host-config.tar.gz" +[[ -s $stage/rootfs/root/mike-ai-install.env ]] || \ + die "Installationskonfiguration fehlt im Bundle." +install -d -m 0700 /etc/mike-ai +rsync -a "$stage/rootfs/etc/mike-ai/" /etc/mike-ai/ +install -m 0600 "$stage/rootfs/root/mike-ai-install.env" /root/mike-ai-install.env +if [[ -x $stage/rootfs/usr/local/bin/runraid ]]; then + install -m 0755 "$stage/rootfs/usr/local/bin/runraid" /usr/local/bin/runraid +fi + +log "Reproduzierbaren Host-Installer ausführen" +set +e +"$ROOT_DIR/install.sh" --config /root/mike-ai-install.env +status=$? +set -e +if [[ $status == 20 || $status == 21 ]]; then + printf 'REBOOT_REQUIRED code=%s\n' "$status" + printf 'Nach dem Neustart denselben Restore-Befehl erneut ausführen.\n' + exit "$status" +fi +[[ $status == 0 ]] || die "Host-Installer ist mit Status $status fehlgeschlagen." + +log "OpenWebUI-Zustand atomar wiederherstellen" +volume_path=$(docker volume inspect -f '{{.Mountpoint}}' "$OPENWEBUI_VOLUME") +[[ -d $volume_path && $volume_path == /* && $volume_path != / && \ + $volume_path != /data && $volume_path != /var && \ + $volume_path != /var/lib && $volume_path != /var/lib/docker ]] || \ + die "Unsicherer Docker-Volume-Pfad: $volume_path" +fallback=/data/openwebui-before-disaster-restore-$(date +%Y%m%d-%H%M%S).tar.gz +docker stop mike-ai-open-webui >/dev/null 2>&1 || true +tar -C "$volume_path" -czf "$fallback" . +find "$volume_path" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + +tar -C "$volume_path" -xzf "$stage/openwebui-data.tar.gz" +docker start mike-ai-open-webui >/dev/null + +deadline=$((SECONDS + 240)) +until [[ $(docker inspect -f '{{.State.Health.Status}}' mike-ai-open-webui 2>/dev/null || true) == healthy ]]; do + (( SECONDS < deadline )) || die "OpenWebUI wurde nicht rechtzeitig gesund." + sleep 3 +done + +log "Versionierte Modelle, Filter und Tool-Verbindungen nachziehen" +"$ROOT_DIR/platform/openwebui/install-models.sh" +"$ROOT_DIR/platform/openwebui/install-filters.sh" +"$ROOT_DIR/platform/mcp/install-tools.sh" +if [[ -s /etc/mike-ai/navidrome-mcp.env ]]; then + "$ROOT_DIR/platform/mcp/verify-navidrome.sh" +fi + +printf 'BARE_METAL_RECOVERY_OK\n' +printf 'Rückfallsicherung des leeren OpenWebUI-Stands: %s\n' "$fallback"