Add encrypted bare-metal recovery workflow

This commit is contained in:
Mikei386
2026-08-23 15:48:41 +02:00
parent e5dffbc2ba
commit 3d528f2716
9 changed files with 416 additions and 7 deletions
+5 -4
View File
@@ -18,8 +18,8 @@ WireGuard-Isolation.
80:20); etwa 68 Token/s und erfolgreicher 220K-Prompt-Fülltest 80:20); etwa 68 Token/s und erfolgreicher 220K-Prompt-Fülltest
- Open WebUI als einzige normale Oberfläche - Open WebUI als einzige normale Oberfläche
- SearXNG/Web-MCP ohne externen API-Schlüssel - SearXNG/Web-MCP ohne externen API-Schlüssel
- zentrale MCP-Werkzeugebene: getrennte Container für Web, HA, ARR, Unraid - zentrale MCP-Werkzeugebene: getrennte Container für Web, HA, ARR, Unraid,
und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients
- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar - KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed - KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
- keine Secrets, Chats, Logs oder Modelldateien im Repository - 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 | | XTTS-v2 | nur Docker-intern, RTX 3060 | primäre mehrsprachige Sprachausgabe |
| TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback | | TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback |
| Piper | nur Docker-intern, CPU | ausfallsichere deutsche Ersatzstimme | | 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 XTTS-v2, TTS-Gateway, Piper-Fallback und der FLUX.2-Klein-Hot-Swap sind
reproduzierbare Kerndienste; STT reproduzierbare Kerndienste; STT
bleibt optional. Web-, Home-Assistant-, 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 `platform/mcp/`. Open WebUI erreicht sie ausschließlich über das interne
`mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine `mike-ai-tools`-Netz; llama.cpp erhält keine MCP-Konfiguration und keine
Infrastruktur-Secrets. Die Bildanalyse ist Bestandteil des multimodalen 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) - [Betrieb und Profilwechsel](docs/OPERATIONS.md)
- [Sicherheitsmodell](docs/SECURITY.md) - [Sicherheitsmodell](docs/SECURITY.md)
- [Disaster Recovery](docs/DISASTER_RECOVERY.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) - [Protokoll des Athena-Leerhostaufbaus](docs/ATHENA_REBUILD_LOG.md)
- [Komponenten](docs/COMPONENTS.md) - [Komponenten](docs/COMPONENTS.md)
+121
View File
@@ -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.
+7
View File
@@ -61,6 +61,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
- [ ] GitHub- und Hugging-Face-Routing geprüft - [ ] GitHub- und Hugging-Face-Routing geprüft
- [ ] Home Assistant read-only Diagnose geprüft - [ ] Home Assistant read-only Diagnose geprüft
- [ ] ARR read-only Suche 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 - [ ] Unraid read-only Diagnose geprüft
- [ ] schreibende Werkzeuge standardmäßig nicht geladen - [ ] schreibende Werkzeuge standardmäßig nicht geladen
- [ ] Tool-Schemas bleiben innerhalb des festgelegten Kontextbudgets - [ ] 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 - [ ] kein allgemeiner Shell-MCP im Standardprofil
- [ ] Schreibaktionen verlangen Vorschau und Approval Ticket - [ ] Schreibaktionen verlangen Vorschau und Approval Ticket
- [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt - [ ] 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 ## 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 Erst nach Abschluss aller Pflichtpunkte darf der alte Host gelöscht oder als
Fallback außer Betrieb genommen werden. Fallback außer Betrieb genommen werden.
Der ausführbare Ablauf steht in [BARE_METAL_RECOVERY.md](BARE_METAL_RECOVERY.md).
+5
View File
@@ -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 benötigen später den authentifizierten WireGuard-Gateway und dürfen nicht
direkt auf das interne Werkzeugnetz zugreifen. 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, 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, 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`. Dateinamen und SHA256 stehen vollständig in `config/install.env.example`.
+9 -2
View File
@@ -90,10 +90,16 @@ Benötigt wird ein festes Verfahren für:
- Home-Assistant-Token - Home-Assistant-Token
- Sonarr-/Radarr-API-Schlüssel - Sonarr-/Radarr-API-Schlüssel
- Unraid-Zugang - Unraid-Zugang
- Navidrome-Benutzer und Last.fm API-Key
- optionale GitHub-, Hugging-Face- und Brave-Schlüssel - optionale GitHub-, Hugging-Face- und Brave-Schlüssel
- SSH-Hostschlüssel und bekannte Hosts - 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 - verschlüsseltes Backupformat, beispielsweise age oder ein Passwortmanager
- Besitzer und Rechte je Environment-Datei - Besitzer und Rechte je Environment-Datei
@@ -186,7 +192,8 @@ run-acceptance-tests
``` ```
`platform/mcp/install-tools.sh` installiert den Webbereich automatisch und `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 `platform/migration/restore-reference-backup.sh` importiert eine bestehende
OpenWebUI-Datenbank und ausschließlich die freigegebenen Tool-Secrets, ohne OpenWebUI-Datenbank und ausschließlich die freigegebenen Tool-Secrets, ohne
experimentelle Altcontainer zurückzubringen. Der Leerhost-Probelauf wird auf experimentelle Altcontainer zurückzubringen. Der Leerhost-Probelauf wird auf
+4 -1
View File
@@ -67,7 +67,7 @@ install_base_packages() {
apt-get update apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ 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() { 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" install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$XTTS_CACHE_DIR" "$STATE_DIR/backups"
rsync -a --delete --exclude .git --exclude '*.local.*' \ rsync -a --delete --exclude .git --exclude '*.local.*' \
--exclude config/install.env "$ROOT_DIR/" "$STACK_DIR/" --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" install -d -m 0700 "$SECRETS_DIR"
[[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key [[ -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 [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token
+96
View File
@@ -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"
+78
View File
@@ -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" <<EOF
created_utc=$(date -u +%FT%TZ)
hostname=$(hostname)
source_commit=$source_commit
openwebui_volume=$OPENWEBUI_VOLUME
EOF
(
cd "$stage/payload"
sha256sum host-config.tar.gz openwebui-data.tar.gz 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"
+91
View File
@@ -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"