Add encrypted bare-metal recovery workflow
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
Executable
+96
@@ -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"
|
||||
Executable
+78
@@ -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"
|
||||
Executable
+91
@@ -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"
|
||||
Reference in New Issue
Block a user