From 5bc30c950c252e9429cfc383992dbb28d5459b23 Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 19:39:04 +0200 Subject: [PATCH] feat(infra): migrate self-hosted backend to netralax.de Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.release.example | 6 +- apps/desktop/package.json | 2 +- apps/desktop/src/lib/changelog.ts | 8 +- docs/migration-netralax-de.md | 733 ++++++++++++++++++ infra/caddy/Caddyfile | 91 +++ infra/livekit/coturn.prod.conf.example | 50 ++ infra/livekit/docker-compose.prod.yml.example | 41 + infra/livekit/livekit.prod.yaml.example | 41 + scripts/migrate/01-bootstrap-new-server.sh | 244 ++++++ scripts/migrate/02-migrate-data.sh | 207 +++++ scripts/migrate/03-copy-secrets.mjs | 189 +++++ scripts/migrate/README.md | 132 ++++ scripts/migrate/config.sh | 51 ++ scripts/prod/README.md | 5 +- scripts/prod/config.sh | 18 +- 15 files changed, 1807 insertions(+), 11 deletions(-) create mode 100644 docs/migration-netralax-de.md create mode 100644 infra/caddy/Caddyfile create mode 100644 infra/livekit/coturn.prod.conf.example create mode 100644 infra/livekit/docker-compose.prod.yml.example create mode 100644 infra/livekit/livekit.prod.yaml.example create mode 100644 scripts/migrate/01-bootstrap-new-server.sh create mode 100644 scripts/migrate/02-migrate-data.sh create mode 100644 scripts/migrate/03-copy-secrets.mjs create mode 100644 scripts/migrate/README.md create mode 100644 scripts/migrate/config.sh diff --git a/.env.release.example b/.env.release.example index 618426c..0ffe8d4 100644 --- a/.env.release.example +++ b/.env.release.example @@ -7,7 +7,11 @@ # stopped publishing Tauri releases to this host. # Host serving latest.yml + installer artifacts over HTTPS. -UPDATE_HOST=update.netralax.cloud +# NOTE: during the .cloud→.de transition the new VPS must ALSO serve the same +# artifacts under update.netralax.cloud (point its DNS at the new IP) so that +# already-installed clients — which have update.netralax.cloud baked in — can +# still pull the release that switches them over to .de. +UPDATE_HOST=update.netralax.de # SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH. UPDATE_SSH_USER=chatapp-deploy diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cc6dec6..20a9656 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -95,7 +95,7 @@ "publish": [ { "provider": "generic", - "url": "https://update.netralax.cloud/windows/" + "url": "https://update.netralax.de/windows/" } ] } diff --git a/apps/desktop/src/lib/changelog.ts b/apps/desktop/src/lib/changelog.ts index ba6951f..209b553 100644 --- a/apps/desktop/src/lib/changelog.ts +++ b/apps/desktop/src/lib/changelog.ts @@ -1,11 +1,15 @@ // In-app changelog feed. // // The release script (`scripts/release.mjs`) maintains a single -// `changelog.json` file alongside `latest.json` on update.netralax.cloud. +// `changelog.json` file alongside `latest.json` on update.netralax.de. // The list is newest-first, capped at 200 entries server-side, and rewritten // after every release. +// +// NOTE: already-installed clients still fetch this from update.netralax.cloud +// (baked into their bundle), so Caddy on the new VPS must keep serving the +// update.netralax.cloud vhost from the same directory during the transition. -const CHANGELOG_URL = 'https://update.netralax.cloud/windows/changelog.json'; +const CHANGELOG_URL = 'https://update.netralax.de/windows/changelog.json'; export interface ChangelogEntry { version: string; diff --git a/docs/migration-netralax-de.md b/docs/migration-netralax-de.md new file mode 100644 index 0000000..8095893 --- /dev/null +++ b/docs/migration-netralax-de.md @@ -0,0 +1,733 @@ +# Migrations-Runbook: Self-Hosted Backend von `*.netralax.cloud` auf `*.netralax.de` (neuer VPS) + +> **Zweck:** Vollständiger Umzug des selbstgehosteten Chat-Backends (Supabase + LiveKit/coturn + Update-Host) vom ALTEN VPS (`46.225.156.249`, `*.netralax.cloud`) auf einen FRISCHEN, leeren NEUEN VPS, der danach `*.netralax.de` UND – während der Übergangsphase – weiterhin `*.netralax.cloud` ausliefert. +> +> **Lesbar als:** Copy-paste-Runbook. Überschriften und Erklärungen sind deutsch; alle Befehle, Pfade, Variablennamen und Konfig-Snippets bleiben wörtlich/literal. + +--- + +## ⚠️ Zwei nicht verhandelbare Kontinuitäts-Garantien (vor allem anderen lesen) + +Die bereits installierten Desktop- (Vite/electron) und Mobile- (Expo) Clients tragen die ALTEN Hostnamen **und** den anon-JWT **fest im Bundle einkompiliert** (`SUPABASE_URL`, `LIVEKIT_URL`, `SUPABASE_ANON_KEY`, `VITE_VAPID_PUBLIC_KEY`, Update-Host). Daraus folgen zwei Garantien, deren Verletzung **alle bestehenden Installationen sofort und lautlos zerstört**: + +1. **`JWT_SECRET` (und damit `ANON_KEY`, `SERVICE_ROLE_KEY`) MÜSSEN byte-für-byte vom ALTEN Server übernommen werden.** Der anon-JWT in den Bundles ist mit dem alten `JWT_SECRET` signiert. Ein anderes Secret → Kong/PostgREST/GoTrue verwerfen **jedes** Token → **alle** Sessions fallen aus, niemand kann sich mehr anmelden. Es gibt keine Fehlermeldung, die das offensichtlich macht. +2. **Das VAPID-Schlüsselpaar (`VAPID_PUBLIC_KEY` + `VAPID_PRIVATE_KEY`) MUSS identisch übernommen werden.** Bestehende Web-Push-Subscriptions sind an den öffentlichen VAPID-Key gebunden. Ändert er sich, brechen **alle** vorhandenen Push-Abos – Benachrichtigungen verstummen lautlos. + +Zusätzlich: Der NEUE Caddy **muss die Legacy-Vhosts `*.netralax.cloud` mitbedienen** und die `.cloud`-DNS-A-Records müssen auf die NEUE IP zeigen, sonst sterben alte Clients in dem Moment, in dem der alte VPS abgeschaltet wird. + +--- + +## 0. Voraussetzungen & Übersicht + +### 0.1 Architektur (unverändert auf beiden Servern) + +| Komponente | Verzeichnis | Intern | Öffentlich (neu) | Öffentlich (Legacy, weiter bedient) | +|---|---|---|---|---| +| Supabase (Postgres 17, GoTrue, PostgREST, Realtime, Storage, Kong, edge-runtime, Mailpit) | `/opt/supabase` | Kong `127.0.0.1:8000` | `supabase.netralax.de` | `supabase.netralax.cloud` | +| LiveKit SFU (Signaling-WS) | `/opt/livekit` | `127.0.0.1:7880` | `livekit.netralax.de` | `livekit.netralax.cloud` | +| coturn (TURN/TURNS) | `/opt/livekit` | `:3478`, `:5349` (TLS) | `turn.netralax.de:5349` | `turn.netralax.cloud:5349` | +| Update-Host (electron-updater) | `/var/www/updates/windows` | `file_server` | `update.netralax.de` | `update.netralax.cloud` | + +TLS-Terminierung für Supabase/LiveKit/Update via **Caddy** (automatisches Let's Encrypt). **TURNS auf `5349` läuft NICHT über Caddy** und braucht ein eigenes Zertifikat auf der Platte. + +### 0.2 Was du brauchst + +- SSH-Zugang: User `prox` auf dem **alten** (.cloud) VPS, User `debian` auf dem **neuen** (.de) VPS. Der neue VPS ist leer. +- Die NEUE öffentliche IP des `.de`-VPS: **`141.95.34.204`** (bereits in `scripts/migrate/config.sh` → `NEW_HOST` und `scripts/prod/config.sh` → `PROD_SERVER` eingetragen). Login-User: `debian`. +- Lese-Zugriff auf die ALTE `/opt/supabase/.env` (enthält alle zu kopierenden Secrets). +- DNS-Verwaltung für `netralax.de` **und** `netralax.cloud`. +- Entwickler-Laptop mit Bash (Linux/macOS/WSL), `ssh`, `rsync`, `openssl`. +- Ein Wartungsfenster (Schreibstopp auf der App), siehe Abschnitt 5. + +### 0.3 Reihenfolge der Arbeit (Überblick) + +``` +1. DNS vorbereiten (niedrige TTL setzen, noch NICHT umbiegen) +2. Neuen VPS bootstrappen → scripts/migrate/01-bootstrap-new-server.sh +3. Secrets 1:1 in /opt/supabase/.env übernehmen (JWT_SECRET/VAPID identisch!) +4. Stacks LEER hochfahren (init der Rollen) +5. Wartungsfenster: DB + Storage migrieren → scripts/migrate/02-migrate-data.sh +6. LiveKit/coturn Prod-Config + Firewall + TURNS-Zertifikat +7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud) +8. Edge-Functions + deren Secrets deployen +9. Update-Host migrieren + Dual-Publish (.de UND .cloud) +10. Cutover: DNS scharf schalten (alle .de + Repoint aller .cloud) +11. Smoke-Tests (inkl. ALTER .cloud-Client) +12. Repo-Edits + neues Desktop-/Mobile-Release ausliefern +13. Rollback-Plan (bereithalten) +14. Aufräumen / .cloud später abschalten +``` + +### 0.4 Konventionen der Migrate-Skripte (Interface-Contract) + +Alle `scripts/migrate/*`-Skripte sourcen `scripts/migrate/config.sh`. Dieses kennt **beide** Hosts und ist bewusst **unabhängig** von `scripts/prod/config.sh` (das bereits auf den End-Zustand `.de` zeigt). `config.sh` exportiert: + +```bash +OLD_HOST="46.225.156.249" +OLD_USER="prox" +NEW_HOST="141.95.34.204" +NEW_USER="debian" # neuer .de-Server: User debian (alt: prox) +SUPABASE_DIR="/opt/supabase" +LIVEKIT_DIR="/opt/livekit" +OLD_SSH="${OLD_USER}@${OLD_HOST}" +NEW_SSH="${NEW_USER}@${NEW_HOST}" +SSH_OPTS="-o StrictHostKeyChecking=accept-new" +``` + +…und die Helfer `old_remote()` / `new_remote()`, die per `ssh ${SSH_OPTS}` zum jeweiligen Host verbinden. + +--- + +## 1. DNS-Plan + +> **Wichtig:** In diesem Schritt wird DNS **noch nicht** umgebogen (außer der TTL-Absenkung). Das eigentliche Scharfschalten passiert erst im **Cutover (Abschnitt 10)**, wenn der neue VPS vollständig steht und getestet ist. + +### 1.1 Jetzt (Vorbereitung): TTL absenken + +Setze auf **allen** unten genannten A-Records die TTL auf **300 Sekunden (5 min)**, mindestens 24–48 h vor dem geplanten Cutover. So wird die spätere Umstellung schnell wirksam. + +### 1.2 Beim Cutover (Abschnitt 10): A-Records auf `141.95.34.204` + +**Neue `.de`-Records (anlegen):** + +| Record | Typ | Ziel | +|---|---|---| +| `supabase.netralax.de` | A | `141.95.34.204` | +| `livekit.netralax.de` | A | `141.95.34.204` | +| `turn.netralax.de` | A | `141.95.34.204` | +| `update.netralax.de` | A | `141.95.34.204` | + +**Legacy `.cloud`-Records (REPOINT von alter IP `46.225.156.249` auf neue IP):** + +| Record | Typ | Neues Ziel | +|---|---|---| +| `supabase.netralax.cloud` | A | `141.95.34.204` | +| `livekit.netralax.cloud` | A | `141.95.34.204` | +| `turn.netralax.cloud` | A | `141.95.34.204` | +| `update.netralax.cloud` | A | `141.95.34.204` | + +> **⚠️ Den Repoint der `.cloud`-Records NICHT vergessen.** Alle bereits installierten Clients sprechen `*.netralax.cloud` an. Bleiben diese Records auf der alten IP, brechen sämtliche Installationen, sobald der alte VPS abgeschaltet wird. Der neue Caddy bedient die `.cloud`-Vhosts mit (Abschnitt 7), und Let's Encrypt stellt für `.cloud` erst dann gültige Zertifikate aus, **wenn** die `.cloud`-A-Records auf die neue IP zeigen. + +### 1.3 Verifikation nach dem Cutover + +```bash +for h in supabase livekit turn update; do + echo "== $h.netralax.de =="; dig +short $h.netralax.de + echo "== $h.netralax.cloud =="; dig +short $h.netralax.cloud +done +``` + +Alle acht müssen `141.95.34.204` zurückgeben. + +--- + +## 2. Neuen Server bootstrappen + +Das Skript **`scripts/migrate/01-bootstrap-new-server.sh`** wird **auf den neuen VPS kopiert und dort als root** ausgeführt. Es ist idempotent, erfindet **keine** Secrets und gibt am Ende klare NEXT-STEP-Hinweise. + +### 2.1 Skript übertragen und ausführen + +```bash +# Vom Laptop aus: +scp -o StrictHostKeyChecking=accept-new \ + scripts/migrate/01-bootstrap-new-server.sh \ + debian@141.95.34.204:/tmp/ + +ssh -o StrictHostKeyChecking=accept-new debian@141.95.34.204 \ + 'sudo bash /tmp/01-bootstrap-new-server.sh' +``` + +### 2.2 Was das Bootstrap-Skript tut + +- Installiert **Docker Engine + compose-plugin**. +- Installiert + aktiviert **ufw** und öffnet die Ports (siehe Abschnitt 6 für die vollständige Liste): `22/tcp`, `80/tcp`, `443/tcp`, `7880/tcp`, `7881/tcp`, `50000:50100/udp`, `3478/tcp`, `3478/udp`, `5349/tcp`, `50200:50300/udp`. +- Klont `https://github.com/supabase/supabase` und kopiert `supabase/docker/` nach **`/opt/supabase`** (inkl. `docker-compose.yml`, `volumes/`, `.env.example`). Hinweis: Wir vendoren die Supabase-Compose-Datei **nicht** im Repo – sie wird beim Bootstrap frisch geklont. +- Legt **`/opt/livekit`** an und schreibt Platzhalter `docker-compose.yml` + `livekit.yaml` + `coturn.conf`. +- Installiert **Caddy** und legt eine Platzhalter-`/etc/caddy/Caddyfile` an. +- Legt **`/var/www/updates/windows`** an (Artefakt-Verzeichnis; Caddy-Docroot ist das Eltern-Verzeichnis `/var/www/updates`, siehe §7). +- Setzt in `/opt/supabase/.env` die sicherheitskritischen Secrets (`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD`, …) auf den Sentinel `__COPY_FROM_OLD_SERVER__`, damit ein vergessener Wert **laut scheitert** statt still die öffentlich bekannten Upstream-Defaults zu benutzen. +- Druckt am Ende die NEXT-STEPS: `/opt/supabase/.env` befüllen (Abschnitt 3), Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen (Abschnitt 6), `Caddyfile` einsetzen (Abschnitt 7). + +> **Das Bootstrap-Skript erfindet KEINE Secrets.** Die sicherheitskritischen Keys stehen danach auf dem Sentinel `__COPY_FROM_OLD_SERVER__` (fail-loud); Custom-Secrets wie `VAPID_*` / `PUSH_FANOUT_SHARED_SECRET` sind im Upstream-`.env` gar nicht vorhanden und müssen ergänzt werden. Alle echten Werte kommen in Abschnitt 3 vom alten Server. + +--- + +## 3. Secrets 1:1 übernehmen + +Alle Server-Secrets leben auf dem Server in **`/opt/supabase/.env`**. Hole zuerst die ALTE Datei: + +```bash +# ALTE .env lokal sichern (nur lesend, nichts ändern): +ssh -o StrictHostKeyChecking=accept-new prox@46.225.156.249 \ + 'cat /opt/supabase/.env' > old.env.backup +chmod 600 old.env.backup +``` + +### 3.1 Entscheidungstabelle: identisch kopieren vs. auf neuen Host umstellen + +**Spalte „Aktion": `IDENTISCH` = byte-für-byte aus `old.env.backup` übernehmen; `NEU` = auf den neuen Host/Wert setzen.** + +| Variable | Aktion | Woher / Neuer Wert | Begründung | +|---|---|---|---| +| `POSTGRES_PASSWORD` | **IDENTISCH** | old.env | Dump trägt Rollen-Passwort-Hashes; muss vor Restore passen, sonst können interne Dienste sich nicht an Postgres anmelden. | +| `JWT_SECRET` | **🔴 IDENTISCH** | old.env | **Signiert die eingebackenen anon/service-role-JWTs. Abweichung = alle Sessions tot.** | +| `ANON_KEY` | **🔴 IDENTISCH** | old.env | Eingebackener anon-JWT der Clients. | +| `SERVICE_ROLE_KEY` | **IDENTISCH** | old.env | service-role-JWT für Edge-Functions/Admin-Skripte; muss zu `JWT_SECRET` passen. | +| `SECRET_KEY_BASE` | **IDENTISCH** | old.env | Realtime (Phoenix) + Vault: signiert Channel-Tokens/Cookies. | +| `VAULT_ENC_KEY` | **IDENTISCH** | old.env | Entschlüsselt vault/pgsodium-verschlüsselte Zeilen aus dem Dump. | +| `PG_META_CRYPTO_KEY` | **IDENTISCH** | old.env | postgres-meta-Crypto-Key; stabil halten. | +| `SMTP_HOST` | **IDENTISCH** | old.env | Magic-Link-Mailversand erhalten (externes Relay / Mailpit). | +| `SMTP_PORT` | **IDENTISCH** | old.env | s.o. | +| `SMTP_USER` | **IDENTISCH** | old.env | s.o. | +| `SMTP_PASS` | **IDENTISCH** | old.env | s.o. | +| `SMTP_ADMIN_EMAIL` | **IDENTISCH** | old.env | Absender/SPF-Konsistenz. | +| `SMTP_SENDER_NAME` | **IDENTISCH** | old.env | Anzeigename konsistent. | +| `FUNCTIONS_VERIFY_JWT` | **IDENTISCH** | old.env (`false`) | `notify-push` nutzt Shared-Secret-Header statt User-JWT; bleibt `false`. | +| `LIVEKIT_API_KEY` | **IDENTISCH** | old.env | Muss = `keys:`-Block in `livekit.prod.yaml`, sonst SFU-Reject (403). | +| `LIVEKIT_API_SECRET` | **IDENTISCH** | old.env | s.o. | +| `VAPID_PUBLIC_KEY` | **🔴 IDENTISCH** | old.env | **Bindet bestehende Push-Abos. Abweichung = alle Push-Subscriptions tot.** | +| `VAPID_PRIVATE_KEY` | **🔴 IDENTISCH** | old.env | Muss mit unverändertem Public-Key paaren. | +| `VAPID_SUBJECT` | **IDENTISCH** | old.env | Konsistenz (mailto/URL). | +| `PUSH_FANOUT_SHARED_SECRET` | **IDENTISCH** | old.env | `x-shared-secret`-Header zwischen DB-Trigger und `notify-push`. | +| `SUPABASE_SERVICE_ROLE_KEY` | **IDENTISCH** | = `SERVICE_ROLE_KEY` | Edge-Function-Alias. | +| `SUPABASE_ANON_KEY` | **IDENTISCH** | = `ANON_KEY` | Edge-Function-Alias (mint-livekit-token RLS-Client). | +| `SITE_URL` | **NEU** | `https://supabase.netralax.de` | GoTrue-Basis-URL für Magic-Link-Redirects. | +| `API_EXTERNAL_URL` | **NEU** | `https://supabase.netralax.de` | Öffentliche Kong-URL, die GoTrue/Studio bewerben. | +| `SUPABASE_PUBLIC_URL` | **NEU** | `https://supabase.netralax.de` | Studio/Kong-Asset-/Link-Generierung. | +| `ADDITIONAL_REDIRECT_URLS` | **NEU** (Superset) | siehe 3.2 | GoTrue-Redirect-Allow-List inkl. Deep-Link-Schemata. | +| `SUPABASE_URL` (Edge-Function) | **NEU** | `https://supabase.netralax.de` (oder internes Kong) | Funktionen müssen es nur erreichen. | +| `LIVEKIT_URL` (Edge-Function) | **NEU** | `wss://livekit.netralax.de` | wss-URL für neue Builds; alte Clients nutzen `.cloud` (vom neuen Caddy mitbedient). | +| `DASHBOARD_USERNAME` | **NEU** | frei wählbar | Studio-Basic-Auth; nicht client-kritisch. | +| `DASHBOARD_PASSWORD` | **NEU** | starkes neues Passwort | s.o. | +| `POSTGRES_HOST` | Default | `db` | nicht host-spezifisch. | +| `POSTGRES_DB` | Default | `postgres` | s.o. | +| `POSTGRES_PORT` | Default | `5432` (nur an localhost gebunden) | s.o. | +| `KONG_HTTP_PORT` | Default | `8000` | muss zum Caddyfile passen. | +| `KONG_HTTPS_PORT` | Default | `8443` (ungenutzt) | Caddy terminiert TLS. | + +### 3.2 `ADDITIONAL_REDIRECT_URLS` (exakt, ohne Leerzeichen) + +``` +ADDITIONAL_REDIRECT_URLS=chatapp://auth/callback,netralax://auth/callback,https://supabase.netralax.de,https://supabase.netralax.cloud +``` + +> **⚠️ GoTrue lehnt jeden Magic-Link-Redirect ab, der nicht exakt auf der Allow-List steht.** Beide Deep-Link-Schemata (`chatapp://auth/callback` **und** `netralax://auth/callback`) müssen drin sein, sonst scheitert der Native-App-Login. + +### 3.3 Werte übertragen + +Bearbeite `/opt/supabase/.env` auf dem neuen Server und setze die `IDENTISCH`-Werte aus `old.env.backup`, die `NEU`-Werte aus der Tabelle: + +```bash +ssh debian@141.95.34.204 'sudo nano /opt/supabase/.env' +``` + +> **Reihenfolge-Falle:** `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD` und das VAPID-Paar müssen in der `.env` stehen, **bevor** in Abschnitt 4 der Stack hochfährt und **bevor** in Abschnitt 5 der Restore läuft. Setze sie jetzt vollständig. + +### 3.4 Verifikation (Hashes vergleichen, nicht Klartext loggen) + +```bash +# Stelle sicher, dass die kritischen Secrets identisch sind: +for v in JWT_SECRET ANON_KEY SERVICE_ROLE_KEY POSTGRES_PASSWORD VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY; do + old=$(ssh prox@46.225.156.249 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum) + new=$(ssh debian@141.95.34.204 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum) + [ "$old" = "$new" ] && echo "OK $v" || echo "DIFF $v <-- FIX BEFORE RESTORE" +done +``` + +Jede Zeile muss `OK` sein. + +--- + +## 4. Stacks leer hochfahren + +Bevor Daten restauriert werden, muss der frische Supabase-Stack **einmal** hochfahren, damit die Init-Skripte die Rollen anlegen (`supabase_admin`, `authenticator`, `anon`, `authenticated`, `service_role`, `supabase_auth_admin`, `supabase_storage_admin`, …), Extensions und Grants. Voraussetzung: `POSTGRES_PASSWORD` und `JWT_SECRET` sind bereits identisch gesetzt (Abschnitt 3). + +```bash +# DB-Container zuerst hochfahren (legt Rollen + Extensions an): +ssh debian@141.95.34.204 \ + 'cd /opt/supabase && docker compose up -d db && sleep 20' + +# Health-Check: +ssh debian@141.95.34.204 \ + 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres' +``` + +> Den **vollständigen** Stack (`docker compose up -d`) fahren wir erst **nach** dem Daten-Restore hoch (Abschnitt 5, Schritt 3), damit alle Dienste gegen die wiederbefüllte DB neu verbinden. + +--- + +## 5. Datenmigration: DB + Storage + +Genutzt wird **`scripts/migrate/02-migrate-data.sh`** (läuft vom Laptop, sourct `config.sh`, `set -euo pipefail`, jeder destruktive Schritt ist abgesichert). + +### 5.1 🔴 Wartungsfenster: Schreibstopp ZUERST + +> **Friere Schreibvorgänge ein, bevor du dumpst und bevor du Storage rsyncst.** Sonst werden DB-Zeilen und Storage-Volume inkonsistent (Objekte auf der Platte ohne Metadaten-Zeile – oder umgekehrt). Setze die App in Wartungsmodus / stoppe neue Uploads/Nachrichten auf dem ALTEN System. + +Pre-Flight (beide Stacks gesund): + +```bash +ssh prox@46.225.156.249 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres' +ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres' +``` + +### 5.2 Postgres (Major-Version 17) – `pg_dumpall`, gestreamt ALT → NEU + +Faithful Full-Cluster-Dump (Rollen **inkl. Passwort-Hashes** + alle DBs + auth/storage/realtime/public-Schemata), direkt vom alten in den neuen Container gestreamt: + +```bash +old_remote 'cd /opt/supabase && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists' \ + | new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0' +``` + +Wichtige Hinweise zu diesem Befehl: + +- **`pg_dumpall` (nicht `pg_dump`)** ist nötig, weil es die ROLLEN-Definitionen samt Passwort-Hashes (md5/scram) mitnimmt. Da `POSTGRES_PASSWORD` auf beiden Hosts identisch ist, passen die restaurierten Rollen-Passwörter zu dem, was die Dienste benutzen. +- **`--clean --if-exists`** macht den Dump gegen den bereits initialisierten Cluster wiederholbar (droppt/erzeugt Objekte neu). +- **`ON_ERROR_STOP=0` (nicht `=1`):** `pg_dumpall` versucht, bereits existierende Rollen wie `supabase_admin`/`postgres` per `CREATE ROLE` anzulegen → harmlose „already exists"-Fehler. Mit `ON_ERROR_STOP=1` würde der erste davon einen guten Restore abbrechen. `=0` schluckt aber **auch echte Fehler** (FK/Constraint/Ownership) und hinterlässt eine teil-restaurierte DB, die „erfolgreich" aussieht. **Deshalb scannt `02-migrate-data.sh` den Restore automatisch:** es teet die Ausgabe in ein Log, grept nach `ERROR/FATAL/PANIC` abzüglich der harmlosen Muster und **bricht VOR dem Storage-rsync ab**, falls echte Fehler übrig bleiben (bewusster Override: `FORCE_RESTORE_OK=1`). +- Erfasst in einem Rutsch **alle** Schemata: `auth` (User/Identities/Sessions), `storage` (Buckets + Objekt-Metadaten), `realtime` (Tenants/Subscriptions), `public` (App-Tabellen), ggf. `_realtime`/`_analytics`. + +> **🔴 Migrationen NICHT erneut anwenden.** Alle Migrationen stecken bereits im Dump. **`scripts/prod/push-migrations.sh` nach dem Restore NICHT ausführen** – das riskiert Drift/Duplicate-Object-Fehler. + +**Alternative (nur falls Cluster-Level scheitert):** Single-DB `pg_dump -Fc` + `pg_restore --clean --if-exists --no-owner`, plus separat `pg_dumpall --roles-only`. Der `pg_dumpall`-Pfad oben ist für self-hosted→self-hosted vorzuziehen. + +### 5.3 Vollständigen Stack neu hochfahren + +```bash +new_remote 'cd /opt/supabase && docker compose down && docker compose up -d' +``` + +### 5.4 Storage-Objekte – `rsync` (ALT → NEU) + +Die Objekt-Bytes liegen unter `/opt/supabase/volumes/storage` (Bind-Mount → Container `/var/lib/storage`); die Metadaten-Zeilen kamen bereits mit dem Dump. **Schreibstopp muss noch aktiv sein.** Trailing-Slashes beachten: + +```bash +# Direkt ALT -> NEU (Daten fließen Server-zu-Server, wenn alt den neuen erreicht): +old_remote "sudo rsync -aHAX --numeric-ids --delete \ + -e 'ssh -o StrictHostKeyChecking=accept-new' \ + /opt/supabase/volumes/storage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/" +``` + +Falls die Server sich gegenseitig **nicht** per SSH erreichen, zwei-stufig über den Laptop: + +```bash +rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" ${OLD_USER}@${OLD_HOST}:/opt/supabase/volumes/storage/ ./_storage_stage/ +rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" ./_storage_stage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/ +``` + +- `-aHAX` erhält Hardlinks/ACLs/xattrs; `--delete` macht das Ziel zum exakten Spiegel (**nur sicher bei eingefrorenen Schreibvorgängen**). + +Danach Storage-Service neu starten, damit die UID-/Ownership-Erwartung passt: + +```bash +new_remote 'cd /opt/supabase && docker compose restart storage imgproxy' +``` + +### 5.5 Daten-Verifikation + +```bash +# Tabellen-/User-Counts vergleichen (Beispiel): +new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres \ + -c "select count(*) as users from auth.users;" \ + -c "select count(*) as objects from storage.objects;"' +``` + +`02-migrate-data.sh` macht zusätzlich eine **Zeilen-Paritätsprüfung OLD vs NEU** über tragende Tabellen (`auth.users`, `auth.identities`, `public.profiles`, `public.messages`, `public.conversation_members`, `storage.objects`) und meldet jede Abweichung — eine reine User-/Objekt-Zählung würde Teilverluste in `messages`/`members` übersehen. Ein bekanntes Objekt sollte zudem über das neue Gateway ladbar sein (Test nach Caddy-Setup, Abschnitt 11). + +> **Cold-Volume-Copy-Alternative:** Nur falls Image-Tags byte-identisch sind, kann man statt Logical-Dump **beide** DBs stoppen und `volumes/db/data` (PGDATA) **plus** das `db-config`-Named-Volume (enthält den pgsodium-Key) rsyncen. Nur mit gestoppten DBs und identischen Postgres-Image-Tags; ansonsten den Logical-Dump oben bevorzugen. + +--- + +## 6. LiveKit/coturn Prod-Config + Firewall-Ports + TURNS-Zertifikat + +> **Die Prod-Config unterscheidet sich von der Dev-`infra/livekit/livekit.yaml` im Repo.** Prod setzt `rtc.use_external_ip: true` und enthält **KEIN** `node_ip: 127.0.0.1` (das ist Dev-only). + +> **🟢 Sicherster Weg — die ALTE, funktionierende Config übernehmen.** Die `.example`-Templates sind eine Referenz; produktiv erprobt ist aber die Config, die auf dem alten Server **bereits läuft**. Hol dir die echten Dateien vom alten VPS und ändere nur das Nötigste — so bleibt insbesondere erhalten, **wie** den Clients die TURN-Server/ICE-Credentials angekündigt werden (das macht der alte `livekit.yaml`-`turn:`/`rtc:`-Block bzw. die coturn-`user=`-Zeile; `mint-livekit-token` liefert nur LiveKit-URL+Token, nicht die TURN-Creds): +> ```bash +> # vom Laptop: +> scp prox@46.225.156.249:/opt/livekit/livekit.yaml ./_livekit_old.yaml +> scp prox@46.225.156.249:/opt/livekit/coturn.conf ./_coturn_old.conf +> # dann NUR anpassen: external-ip (neue IP), cert/pkey-Pfade (turn.netralax.de), +> # und — falls vorhanden — eine externe IP/Domain im livekit.yaml turn-Block. +> # Danach als /opt/livekit/{livekit.yaml,coturn.conf} auf den neuen Server. +> ``` +> Wenn die alten Dateien nicht greifbar sind, nutze die Templates unten und stelle sicher, dass die coturn-`user=`-Credentials zu dem passen, was deine Clients heute für TURN verwenden. + +### 6.1 Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen + +Auf dem alten Server lief LiveKit/coturn über ein Compose in `/opt/livekit`. Das Repo liefert dafür **`infra/livekit/docker-compose.prod.yml.example`** (die Dev-`infra/livekit/docker-compose.yml` ist **nicht** prod-tauglich: coturn läuft dort mit `--no-tls`, ohne `5349`, ohne Zertifikat). Drei Dateien auf den Server kopieren — die **on-server-Namen** sind bewusst `livekit.yaml` / `coturn.conf` (genau die, die auch `scripts/prod/rotate-livekit-keys.sh` editiert): + +| Repo-Template | → on-server | +|---|---| +| `infra/livekit/docker-compose.prod.yml.example` | `/opt/livekit/docker-compose.yml` | +| `infra/livekit/livekit.prod.yaml.example` | `/opt/livekit/livekit.yaml` | +| `infra/livekit/coturn.prod.conf.example` | `/opt/livekit/coturn.conf` | + +`keys:`-Block in **`/opt/livekit/livekit.yaml`** mit den Werten aus Abschnitt 3 (`LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`) füllen: + +```yaml +port: 7880 +log_level: info + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + use_external_ip: true + # KEIN node_ip: 127.0.0.1 — das ist dev-only und würde alle Remote-Clients + # ihre Medien an den eigenen Loopback schicken lassen (Call ohne Audio/Video). + +keys: + __LIVEKIT_API_KEY__: __LIVEKIT_API_SECRET__ + +turn: + enabled: false # coturn läuft separat +``` + +> **🔴 `node_ip: 127.0.0.1` aus der Dev-Config NICHT übernehmen.** Sonst verbinden Calls zwar, haben aber **keinen Ton und kein Bild**, weil jeder Remote-Client Medien an seinen eigenen Loopback sendet. +> +> **🔴 `LIVEKIT_API_KEY`/`SECRET` im `keys:`-Block MÜSSEN exakt den Edge-Function-Werten in `/opt/supabase/.env` entsprechen.** Sonst signiert `mint-livekit-token` Tokens, die der SFU mit 403 ablehnt. + +### 6.2 coturn Prod-Config einsetzen + +Template: **`infra/livekit/coturn.prod.conf.example`** → **`/opt/livekit/coturn.conf`**. Die Zertifikatspfade zeigen auf `/etc/letsencrypt/...` — genau das Verzeichnis, das das Prod-Compose read-only in den coturn-Container einhängt: + +```conf +realm=netralax.de +listening-port=3478 +tls-listening-port=5349 +external-ip=141.95.34.204 +min-port=50200 +max-port=50300 +cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem +pkey=/etc/letsencrypt/live/turn.netralax.de/privkey.pem +lt-cred-mech +user=__TURN_USER__:__TURN_PASSWORD__ +fingerprint +no-multicast-peers +``` + +### 6.3 TURNS-Zertifikat für `turn.netralax.de` (NICHT über Caddy) + +> **TURNS auf `5349` geht NICHT durch Caddy** – coturn braucht ein eigenes TLS-Cert+Key auf der Platte (`cert`/`pkey`-Pfade oben). Ein reines Caddy-Cert deckt das nicht ab. + +Zwei Wege, das Zertifikat bereitzustellen: + +**A) certbot standalone (empfohlen, einfachster Pfad).** Schreibt direkt nach `/etc/letsencrypt/live/turn.netralax.de/` — also genau die Pfade, die `coturn.conf` referenziert und die das Prod-Compose in den Container einhängt. Kein Kopieren nötig: + +```bash +# Port 80 muss kurz frei sein (Caddy ggf. stoppen oder DNS-01 nutzen): +sudo certbot certonly --standalone -d turn.netralax.de +# Renewal-Hook, damit coturn das erneuerte Cert lädt: +sudo certbot renew --deploy-hook 'docker compose -f /opt/livekit/docker-compose.yml restart turn' +``` + +**B) Caddy-Cert wiederverwenden.** Caddy hat ohnehin ein gültiges Cert für `turn.netralax.de`, sobald der DNS-Record steht und der Host in der Caddy-Config ist. PEM/Key aus Caddys Storage (`/var/lib/caddy/.local/share/caddy/certificates/...`) an die `/etc/letsencrypt/live/turn.netralax.de/`-Pfade symlinken/kopieren und coturn nach Renewals neu starten. Umständlicher als (A) — nur, wenn certbot nicht in Frage kommt. + +> coturn liest das Cert **beim Start**; nach jeder Erneuerung den `turn`-Container neu starten (Hook oben). Das `external-ip` muss die **neue** öffentliche IP sein. + +### 6.4 Firewall-Ports (ufw) – ALLE öffnen, sonst kein A/V + +> Diese Ports **umgehen Caddy** und müssen direkt in ufw offen sein. Fehlt einer, haben Calls **keinen Ton/kein Bild**. + +```bash +ssh debian@141.95.34.204 'sudo bash -s' <<'EOF' +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw allow 7880/tcp # LiveKit Signaling (hinter Caddy) +ufw allow 7881/tcp # RTC TCP-Fallback +ufw allow 50000:50100/udp # RTC Media +ufw allow 3478/udp # coturn STUN/TURN +ufw allow 3478/tcp # coturn STUN/TURN +ufw allow 5349/tcp # coturn TURNS (TLS) +ufw allow 50200:50300/udp # coturn TURN-Relay +ufw --force enable +ufw status verbose +EOF +``` + +> **Postgres NICHT öffentlich öffnen.** `5432` bleibt nur an `localhost` gebunden (wie auf dem alten Server). Für Remote-`psql` das bestehende Tunnel-Muster nutzen: `./scripts/prod/tunnel-db.sh` (SSH-Tunnel `localhost:5433 → server:5432`). + +### 6.5 LiveKit-Stack starten + +Voraussetzung: `/opt/livekit/docker-compose.yml` ist das **Prod**-Compose aus §6.1 (host-networking, mountet `livekit.yaml` + `coturn.conf` + `/etc/letsencrypt`), nicht das Dev-Compose. + +```bash +ssh debian@141.95.34.204 'cd /opt/livekit && docker compose up -d && docker compose ps' +# coturn lauscht jetzt auf 5349/TLS? prüfen: +ssh debian@141.95.34.204 'ss -tlnp | grep -E "5349|3478" ; docker compose -f /opt/livekit/docker-compose.yml logs turn --tail=20' +``` + +--- + +## 7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud Legacy) + +Template: **`infra/caddy/Caddyfile`** → auf dem Server `/etc/caddy/Caddyfile`. Caddy terminiert TLS (automatisches Let's Encrypt) und reverse-proxyt Klartext-HTTP an die lokalen Backends. **Pro Vhost genau EIN `reverse_proxy`** – Kong multiplext bereits alle Supabase-Routen; keine Pfad-Splits in Caddy. + +```caddyfile +# Caddyfile — Dual-Domain-Übergang .cloud -> .de +# +# Während der Migration bedient dieser Caddy BEIDE Domain-Sätze aus denselben +# lokalen Backends: +# - *.netralax.de = neue, primäre Hostnamen (neue Client-Builds) +# - *.netralax.cloud = Legacy-Hostnamen, die in bereits installierten +# Desktop-/Mobile-Bundles fest einkompiliert sind. +# Die .cloud-DNS-A-Records zeigen (nach dem Cutover) auf DIESELBE neue IP, damit +# alte Installationen weiterlaufen, bis sie sich selbst auf .de aktualisieren. +# NICHT entfernen, solange noch alte Clients .cloud ansprechen (siehe Abschnitt 14). + +# AKTIV ab jetzt: nur die .de-Hosts. Die .cloud-Blöcke stehen auskommentiert +# darunter und werden ERST beim Cutover (§10) aktiviert — sonst läuft Caddy ins +# Let's-Encrypt-Rate-Limit, weil .cloud-DNS noch auf den alten Server zeigt. + +# --- Supabase (Kong-Gateway :8000 multiplext auth/rest/realtime/storage/functions/Studio) --- +# Realtime-WS (/realtime/v1/websocket) wird von reverse_proxy transparent upgegradet. +supabase.netralax.de { + reverse_proxy localhost:8000 +} + +# --- LiveKit Signaling-WS (:7880). Caddy reicht Upgrade/Connection-Header durch. --- +livekit.netralax.de { + reverse_proxy localhost:7880 +} + +# --- Update-Host (electron-updater: latest.yml + .exe + changelog.json) --- +# 🔴 docroot ist /var/www/updates, NICHT .../windows: release.mjs lädt nach +# /var/www/updates/windows/ hoch, Clients holen unter URL-Pfad /windows/… +# Mit root=.../windows entstünde /windows/windows/ → 404 für JEDES Update. +update.netralax.de { + root * /var/www/updates + file_server +} + +# --- CUTOVER (§10): erst NACH .cloud-DNS-Repoint einkommentieren + caddy reload --- +# supabase.netralax.cloud { reverse_proxy localhost:8000 } +# livekit.netralax.cloud { reverse_proxy localhost:7880 } +# update.netralax.cloud { root * /var/www/updates +# file_server } +``` + +> **🔴 Pfad-Matcher, die WS-Endpunkte ausschließen, sind tabu.** Caddy v2 reicht WebSocket-Upgrades transparent durch – aber nur, wenn der **ganze** Host reverse-proxyt wird (kein Sub-Path-Matching). Das gilt für Realtime (`/realtime/v1/websocket`) **und** LiveKit (`/rtc`). Es gibt kein „websocket"-Flag und es wird keins gebraucht. + +Aktivieren: + +```bash +ssh debian@141.95.34.204 'sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy' +``` + +> Let's Encrypt stellt für die `.cloud`-Namen erst gültige Zertifikate aus, **nachdem** die `.cloud`-A-Records auf die neue IP zeigen (Cutover, Abschnitt 10). Bis dahin schlägt die Cert-Ausstellung für `.cloud` fehl – das ist erwartbar und löst sich mit dem DNS-Repoint. + +--- + +## 8. Edge-Functions deployen + Secrets + +Edge-Functions liegen im Repo unter `supabase/functions/`: **`mint-livekit-token`**, **`notify-push`**, **`og-preview`**. Deploy via bestehendem Skript (kopiert `supabase/functions//` nach `/opt/supabase/volumes/functions//` und startet `functions`-Container neu). + +> **Achtung Host-Pinning des Deploy-Skripts:** `scripts/prod/push-edge-function.sh` sourct `scripts/prod/config.sh`, das auf `PROD_SERVER="141.95.34.204"` (neuer `.de`-VPS, User `debian`) zeigt. Diese Befehle pushen also auf den NEUEN Server — erst ausführen, nachdem Bootstrap + Secrets dort stehen: + +```bash +./scripts/prod/push-edge-function.sh mint-livekit-token +./scripts/prod/push-edge-function.sh notify-push +./scripts/prod/push-edge-function.sh og-preview +``` + +### 8.1 Erwartete Edge-Function-Secrets in `/opt/supabase/.env` + +Aus dem Code verifiziert; alle in `/opt/supabase/.env` (in Abschnitt 3 bereits gesetzt): + +`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`, `PUSH_FANOUT_SHARED_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`. + +Erinnerung: `FUNCTIONS_VERIFY_JWT=false` lassen (notify-push gatet über `x-shared-secret`-Header, nicht über User-JWT). + +> **🔴 Custom-Secrets müssen den `functions`-Container auch erreichen.** Im **frisch geklonten** Supabase-Compose bekommt der `functions`-Service nur die env-Variablen, die in seinem `environment:`/`env_file:`-Block stehen. `LIVEKIT_API_KEY/SECRET`, `VAPID_*`, `PUSH_FANOUT_SHARED_SECRET` und `SUPABASE_ANON_KEY` sind **Custom-Variablen** und stehen dort per Default **nicht** drin. Auf dem alten Server ist das verdrahtet (es läuft ja) — auf dem neuen muss es nachgezogen werden: entweder `env_file: .env` am `functions`-Service ergänzen oder die Variablen explizit in dessen `environment:` listen. Sonst sieht `mint-livekit-token` leere Strings → `livekit-not-configured` (500) und `notify-push` lehnt mangels `SHARED_SECRET` jede Anfrage ab. + +### 8.2 Verifikation + +```bash +# 1) Erreichen die Secrets den Container wirklich? (vor dem Funktionstest!) +ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T functions \ + env | grep -E "LIVEKIT_API_KEY|LIVEKIT_API_SECRET|VAPID_PUBLIC_KEY|PUSH_FANOUT_SHARED_SECRET|SUPABASE_ANON_KEY"' +# -> Es müssen NICHT-leere Werte erscheinen. Fehlt einer: env_file/environment im +# functions-Service nachziehen und 'docker compose up -d functions'. + +# 2) Logs: +./scripts/prod/logs.sh # bzw. docker compose logs functions --tail=20 +# 403 bei mint-livekit-token? -> LIVEKIT_API_KEY/SECRET stimmen nicht mit /opt/livekit/livekit.yaml überein. +``` + +--- + +## 9. Update-Host migrieren + Dual-Publish (.de UND .cloud) + +Der Update-Host ist ein statisches Verzeichnis `/var/www/updates/windows` mit `latest.yml`, `.exe`-Installern und `changelog.json`, ausgeliefert per `file_server` (Abschnitt 7). SSH-Deploy-User: `chatapp-deploy`. + +### 9.1 Bestehende Artefakte ALT → NEU spiegeln + +```bash +rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ + chatapp-deploy@46.225.156.249:/var/www/updates/windows/ ./_updates_stage/ +rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ + ./_updates_stage/ chatapp-deploy@141.95.34.204:/var/www/updates/windows/ +``` + +### 9.2 Dual-Publish-Garantie + +Beide Hosts (`update.netralax.de` und – ab Cutover – `update.netralax.cloud`) haben im Caddyfile denselben docroot **`/var/www/updates`** (nicht `…/windows`). Die Artefakte liegen physisch in `/var/www/updates/windows/` und werden so unter dem URL-Pfad `/windows/latest.yml` usw. ausgeliefert – unter **beiden** Hosts aus **einem** Verzeichnis. (Den Docroot-Fallstrick `/windows/windows/` → 404 siehe §7.) + +> **🔴 Alte Clients prüfen `update.netralax.cloud`.** Liegt die Switch-over-Release nicht (auch) unter `.cloud`, können alte Installationen sich **niemals** auf `.de` aktualisieren. Der `changelog.ts` der neuen Builds zeigt zwar auf `https://update.netralax.de/windows/changelog.json`, aber die im Bundle der **alten** Clients eingebackene URL ist `.cloud` – beide müssen funktionieren. + +### 9.3 Deploy-Konfiguration + +`.env.release` ist bereits gesetzt (`UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows`). Stelle sicher, dass der Deploy-User `chatapp-deploy` auf dem neuen VPS existiert und Schreibrechte auf `/var/www/updates/windows` hat. + +--- + +## 10. Cutover & DNS scharf schalten + +> **Erst hier wird DNS umgebogen.** Voraussetzung: Abschnitte 2–9 abgeschlossen, neuer VPS steht, Stacks laufen, Caddy lädt (für `.de` bereits mit gültigem Cert), Storage + DB migriert, Wartungsfenster ggf. noch aktiv. + +### 10.1 Reihenfolge + +1. **`.de`-A-Records anlegen** (Abschnitt 1.2, neue Records) → Caddy holt sofort Let's-Encrypt-Certs für `.de`. +2. Interner Smoke-Test über `.de` (Abschnitt 11) **bevor** alte Clients umgeschwenkt werden. +3. **`.cloud`-A-Records repointen** auf `141.95.34.204` (Abschnitt 1.2, Legacy-Records) → Caddy stellt jetzt auch für `.cloud` Certs aus; alte Clients landen ab jetzt auf dem neuen VPS. +4. Propagation prüfen (Abschnitt 1.3). +5. **Wartungsmodus aufheben**, Schreibvorgänge auf dem **neuen** System freigeben. + +### 10.2 Verifikation der TLS-Ausstellung + +```bash +for h in supabase.netralax.de supabase.netralax.cloud livekit.netralax.de livekit.netralax.cloud update.netralax.de update.netralax.cloud; do + echo "== $h ==" + echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null | openssl x509 -noout -subject -dates +done +``` + +Jeder Host muss ein gültiges, nicht abgelaufenes Cert liefern. + +--- + +## 11. Smoke-Test-Checkliste + +Nach dem Cutover, in dieser Reihenfolge: + +### 11.1 Supabase / Auth / Magic-Link + +- [ ] `https://supabase.netralax.de/auth/v1/health` und `https://supabase.netralax.cloud/auth/v1/health` liefern `200`. +- [ ] **Login per Magic-Link, pro Plattform mit dem JEWEILS registrierten Schema** testen: **Desktop** über `chatapp://auth/callback`, **Mobile** über `netralax://auth/callback` (das in `apps/mobile/app.json` registrierte Schema). `ADDITIONAL_REDIRECT_URLS` enthält beide, daher akzeptiert GoTrue beides — aber das OS routet nur das tatsächlich registrierte Schema zurück in die App. + > ⚠️ Vorbestehend (nicht durch den Umzug verursacht): `apps/mobile/.env.local` setzt aktuell `EXPO_PUBLIC_AUTH_REDIRECT_URL=chatapp://auth/callback`, `app.json` registriert aber nur `netralax://`. Für funktionierende Mobile-Magic-Links sollte das App-Team den Mobile-Wert auf `netralax://auth/callback` setzen (Desktop bleibt `chatapp://`). Außerhalb des Server-Umzugs — hier nur als Flag. +- [ ] PostgREST-Zugriff mit dem **eingebackenen** anon-Key wird akzeptiert (kein 401 wegen falschem `JWT_SECRET`): + ```bash + curl -s -H "apikey: " "https://supabase.netralax.de/rest/v1/" | head + ``` + +### 11.2 Nachricht senden / Realtime + +- [ ] Zwei eingeloggte Clients: Nachricht von A erscheint bei B in Echtzeit (Realtime-WS `/realtime/v1/websocket` über Caddy). +- [ ] Storage: Upload + Re-Download eines Bildes (`/storage/v1/object/...`) funktioniert (DB-Metadaten + Volume-Bytes konsistent). + +### 11.3 Voice-Call mit echtem Ton (über TURN) + +- [ ] **Call zwischen zwei Geräten in unterschiedlichen Netzen** (mind. eins hinter NAT/CGNAT, das TURN erzwingt): Verbindung steht **und es ist echter Ton/Bild hörbar/sichtbar**. +- [ ] Bestätigt indirekt: `rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, alle Media-Ports offen, TURNS-Cert für `turn.netralax.de` gültig. +- [ ] `mint-livekit-token` liefert ein Token, das der SFU akzeptiert (kein 403 → Keys stimmen mit `/opt/livekit/livekit.yaml` überein). + +### 11.4 Web-Push + +- [ ] Ein **bestehender** (vor der Migration angelegter) Push-Abonnent erhält weiterhin Benachrichtigungen → bestätigt identisches VAPID-Paar. +- [ ] Neue Subscription + Test-Push über `notify-push` (mit korrektem `x-shared-secret` / `PUSH_FANOUT_SHARED_SECRET`) kommt an. + +### 11.5 Auto-Update-Check von einem ALTEN `.cloud`-Client + +- [ ] `latest.yml` ist unter **beiden** Hosts mit echtem `200` abrufbar (nicht nur „erreichbar" — der Docroot-Bug aus §7 würde hier 404 liefern): + ```bash + curl -sI https://update.netralax.de/windows/latest.yml | head -1 # HTTP/2 200 + curl -sI https://update.netralax.cloud/windows/latest.yml | head -1 # HTTP/2 200 + curl -sI https://update.netralax.cloud/windows/changelog.json | head -1 + ``` +- [ ] Eine **bestehende, alte** Desktop-Installation (Hostnamen `.cloud` eingebacken) prüft auf Updates: electron-updater findet die Switch-over-Release, lädt sie und installiert. +- [ ] Nach dem Update zeigt der Client auf `.de` (neue Bundle-Werte) und funktioniert vollständig (Login, Nachricht, Call, Push). + +> **Dieser letzte Test ist der wichtigste.** Er beweist den gesamten Übergangspfad: alter Client → `.cloud` (neue IP) → lädt Update → wird zu `.de`-Client. + +--- + +## 12. Repo-Änderungen + neues Release bauen/ausliefern + +### 12.1 Bereits gemachte Edits (verifiziert im Repo) + +| Datei | Änderung | Status | +|---|---|---| +| `scripts/prod/config.sh` | `PROD_SERVER="141.95.34.204"`, `PROD_DOMAIN_SUPABASE=supabase.netralax.de`, `PROD_DOMAIN_LIVEKIT=livekit.netralax.de` | ✅ erledigt (End-Zustand) | +| `apps/desktop/.env` | `SUPABASE_URL` + `VITE_SUPABASE_URL` = `https://supabase.netralax.de`; `VITE_LIVEKIT_URL=wss://livekit.netralax.de`; anon-Key + `VITE_VAPID_PUBLIC_KEY` (unverändert übernommen) | ✅ erledigt | +| `apps/mobile/.env.local` | `EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de` (anon-Key, redirect-Schema unverändert) | ✅ erledigt | +| `.env.release` | `UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows` | ✅ erledigt | +| `package.json` | `release`-Script + `prod:*`-Scripts vorhanden (unverändert; nutzen `scripts/prod/config.sh`) | ✅ vorhanden | +| `apps/desktop/src/lib/changelog.ts` | `CHANGELOG_URL='https://update.netralax.de/windows/changelog.json'` (mit Kommentar, dass alte Clients weiter `.cloud` abfragen) | ✅ erledigt | + +> **✅ Erledigt:** Die neue IP `141.95.34.204` ist in `scripts/prod/config.sh` (`PROD_SERVER`) und `scripts/migrate/config.sh` (`NEW_HOST`) eingetragen; Login-User dort ist `debian`. + +### 12.2 Neues Desktop-Release bauen + dual publizieren + +```bash +# Vom Laptop, mit korrektem .env.release: +pnpm install +pnpm --filter @chat-app/desktop build +pnpm release # = node scripts/release.mjs +``` + +`scripts/release.mjs` lädt `latest.yml` + `.exe` + aktualisiertes `changelog.json` nach `UPDATE_HOST` (`update.netralax.de`). Da Caddy `update.netralax.de` **und** `update.netralax.cloud` aus demselben Verzeichnis bedient, ist diese eine Veröffentlichung **automatisch** unter beiden Hosts verfügbar (Dual-Publish, Abschnitt 9). + +> **🔴 Diese Release MUSS unter `.cloud` erreichbar sein**, denn nur sie schaltet alte Installationen auf `.de` um. Nach dem Upload mit Abschnitt 11.5 verifizieren. + +### 12.3 Neues Mobile-Release + +```bash +pnpm --filter @chat-app/mobile typecheck +# Expo-Build/Submit nach eurem üblichen EAS-/Store-Prozess. +# .env.local trägt bereits EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de. +``` + +> Mobile-Clients aktualisieren über die App-Stores, nicht über den Update-Host. Bis ein User die neue Store-Version installiert, hält ihn der `.cloud`-Vhost am Leben. + +--- + +## 13. Rollback-Plan + +Der alte VPS bleibt **vollständig intakt und laufend**, bis der neue verifiziert ist. Rollback heißt im Kern: **DNS zurückbiegen**. + +1. **Schnell-Rollback (DNS):** Alle `.cloud`-A-Records zurück auf `46.225.156.249` (alte IP), `.de`-Records entfernen oder ebenfalls auf alt zeigen lassen. Dank niedriger TTL (Abschnitt 1.1) greift das in Minuten. Alte Clients landen wieder auf dem alten, intakten Server. +2. **Voraussetzung dafür:** Während der Migration **keine destruktiven Änderungen am alten Server** (alter Stack nicht löschen, alte Volumes nicht anfassen). Der Schreibstopp (Abschnitt 5.1) bedeutet nur Wartungsmodus, kein Datenverlust. +3. **Daten-Divergenz beachten:** Wurden nach dem Cutover bereits Schreibvorgänge auf dem **neuen** Server akzeptiert, gehen diese bei einem reinen DNS-Rollback verloren. Deshalb: Cutover (Abschnitt 10.5, Schreibfreigabe) erst nach den Smoke-Tests; bis dahin ist der Rollback verlustfrei. +4. **Update-Host-Rollback:** `.exe`/`latest.yml` auf dem alten Host wurden nicht verändert; alte Clients, die noch nicht aktualisiert haben, finden dort weiterhin den alten Stand. +5. Wenn nur **eine** Komponente klemmt (z. B. nur TURN ohne Ton), kann punktuell zurückgerollt werden, indem nur der betroffene `.cloud`-Record zurückzeigt – die übrigen können auf neu bleiben. + +--- + +## 14. Aufräumen / `.cloud` später abschalten + +Die `.cloud`-Hosts dürfen **erst** verschwinden, wenn praktisch keine alten Clients mehr darauf zugreifen. + +### 14.1 Reihenfolge der Abschaltung (frühestens → spätestens) + +1. **Alten VPS dekommissionieren:** Erst nachdem `.cloud`-DNS auf den **neuen** VPS repointet ist und über die neue IP läuft. (Der alte Server liefert dann ohnehin keinen Traffic mehr.) Vorher als Rollback-Sicherheit behalten (Abschnitt 13). +2. **Supabase-/LiveKit-`.cloud`-Vhosts in Caddy** entfernen, sobald Telemetrie/Logs zeigen, dass praktisch alle aktiven Sessions auf `.de` laufen (d. h. die meisten Desktop-Clients haben die Switch-over-Release gezogen und Mobile-Clients die neue Store-Version). +3. **Update-`.cloud`-Vhost als LETZTES abschalten.** + +### 14.2 Warum der Update-Host am längsten bleiben muss + +> Eine Desktop-Installation, die **noch nie** die Switch-over-Release gezogen hat, kennt **nur** `update.netralax.cloud` (eingebacken). Sie erreicht `.de` ausschließlich, indem sie die neue Version über **`.cloud`** herunterlädt. Schaltest du `update.netralax.cloud` zu früh ab, **stranden** alle noch nicht aktualisierten Clients dauerhaft auf der alten Version – sie können sich nie mehr selbst auf `.de` updaten und müssten manuell neu installiert werden. +> +> Faustregel: `update.netralax.cloud` so lange behalten, bis die Update-Metriken zeigen, dass der Long-Tail alter Installationen vernachlässigbar ist (eher Monate als Wochen). Supabase-/LiveKit-`.cloud` können früher fallen als Update-`.cloud`, aber niemals umgekehrt. + +### 14.3 Endzustand + +- DNS: nur noch `*.netralax.de` aktiv; `*.netralax.cloud` entfernt (zuletzt `update.netralax.cloud`). +- Caddyfile: nur noch die `.de`-Vhosts (Legacy-Block + Kommentar entfernt). +- `scripts/prod/config.sh` ist die alleinige Live-Konfiguration; `scripts/migrate/` wird nicht mehr gebraucht (kann archiviert bleiben). +- Lokale Sicherungen (`old.env.backup`, `_storage_stage/`, `_updates_stage/`) sicher löschen (`shred`/Secure-Delete), da sie Secrets enthalten. + +--- + +**Grounding-Hinweise (Repo-Fakten):** Postgres-Major-Version aus `supabase/config.toml` = `17`. Edge-Functions im Repo: `supabase/functions/{mint-livekit-token,notify-push,og-preview}`. Dev-`infra/livekit/livekit.yaml` enthält absichtlich `use_external_ip: false` + `node_ip: 127.0.0.1` (Dev-only — in Prod invertiert/entfernt). `apps/desktop/.env`, `apps/mobile/.env.local`, `.env.release`, `scripts/prod/config.sh` und `apps/desktop/src/lib/changelog.ts` sind bereits auf `.de` umgestellt (verifiziert). \ No newline at end of file diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile new file mode 100644 index 0000000..e29fe28 --- /dev/null +++ b/infra/caddy/Caddyfile @@ -0,0 +1,91 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Caddyfile — Produktion (NEW VPS, netralax.de) +# +# Dual-Domain-Übergang (.de + .cloud): +# Bereits installierte Desktop- (Vite) und Mobile- (Expo) Clients haben die +# ALTEN Hostnamen fest in ihre Bundles eingebacken +# (supabase.netralax.cloud, livekit.netralax.cloud, update.netralax.cloud). +# Deshalb bedient dieser NEUE Server BEIDE Domains aus denselben Backends: +# - die neuen *.netralax.de Hosts für aktuelle/neue Releases +# - die legacy *.netralax.cloud Hosts NUR damit Alt-Installationen weiter +# funktionieren, bis sie sich per Auto-Update auf .de umgestellt haben. +# Voraussetzung: die .cloud-DNS-A-Records müssen auf die NEUE VPS-IP zeigen. +# Die .cloud-Blöcke dürfen NICHT entfernt werden, solange noch Alt-Clients +# im Umlauf sind — sonst brechen alle bestehenden Installationen. +# +# TLS: Automatisches HTTPS via Let's Encrypt für alle Hosts. +# WebSockets: Caddy v2 reicht Upgrade/Connection-Header bei reverse_proxy +# transparent durch — sowohl für Supabase Realtime (/realtime/v1/websocket) +# als auch für LiveKit (/rtc). KEINE websocket-Direktive nötig/vorhanden. +# +# WICHTIG: Nur der Signaling-WS (7880) und das Supabase-Gateway (Kong 8000) +# laufen über Caddy. RTC-Medien (7881/tcp, 50000-50100/udp) und coturn +# (3478, 5349/TLS, 50200-50300/udp) gehen NICHT über Caddy und müssen direkt +# in der ufw geöffnet werden. TURNS auf 5349 braucht ein EIGENES Zertifikat +# für turn.netralax.de (siehe coturn.prod.conf.example). +# ───────────────────────────────────────────────────────────────────────────── + +# ───────────────────────────────────────────────────────────────────────────── +# AKTIV ab Bootstrap: die NEUEN .de-Hosts. +# Die .cloud-Legacy-Blöcke stehen weiter unten und werden ERST beim Cutover +# (Runbook §10) einkommentiert — nämlich NACHDEM die .cloud-A-Records auf die +# neue VPS-IP zeigen. Grund: stehen die .cloud-Namen schon vorher in der aktiven +# Config, scheitert Caddy wiederholt an der Let's-Encrypt-Ausstellung (DNS zeigt +# noch auf den alten Server) und läuft ins ACME-Rate-Limit (5 Fehler/Host/Stunde). +# ───────────────────────────────────────────────────────────────────────────── + +# Supabase API-Gateway (Kong multiplext auth/rest/realtime/storage/functions +# + Studio). EIN reverse_proxy genügt — KEINE Routen in Caddy aufsplitten. +supabase.netralax.de { + reverse_proxy localhost:8000 +} + +# LiveKit Signaling-WebSocket. Caddy übernimmt den WS-Upgrade automatisch. +# CORS-Header + OPTIONS-Preflight wie auf dem alten Server (Browser/Electron- +# Clients erwarten sie beim Token-/Connect-Handshake). +livekit.netralax.de { + header Access-Control-Allow-Origin "*" + header Access-Control-Allow-Methods "GET, POST, OPTIONS" + header Access-Control-Allow-Headers "Authorization, Content-Type" + header Access-Control-Expose-Headers "*" + @options method OPTIONS + handle @options { + respond 204 + } + reverse_proxy localhost:7880 +} + +# electron-updater Artefakte (latest.yml + .exe + changelog.json). +# WICHTIG: docroot ist /var/www/updates (NICHT .../windows). release.mjs lädt +# nach /var/www/updates/windows/ hoch und die Clients holen unter dem URL-Pfad +# /windows/latest.yml — der Pfad-Präfix /windows/ muss also auf das Unterverzeichnis +# mappen. Mit root=/var/www/updates/windows entstünde .../windows/windows → 404. +update.netralax.de { + root * /var/www/updates + file_server +} + +# ───────────────────────────────────────────────────────────────────────────── +# LEGACY .cloud-Hosts — AKTIV seit dem Cutover (DNS .cloud → neue VPS-IP). +# Liefern aus denselben Backends wie die .de-Hosts, damit bereits installierte +# Clients weiterlaufen, bis sie sich per Auto-Update auf .de umgestellt haben. +# NICHT entfernen, solange Alt-Clients im Umlauf sind. +# ───────────────────────────────────────────────────────────────────────────── +supabase.netralax.cloud { + reverse_proxy localhost:8000 +} +livekit.netralax.cloud { + header Access-Control-Allow-Origin "*" + header Access-Control-Allow-Methods "GET, POST, OPTIONS" + header Access-Control-Allow-Headers "Authorization, Content-Type" + header Access-Control-Expose-Headers "*" + @options method OPTIONS + handle @options { + respond 204 + } + reverse_proxy localhost:7880 +} +update.netralax.cloud { + root * /var/www/updates + file_server +} diff --git a/infra/livekit/coturn.prod.conf.example b/infra/livekit/coturn.prod.conf.example new file mode 100644 index 0000000..f973dc1 --- /dev/null +++ b/infra/livekit/coturn.prod.conf.example @@ -0,0 +1,50 @@ +# ───────────────────────────────────────────────────────────────────────────── +# coturn — Produktionskonfiguration (turnserver.conf) für turn.netralax.de +# +# coturn läuft EIGENSTÄNDIG (LiveKit-internes TURN ist deaktiviert). +# TURNS (5349/TLS) läuft NICHT über Caddy und braucht daher ein EIGENES +# TLS-Zertifikat für turn.netralax.de auf der Platte (cert/pkey unten). +# +# Zertifikat besorgen — zwei Wege: +# (a) certbot standalone (Port 80 muss frei sein, nicht von Caddy belegt): +# certbot certonly --standalone -d turn.netralax.de +# -> liefert /etc/letsencrypt/live/turn.netralax.de/{fullchain,privkey}.pem +# coturn nach Renewals neu laden (z. B. certbot --deploy-hook 'systemctl reload coturn'). +# (b) Caddy-Zertifikat wiederverwenden: lasse Caddy zusätzlich turn.netralax.de +# ausstellen und kopiere/symlinke das Zert aus Caddys data-Verzeichnis +# (~/.local/share/caddy/certificates/...) an die Pfade unten. Achtung: +# coturn braucht Leserechte auf cert+pkey. +# +# ufw muss offen sein: 3478/udp+tcp, 5349/tcp (TURNS), 50200-50300/udp (Relay). +# Diese Ports gehen NICHT über Caddy. +# +# external-ip auf die ÖFFENTLICHE IP der NEUEN VPS setzen. +# lt-cred-mech-User muss zu dem passen, den mint-livekit-token / die Clients +# erwarten (Platzhalter unten ersetzen). +# ───────────────────────────────────────────────────────────────────────────── + +listening-port=3478 +tls-listening-port=5349 + +# Öffentliche IP der neuen VPS. +external-ip=141.95.34.204 + +# Relay-Port-Range (muss in ufw offen sein). +min-port=50200 +max-port=50300 + +realm=netralax.de + +# Long-Term-Credential-Mechanismus. User-Platzhalter ersetzen +# (Format: user=NAME:PASSWORT). Passwort z. B. via `openssl rand -hex 16`. +lt-cred-mech +user=turnuser: + +# TLS-Material für TURNS (turn.netralax.de) — siehe Kopf-Kommentar. +cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem +pkey=/etc/letsencrypt/live/turn.netralax.de/privkey.pem + +# Härtung / Korrektheit. +fingerprint +no-multicast-peers +no-cli diff --git a/infra/livekit/docker-compose.prod.yml.example b/infra/livekit/docker-compose.prod.yml.example new file mode 100644 index 0000000..6b45132 --- /dev/null +++ b/infra/livekit/docker-compose.prod.yml.example @@ -0,0 +1,41 @@ +# ───────────────────────────────────────────────────────────────────────────── +# LiveKit + coturn — Produktions-Compose (NEW VPS, netralax.de) +# +# Dies ist die PROD-Variante von infra/livekit/docker-compose.yml (das ist nur +# Dev: coturn läuft dort mit --no-tls/--no-dtls, ohne 5349, ohne Zertifikat). +# +# Auf den Server kopieren als /opt/livekit/docker-compose.yml und daneben: +# /opt/livekit/livekit.yaml <- infra/livekit/livekit.prod.yaml.example (Keys eintragen) +# /opt/livekit/coturn.conf <- infra/livekit/coturn.prod.conf.example (external-ip + Cert) +# Start: cd /opt/livekit && docker compose up -d && docker compose ps +# +# network_mode: host — auf einem Linux-Server ist das für WebRTC der robusteste +# Weg: die RTC-UDP-Range (50000-50100) und die TURN-Relay-Range (50200-50300) +# müssen NICHT einzeln gemappt werden, und coturn/LiveKit sehen die echten +# Quell-IPs. Welche Ports tatsächlich erreichbar sind, regelt ufw (siehe +# Runbook §6.4). Auf macOS/Docker-Desktop wird host-networking NICHT unterstützt +# — dort gilt weiterhin die Dev-Compose mit explizitem Port-Mapping. +# ───────────────────────────────────────────────────────────────────────────── + +services: + livekit: + image: livekit/livekit-server:latest + restart: unless-stopped + network_mode: host + command: ["--config", "/etc/livekit.yaml"] + volumes: + - ./livekit.yaml:/etc/livekit.yaml:ro + + turn: + image: coturn/coturn:4.6 + restart: unless-stopped + network_mode: host + # Prod: vollständige turnserver.conf statt der Dev-CLI-Flags. Diese Datei + # aktiviert TURNS auf 5349 mit dem Zertifikat für turn.netralax.de. + command: ["-c", "/etc/coturn/turnserver.conf"] + volumes: + - ./coturn.conf:/etc/coturn/turnserver.conf:ro + # TLS-Material für turn.netralax.de. coturn.conf verweist mit + # cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem (und privkey) + # auf genau diese Pfade — daher /etc/letsencrypt read-only einhängen. + - /etc/letsencrypt:/etc/letsencrypt:ro diff --git a/infra/livekit/livekit.prod.yaml.example b/infra/livekit/livekit.prod.yaml.example new file mode 100644 index 0000000..1e25d0b --- /dev/null +++ b/infra/livekit/livekit.prod.yaml.example @@ -0,0 +1,41 @@ +# ───────────────────────────────────────────────────────────────────────────── +# LiveKit — Produktionskonfiguration (NEW VPS) +# +# Diese Datei ERSETZT die Dev-Werte aus infra/livekit/livekit.yaml. +# Unterschiede zur Dev-Config (WICHTIG): +# - rtc.use_external_ip: true (Dev: false) +# - KEIN rtc.node_ip: 127.0.0.1 (Dev-only — würde im Prod jeden Client +# veranlassen, Medien an seinen eigenen Loopback zu senden: Call verbindet, +# aber KEIN Audio/Video). +# - echte keys: (Platzhalter unten) statt der öffentlich bekannten devkey. +# +# Die keys: müssen EXAKT zu LIVEKIT_API_KEY / LIVEKIT_API_SECRET in +# /opt/supabase/.env passen (mint-livekit-token signiert damit). Wird nur eine +# Seite rotiert, lehnt die SFU die Tokens beim Join ab (403). +# +# ufw muss offen sein: 7880/tcp (Signaling, hinter Caddy), 7881/tcp (RTC TCP), +# 50000-50100/udp (RTC). Diese Ports außer 7880 gehen NICHT über Caddy. +# +# Kopiere diese Datei als /opt/livekit/livekit.yaml und trage echte Keys ein. +# ───────────────────────────────────────────────────────────────────────────── + +port: 7880 +log_level: info + +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + # Prod: öffentliche IP des Servers ankündigen (NICHT Loopback wie im Dev). + use_external_ip: true + # KEIN node_ip hier — das war dev-only (127.0.0.1) und bricht im Prod die Medien. + +# Produktionsschlüssel — Platzhalter. Muss zu /opt/supabase/.env passen +# (LIVEKIT_API_KEY = der key, LIVEKIT_API_SECRET = das secret). +# Erzeugen z. B. mit: openssl rand -hex 32 +keys: + APIxxxxxxxxxxxx: + +# coturn läuft separat (siehe coturn.prod.conf.example) — eingebauter TURN aus. +turn: + enabled: false diff --git a/scripts/migrate/01-bootstrap-new-server.sh b/scripts/migrate/01-bootstrap-new-server.sh new file mode 100644 index 0000000..ff34f77 --- /dev/null +++ b/scripts/migrate/01-bootstrap-new-server.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# +# Bootstrap a fresh netralax.de VPS so it can host the Supabase + LiveKit stack. +# +# COPY THIS SCRIPT TO THE NEW SERVER AND RUN IT THERE as root (or via sudo): +# scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/ +# ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh' +# +# It is idempotent: re-running it only fills in what is missing. It installs +# Docker CE + the compose plugin, opens the firewall, clones supabase/supabase, +# prepares /opt/livekit, installs Caddy, creates the update host + deploy user, +# and writes placeholder config. It NEVER fabricates secret values — those you +# copy from the old server (see the NEXT STEPS block it prints at the end). + +set -euo pipefail + +# --- must run as root ------------------------------------------------------ +if [[ "${EUID}" -ne 0 ]]; then + echo "this script must run as root (use: sudo bash $0)" >&2 + exit 1 +fi + +SUPABASE_DIR="/opt/supabase" +LIVEKIT_DIR="/opt/livekit" +UPDATES_DIR="/var/www/updates/windows" +DEPLOY_USER="chatapp-deploy" + +log() { echo "==> $*"; } + +# --- base packages --------------------------------------------------------- +log "updating apt and installing base packages" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y \ + ca-certificates curl gnupg lsb-release git ufw rsync apt-transport-https + +# --- Docker CE + compose plugin ------------------------------------------- +if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + log "docker + compose plugin already installed — skipping" +else + log "installing Docker CE + compose plugin (official repo)" + install -m 0755 -d /etc/apt/keyrings + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then + curl -fsSL https://download.docker.com/linux/debian/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + fi + . /etc/os-release + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list + apt-get update -y + apt-get install -y \ + docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + systemctl enable --now docker +fi +# Let the login user run docker/compose without sudo (effective on next login). +usermod -aG docker "${SUDO_USER:-debian}" || true + +# --- firewall (ufw) -------------------------------------------------------- +# Media + TURN ports bypass Caddy entirely and MUST be open or calls have no A/V. +log "configuring ufw" +ufw allow 22/tcp comment 'ssh' +ufw allow 80/tcp comment 'http (caddy / lets encrypt)' +ufw allow 443/tcp comment 'https (caddy)' +ufw allow 7880/tcp comment 'livekit signaling ws (behind caddy)' +ufw allow 7881/tcp comment 'livekit rtc tcp fallback' +ufw allow 50000:50100/udp comment 'livekit rtc udp' +ufw allow 3478/tcp comment 'coturn' +ufw allow 3478/udp comment 'coturn' +ufw allow 5349/tcp comment 'coturn turns (tls)' +ufw allow 50200:50300/udp comment 'coturn turn relay' +# Enable non-interactively (idempotent — re-enabling is a no-op). +ufw --force enable +ufw status verbose || true + +# --- Supabase (clone upstream, prepare .env) ------------------------------- +if [[ -d "${SUPABASE_DIR}/.git" || -f "${SUPABASE_DIR}/docker-compose.yml" ]]; then + log "${SUPABASE_DIR} already populated — skipping clone" +else + log "cloning supabase/supabase into a temp dir and laying out ${SUPABASE_DIR}" + tmp="$(mktemp -d)" + git clone --depth 1 https://github.com/supabase/supabase "${tmp}/supabase" + mkdir -p "${SUPABASE_DIR}" + # The runnable self-hosted stack lives in supabase/docker. + cp -r "${tmp}/supabase/docker/." "${SUPABASE_DIR}/" + rm -rf "${tmp}" +fi + +# Prepare .env from the example WITHOUT inventing secrets. +# +# IMPORTANT: Supabase's upstream .env.example does NOT ship blank secrets — it +# ships well-known PUBLIC default values (JWT_SECRET=your-super-secret..., the +# matching default ANON_KEY/SERVICE_ROLE_KEY, POSTGRES_PASSWORD, etc.). Booting +# with those is both a security hole AND wrong: the baked anon key in installed +# clients is signed with the OLD server's JWT_SECRET, so a default secret makes +# the gateway reject every token and drop all sessions — silently. So we +# OVERWRITE the security-critical keys with a loud sentinel that fails fast if +# someone forgets to fill them from the old server. +SENTINEL="__COPY_FROM_OLD_SERVER__" +CRIT_KEYS=(POSTGRES_PASSWORD JWT_SECRET ANON_KEY SERVICE_ROLE_KEY \ + SECRET_KEY_BASE VAULT_ENC_KEY DASHBOARD_PASSWORD) +if [[ -f "${SUPABASE_DIR}/.env" ]]; then + log "${SUPABASE_DIR}/.env already exists — leaving it untouched" +elif [[ -f "${SUPABASE_DIR}/.env.example" ]]; then + cp "${SUPABASE_DIR}/.env.example" "${SUPABASE_DIR}/.env" + for k in "${CRIT_KEYS[@]}"; do + sed -i "s|^${k}=.*|${k}=${SENTINEL}|" "${SUPABASE_DIR}/.env" || true + done + log "wrote ${SUPABASE_DIR}/.env — critical secrets set to ${SENTINEL}." + log "These are NOT blank by default upstream; you MUST copy the real values" + log "1:1 from the OLD server's /opt/supabase/.env (esp. JWT_SECRET + VAPID)." +else + log "WARNING: no .env.example found in ${SUPABASE_DIR}; create .env by hand" +fi + +# --- LiveKit dir ----------------------------------------------------------- +log "preparing ${LIVEKIT_DIR}" +mkdir -p "${LIVEKIT_DIR}" +if [[ ! -f "${LIVEKIT_DIR}/livekit.yaml" ]]; then + cat > "${LIVEKIT_DIR}/livekit.yaml" <<'YAML' +# PLACEHOLDER — replace with infra/livekit/livekit.prod.yaml.example contents. +# Prod config MUST set rtc.use_external_ip: true and must NOT hardcode +# node_ip: 127.0.0.1 (that is dev-only). Fill the keys: block with the SAME +# API key/secret as LIVEKIT_API_KEY / LIVEKIT_API_SECRET in /opt/supabase/.env. +YAML + log "wrote placeholder ${LIVEKIT_DIR}/livekit.yaml" +fi +if [[ ! -f "${LIVEKIT_DIR}/coturn.conf" ]]; then + cat > "${LIVEKIT_DIR}/coturn.conf" <<'CONF' +# PLACEHOLDER — replace with infra/livekit/coturn.prod.conf.example contents. +# Set external-ip to this VPS's public IP, point cert/pkey at the TLS cert for +# turn.netralax.de, and set a real lt-cred-mech user/password. +CONF + log "wrote placeholder ${LIVEKIT_DIR}/coturn.conf" +fi +if [[ ! -f "${LIVEKIT_DIR}/docker-compose.yml" ]]; then + cat > "${LIVEKIT_DIR}/docker-compose.yml" <<'YAML' +# PLACEHOLDER — replace with infra/livekit/docker-compose.prod.yml.example. +# The dev infra/livekit/docker-compose.yml is NOT suitable for prod (coturn runs +# with --no-tls, no 5349, no cert). The prod compose uses network_mode: host, +# mounts ./livekit.yaml + ./coturn.conf, runs coturn with -c turnserver.conf, +# and mounts /etc/letsencrypt for the turn.netralax.de TURNS cert. +YAML + log "wrote placeholder ${LIVEKIT_DIR}/docker-compose.yml" +fi + +# --- Caddy (official apt repo) -------------------------------------------- +if command -v caddy >/dev/null 2>&1; then + log "caddy already installed — skipping" +else + log "installing Caddy (official repo)" + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + > /etc/apt/sources.list.d/caddy-stable.list + apt-get update -y + apt-get install -y caddy + systemctl enable caddy +fi + +# Write a placeholder Caddyfile if none exists (do not clobber a real one). +if [[ ! -s /etc/caddy/Caddyfile ]] || grep -q 'PLACEHOLDER' /etc/caddy/Caddyfile 2>/dev/null; then + cat > /etc/caddy/Caddyfile <<'CADDY' +# PLACEHOLDER Caddyfile — replace with infra/caddy/Caddyfile from the repo. +# Serve the .de vhosts now; add the legacy .cloud vhosts only at cutover (after +# the .cloud DNS is repointed) so they keep already-installed clients working: +# supabase.netralax.de { reverse_proxy localhost:8000 } +# livekit.netralax.de { reverse_proxy localhost:7880 } +# update.netralax.de { root * /var/www/updates # NOT .../windows — see Caddyfile +# file_server } +CADDY + log "wrote placeholder /etc/caddy/Caddyfile" +fi + +# --- update host + deploy user -------------------------------------------- +log "preparing update host at ${UPDATES_DIR}" +mkdir -p "${UPDATES_DIR}" + +if id "${DEPLOY_USER}" >/dev/null 2>&1; then + log "user ${DEPLOY_USER} already exists — skipping" +else + log "creating deploy user ${DEPLOY_USER}" + useradd --create-home --shell /bin/bash "${DEPLOY_USER}" + mkdir -p "/home/${DEPLOY_USER}/.ssh" + chmod 700 "/home/${DEPLOY_USER}/.ssh" + touch "/home/${DEPLOY_USER}/.ssh/authorized_keys" + chmod 600 "/home/${DEPLOY_USER}/.ssh/authorized_keys" + chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh" +fi +# Let the deploy user write release artifacts. +chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${UPDATES_DIR}" + +# --- next steps ------------------------------------------------------------ +cat < NEW (.de). Run this FROM THE DEV LAPTOP +# (Linux / macOS / WSL), not on a server. It: +# 1. pre-flight checks both stacks are reachable and the DB containers are up, +# 2. streams a full-cluster pg_dumpall from OLD straight into NEW (psql), +# 3. rsyncs ${SUPABASE_DIR}/volumes/storage from OLD to NEW. +# +# Usage: +# ./scripts/migrate/02-migrate-data.sh # interactive, asks to confirm +# ./scripts/migrate/02-migrate-data.sh --check # pre-flight only, no changes +# FORCE=1 ./scripts/migrate/02-migrate-data.sh # skip the confirm prompt +# +# BEFORE running: put the OLD app into maintenance / freeze writes, and make +# sure ${SUPABASE_DIR}/.env on NEW already has the SAME POSTGRES_PASSWORD and +# JWT_SECRET as OLD, and that NEW's db container has been started once so the +# Supabase init scripts created the roles (see bootstrap NEXT STEPS step 4). + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${here}/config.sh" + +require_new_host + +mode="${1:-}" + +log() { echo "==> $*"; } + +# --- pre-flight ------------------------------------------------------------ +log "pre-flight: checking SSH reachability" +old_remote 'echo ok' >/dev/null || { echo "cannot ssh to OLD (${OLD_SSH})" >&2; exit 1; } +new_remote 'echo ok' >/dev/null || { echo "cannot ssh to NEW (${NEW_SSH})" >&2; exit 1; } + +log "pre-flight: checking OLD Supabase db is up" +old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \ + || { echo "OLD db not ready — start the stack first" >&2; exit 1; } + +log "pre-flight: checking NEW Supabase db is up (must be initialized once)" +new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \ + || { echo "NEW db not ready — run 'docker compose up -d db' on NEW first" >&2; exit 1; } + +log "pre-flight: checking NEW storage volume dir exists" +new_remote "test -d ${SUPABASE_DIR}/volumes/storage || mkdir -p ${SUPABASE_DIR}/volumes/storage" + +if [[ "${mode}" == "--check" ]]; then + log "pre-flight OK — --check requested, stopping before any changes." + exit 0 +fi + +# --- loud confirm ---------------------------------------------------------- +cat < NEW ${NEW_SSH} +---------------------------------------------------------------------------- + This will: + * pg_dumpall the WHOLE OLD cluster and restore it into the NEW db + (DROP/CREATE objects on NEW via --clean --if-exists), + * rsync ${SUPABASE_DIR}/volumes/storage OLD -> NEW with --delete + (the NEW storage dir becomes an EXACT mirror of OLD). + + MAKE SURE FIRST: + * the OLD app is in MAINTENANCE / writes are FROZEN (no new uploads, + no new rows) so DB + storage stay consistent, + * NEW /opt/supabase/.env already has the OLD POSTGRES_PASSWORD + JWT_SECRET, + * you have a backup / you can roll DNS back to the OLD VPS. +---------------------------------------------------------------------------- +EOF + +if [[ "${FORCE:-0}" != "1" ]]; then + read -rp "Type 'migrate' to proceed: " confirm + if [[ "${confirm}" != "migrate" ]]; then + echo "aborted — nothing changed." + exit 1 + fi +fi + +# --- 1) Postgres: full-cluster dump OLD -> restore NEW --------------------- +# pg_dumpall (not pg_dump) carries the ROLE definitions + password hashes, so +# with an identical POSTGRES_PASSWORD on both hosts the restored roles line up +# with what the services use. ON_ERROR_STOP=0 because pg_dumpall will try to +# CREATE ROLE supabase_admin/postgres etc. that already exist on the freshly +# initialized NEW cluster — those 'already exists' errors are harmless. +# +# ALTERNATIVE (highest fidelity): if both servers run the SAME Postgres image +# tag, a cold volume copy avoids logical-restore-over-initialized-cluster +# fragility entirely: stop both DB containers, rsync ${SUPABASE_DIR}/volumes/db +# OLD -> NEW, start both again. Use that if the scan below keeps flagging errors. +log "dumping OLD cluster and restoring into NEW (streamed over SSH)" +log "this can take a while; harmless 'already exists' errors are expected." +restore_log="$(mktemp)" +set +e +old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists" \ + | new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0" \ + 2>&1 | tee "${restore_log}" +set -e + +# ON_ERROR_STOP=0 keeps the restore going past harmless 'already exists', but it +# ALSO swallows genuine failures (FK/constraint/ownership/extension errors) +# that would leave a partially-restored DB looking successful. Surface any +# non-benign ERROR/FATAL/PANIC and refuse to continue to the storage rsync. +real_errors="$(grep -E 'ERROR:|FATAL:|PANIC:' "${restore_log}" 2>/dev/null \ + | grep -Eiv 'already exists|cannot drop the currently open database|is being accessed by other users|must be member of role|role .* cannot be dropped|current transaction is aborted' \ + || true)" +if [[ -n "${real_errors}" ]]; then + echo >&2 + echo "!!! Non-benign errors during restore (full log: ${restore_log}):" >&2 + echo "${real_errors}" | head -n 50 >&2 + if [[ "${FORCE_RESTORE_OK:-0}" != "1" ]]; then + echo "Aborting BEFORE the storage rsync. Inspect/fix and re-run, or consider" >&2 + echo "the cold volume-copy path. Override with FORCE_RESTORE_OK=1 only if you" >&2 + echo "are certain these are harmless." >&2 + exit 1 + fi + log "FORCE_RESTORE_OK=1 — continuing despite the errors above." +else + log "restore output scanned: no non-benign errors found." +fi +log "DO NOT run push-migrations.sh: all migrations are already in the dump." + +# --- 2) Storage objects: rsync OLD -> NEW ---------------------------------- +# Object bytes live on the bind-mounted volume; their metadata rows came with +# the dump above. -aHAX keeps perms/hardlinks/ACLs/xattrs; --delete makes NEW an +# exact mirror (safe only because writes are frozen). Trailing slashes matter. +log "rsyncing storage volume OLD -> NEW (server-to-server via SSH)" +if old_remote "command -v rsync >/dev/null 2>&1"; then + # Direct server-to-server: the OLD host pushes to NEW. Needs the OLD host to + # be able to ssh to NEW (key in OLD ~/.ssh, NEW in known_hosts). + old_remote "sudo rsync -aHAX --numeric-ids --delete \ + -e 'ssh -o StrictHostKeyChecking=accept-new' \ + ${SUPABASE_DIR}/volumes/storage/ ${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" \ + || { + log "direct server-to-server rsync failed — falling back to two-hop via laptop" + stage="$(mktemp -d)" + log "staging into ${stage}" + # shellcheck disable=SC2086 + rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ + "${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/" + # shellcheck disable=SC2086 + rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \ + "${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" + rm -rf "${stage}" + } +else + log "rsync missing on OLD — using two-hop via laptop" + stage="$(mktemp -d)" + log "staging into ${stage}" + # shellcheck disable=SC2086 + rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ + "${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/" + # shellcheck disable=SC2086 + rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \ + "${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" + rm -rf "${stage}" +fi + +# --- 3) Restart NEW stack so every service reconnects to the new data ------ +log "restarting the NEW Supabase stack (down + up -d)" +new_remote "cd ${SUPABASE_DIR} && docker compose down && docker compose up -d" + +# --- 4) Row-count parity check OLD vs NEW (load-bearing tables) ------------- +# A users/objects-only check can miss partial loss in messages/members/etc., +# so compare the tables the app actually depends on. Non-fatal (table names can +# legitimately vary), but a mismatch on auth.users / public.messages is a red +# flag — do NOT cut over until it is understood. +log "waiting for NEW db to accept connections, then checking row-count parity" +for _ in $(seq 1 30); do + new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" >/dev/null 2>&1 && break + sleep 2 +done +count_on() { # $1=old|new $2=table + local q="select count(*) from $2;" + local runner=old_remote + [[ "$1" == "new" ]] && runner=new_remote + "${runner}" "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -tAc \"${q}\"" 2>/dev/null | tr -d '[:space:]' +} +parity_fail=0 +for t in auth.users auth.identities public.profiles public.messages \ + public.conversation_members storage.objects; do + o="$(count_on old "$t" 2>/dev/null || echo '?')" + n="$(count_on new "$t" 2>/dev/null || echo '?')" + if [[ -n "$o" && "$o" == "$n" ]]; then + log " OK ${t}: ${o}" + else + log " MISMATCH ${t}: OLD=${o:-?} NEW=${n:-?}" + parity_fail=1 + fi +done +[[ "${parity_fail}" == "1" ]] && log "⚠ row-count mismatch — investigate BEFORE cutover." + +cat < take the OLD value +// - for every key that exists ONLY on OLD -> append it (this is how the +// custom edge secrets VAPID_*/PUSH_FANOUT_SHARED_SECRET/LIVEKIT_API_* +// survive — they are not in the fresh upstream .env) +// - keys ONLY on NEW -> keep their fresh default +// - finally, the OVERRIDES below are upserted (public host = .de) +// +// Usage: +// node scripts/migrate/03-copy-secrets.mjs --check # show plan, change nothing +// node scripts/migrate/03-copy-secrets.mjs # back up + apply on NEW +// +// Pre-req: SSH works to BOTH hosts (prox@OLD, debian@NEW) and NEW's .env exists +// (bootstrap step done). OLD/NEW are read from scripts/migrate/config.sh. + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const here = dirname(fileURLToPath(import.meta.url)); + +// --- read hosts from config.sh (single source of truth) -------------------- +const cfg = readFileSync(join(here, 'config.sh'), 'utf8'); +const cfgVal = (name) => { + const m = cfg.match(new RegExp(`^export ${name}="([^"]*)"`, 'm')); + if (!m) throw new Error(`could not find ${name} in config.sh`); + return m[1]; +}; +const OLD_USER = cfgVal('OLD_USER'); +const OLD_HOST = cfgVal('OLD_HOST'); +const NEW_USER = cfgVal('NEW_USER'); +const NEW_HOST = cfgVal('NEW_HOST'); +const SUPABASE_DIR = cfgVal('SUPABASE_DIR'); +const ENV_PATH = `${SUPABASE_DIR}/.env`; +const OLD_SSH = `${OLD_USER}@${OLD_HOST}`; +const NEW_SSH = `${NEW_USER}@${NEW_HOST}`; +const SSH_OPTS = ['-o', 'StrictHostKeyChecking=accept-new']; + +if (NEW_HOST === '__NETRALAX_DE_SERVER_IP__' || !NEW_HOST) { + console.error('NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first.'); + process.exit(1); +} + +// --- public-host overrides (forced to .de AFTER the merge) ----------------- +// NOTE: SUPABASE_URL is deliberately NOT overridden — for the edge-runtime it +// is the INTERNAL gateway URL and is handled by the compose env in §8, not here. +const NEW_SITE = 'https://supabase.netralax.de'; +const NEW_LIVEKIT = 'wss://livekit.netralax.de'; +const OVERRIDES = { + SITE_URL: NEW_SITE, + API_EXTERNAL_URL: NEW_SITE, + SUPABASE_PUBLIC_URL: NEW_SITE, + LIVEKIT_URL: NEW_LIVEKIT, + ADDITIONAL_REDIRECT_URLS: + 'chatapp://auth/callback,netralax://auth/callback,' + + 'https://supabase.netralax.de,https://supabase.netralax.cloud', +}; + +// Continuity-critical keys: their value MUST end up identical to OLD. +const CRITICAL = [ + 'JWT_SECRET', 'ANON_KEY', 'SERVICE_ROLE_KEY', 'POSTGRES_PASSWORD', + 'VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'PUSH_FANOUT_SHARED_SECRET', + 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET', +]; + +const check = process.argv.includes('--check'); + +// --- ssh helpers (values flow via stdio, never via argv) ------------------- +function ssh(target, remoteCmd, input) { + return execFileSync('ssh', [...SSH_OPTS, target, remoteCmd], { + encoding: 'utf8', + input: input ?? undefined, + maxBuffer: 16 * 1024 * 1024, + }); +} +const readOldEnv = () => ssh(OLD_SSH, `sudo cat ${ENV_PATH} 2>/dev/null || cat ${ENV_PATH}`); +const readNewEnv = () => ssh(NEW_SSH, `sudo cat ${ENV_PATH}`); + +// --- env parsing (split on FIRST '='; keep comments/blank lines as raw) ----- +const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/; +function parse(text) { + const map = new Map(); + for (const line of text.split('\n')) { + const m = line.match(KEY_RE); + if (m) map.set(m[1], m[2]); + } + return map; +} +const sha = (s) => createHash('sha256').update(s ?? '').digest('hex').slice(0, 12); + +// --- main ------------------------------------------------------------------ +console.log(`OLD: ${OLD_SSH} NEW: ${NEW_SSH} file: ${ENV_PATH}\n`); + +let oldText, newText; +try { oldText = readOldEnv(); } catch (e) { + console.error(`Failed to read OLD .env via ssh ${OLD_SSH}.\n${e.message}`); + process.exit(1); +} +try { newText = readNewEnv(); } catch (e) { + console.error(`Failed to read NEW .env via ssh ${NEW_SSH} (bootstrap done?).\n${e.message}`); + process.exit(1); +} + +const oldMap = parse(oldText); +const newMap = parse(newText); +const onlyOld = [...oldMap.keys()].filter((k) => !newMap.has(k)).sort(); +const onlyNew = [...newMap.keys()].filter((k) => !oldMap.has(k)).sort(); +const shared = [...oldMap.keys()].filter((k) => newMap.has(k)).sort(); + +console.log(`shared keys (value taken from OLD): ${shared.length}`); +console.log(`OLD-only keys (appended — incl. custom edge secrets): ${onlyOld.length}`); +onlyOld.forEach((k) => console.log(` + ${k}`)); +console.log(`NEW-only keys (kept at fresh default): ${onlyNew.length}`); +onlyNew.forEach((k) => console.log(` . ${k}`)); +console.log(`\noverrides forced to the .de host:`); +for (const [k, v] of Object.entries(OVERRIDES)) console.log(` ${k}=${v}`); + +// sanity: warn if a continuity-critical key is missing on OLD +const missingCrit = CRITICAL.filter((k) => !oldMap.has(k)); +if (missingCrit.length) { + console.log(`\n⚠ NOTE: these critical keys are absent on OLD (verify they aren't named differently): ${missingCrit.join(', ')}`); +} + +// --- build merged content (preserve NEW order/comments) -------------------- +const used = new Set(); +let lines = newText.split('\n').map((line) => { + const m = line.match(KEY_RE); + if (m && oldMap.has(m[1])) { used.add(m[1]); return `${m[1]}=${oldMap.get(m[1])}`; } + return line; +}); +// append OLD-only keys +if (onlyOld.length) { + if (lines.length && lines[lines.length - 1] !== '') lines.push(''); + lines.push('# --- merged from OLD server (keys not present in fresh upstream .env) ---'); + for (const k of onlyOld) { lines.push(`${k}=${oldMap.get(k)}`); used.add(k); } +} +// upsert overrides +for (const [k, v] of Object.entries(OVERRIDES)) { + let hit = false; + lines = lines.map((line) => { + const m = line.match(KEY_RE); + if (m && m[1] === k) { hit = true; return `${k}=${v}`; } + return line; + }); + if (!hit) lines.push(`${k}=${v}`); +} +const merged = lines.join('\n'); + +if (check) { + console.log('\n--check: nothing written. Re-run without --check to apply.'); + process.exit(0); +} + +// --- apply on NEW: backup, then write via `sudo tee` (content via stdin) ---- +console.log('\nbacking up NEW .env and writing merged result...'); +ssh(NEW_SSH, `sudo cp ${ENV_PATH} ${ENV_PATH}.bak.$(date +%s)`); +ssh(NEW_SSH, `sudo tee ${ENV_PATH} > /dev/null`, merged.endsWith('\n') ? merged : merged + '\n'); + +// --- verify continuity: critical values identical OLD vs NEW --------------- +const newAfter = parse(readNewEnv()); +console.log('\nverifying continuity (OLD value == NEW value):'); +let fail = 0; +for (const k of CRITICAL) { + if (!oldMap.has(k)) { console.log(` skip ${k} (not on OLD)`); continue; } + const ok = oldMap.get(k) === newAfter.get(k); + console.log(` ${ok ? 'OK ' : 'FAIL'} ${k} (sha ${sha(oldMap.get(k))} vs ${sha(newAfter.get(k))})`); + if (!ok) fail++; +} +console.log('\noverrides now on NEW:'); +for (const k of Object.keys(OVERRIDES)) console.log(` ${k}=${newAfter.get(k)}`); + +if (fail) { + console.error(`\n✗ ${fail} critical key(s) did not match — DO NOT proceed. Restore from the .bak.* backup and investigate.`); + process.exit(1); +} +console.log('\n✓ secrets merged; JWT_SECRET + VAPID + LiveKit keys are identical to OLD. Continue with runbook §6 (LiveKit/coturn) and §5 (data).'); diff --git a/scripts/migrate/README.md b/scripts/migrate/README.md new file mode 100644 index 0000000..d9a292f --- /dev/null +++ b/scripts/migrate/README.md @@ -0,0 +1,132 @@ +# Server-Umzug: netralax.cloud -> netralax.de + +Einmalige Migration des selbst gehosteten Chat-Backends vom **alten VPS** +(`46.225.156.249`, `*.netralax.cloud`) auf einen **neuen, leeren VPS** +(`*.netralax.de`). Der neue Server bedient anschliessend **beide** Domains, +damit bereits installierte Desktop-/Mobile-Clients (die alte Hostnamen und den +alten anon-JWT fest eingebaut haben) weiterlaufen, bis sie sich selbst +aktualisieren. + +> Diese Skripte sind bewusst getrennt von `scripts/prod/`. `scripts/prod/config.sh` +> kennt nur den jeweils **aktiven** Server; der Umzug braucht **beide** Hosts und +> hat deshalb seine eigene `scripts/migrate/config.sh`. + +## Dateien + +| Datei | Wo ausführen | Zweck | +|-------|--------------|-------| +| `config.sh` | – | Gemeinsame Konfiguration (alter + neuer Host, SSH-Helfer). Wird von den anderen Skripten eingebunden. | +| `01-bootstrap-new-server.sh` | **auf dem neuen VPS** (als root / sudo) | Richtet den leeren Server ein: Docker, ufw, Supabase-Clone, LiveKit-Verzeichnis, Caddy, Update-Host, Deploy-User. | +| `02-migrate-data.sh` | **auf dem Entwickler-Laptop** | Überträgt Postgres-Daten (pg_dumpall) und die Storage-Objekte (rsync) von alt nach neu. | + +## Voraussetzungen / Einrichtung (einmalig) + +1. **Neue Server-IP — bereits eingetragen.** `scripts/migrate/config.sh` hat + `NEW_HOST="141.95.34.204"` und `NEW_USER="debian"`. (Der `require_new_host`- + Guard greift nur, falls der Platzhalter wieder drinsteht.) + +2. **SSH-Zugriff.** Vom Laptop muss `ssh prox@46.225.156.249` (alt) **und** + `ssh debian@141.95.34.204` (neu) ohne Passwort funktionieren: + ``` + ssh-copy-id prox@46.225.156.249 + ssh-copy-id debian@141.95.34.204 + ``` + Für den direkten Storage-Transfer (Server-zu-Server) muss zusätzlich der + **alte** Server per SSH auf den **neuen** zugreifen können. Klappt das nicht, + fällt `02-migrate-data.sh` automatisch auf den Umweg über den Laptop zurück. + +3. **Skripte ausführbar machen:** + ``` + chmod +x scripts/migrate/*.sh + ``` + +## Ablauf (Reihenfolge unbedingt einhalten) + +1. **Bootstrap auf dem neuen Server.** Skript hochladen und als root ausführen: + ``` + scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/ + ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh' + ``` + Das Skript ist idempotent (mehrfaches Ausführen schadet nicht) und gibt am + Ende einen **NEXT STEPS**-Block aus. + +2. **Secrets eintragen.** `/opt/supabase/.env` auf dem neuen Server befüllen. + Diese Werte **1:1 vom alten Server kopieren** (sonst brechen eingebaute + Tokens, Sessions und Web-Push): + `POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, + `SECRET_KEY_BASE`, `VAULT_ENC_KEY`, `PG_META_CRYPTO_KEY`, alle `SMTP_*`, + `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`, + `PUSH_FANOUT_SHARED_SECRET`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`. + Auf die **neue** Domain zeigen: + `SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL`, `SUPABASE_URL` + = `https://supabase.netralax.de`, `LIVEKIT_URL` = `wss://livekit.netralax.de`. + `ADDITIONAL_REDIRECT_URLS` (komma-getrennt, **ohne Leerzeichen**) muss + enthalten: `chatapp://auth/callback`, `netralax://auth/callback` sowie + `https://supabase.netralax.de` und `https://supabase.netralax.cloud`. + +3. **Server-Konfig platzieren.** + - `infra/livekit/docker-compose.prod.yml.example` -> `/opt/livekit/docker-compose.yml` + (Prod-Compose: host-networking, mountet `livekit.yaml` + `coturn.conf` + + `/etc/letsencrypt`; das Dev-Compose taugt **nicht** für Prod). + - `infra/livekit/livekit.prod.yaml.example` -> `/opt/livekit/livekit.yaml` + (`rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, `keys:`-Block + identisch zu `LIVEKIT_API_KEY/SECRET` aus der `.env`). + - `infra/livekit/coturn.prod.conf.example` -> `/opt/livekit/coturn.conf` + (`external-ip` = öffentliche IP des neuen VPS, TLS-Cert für + `turn.netralax.de`). + - `infra/caddy/Caddyfile` -> `/etc/caddy/Caddyfile`, danach + `systemctl reload caddy`. Caddy bedient **beide** Domains (.de und .cloud). + +4. **Stacks starten – DB zuerst einmal hochfahren**, damit die Supabase-Init- + Skripte die Rollen anlegen (vor dem Restore): + ``` + ssh debian@141.95.34.204 'cd /opt/supabase && docker compose up -d db && sleep 20' + ``` + +5. **Schreibzugriffe auf dem ALTEN System einfrieren** (Wartungsmodus). Sonst + landen während des Umzugs neue Uploads/Zeilen nur auf einer Seite und + DB + Storage werden inkonsistent. + +6. **Daten migrieren** (vom Laptop). Erst der Trockenlauf, dann die Migration: + ``` + ./scripts/migrate/02-migrate-data.sh --check # nur Pre-Flight, keine Änderung + ./scripts/migrate/02-migrate-data.sh # fragt nach Bestätigung + ``` + Das Skript dumpt den **gesamten** Cluster per `pg_dumpall` und spielt ihn auf + dem neuen Server ein, danach rsync der Storage-Objekte. `push-migrations.sh` + **nicht** erneut ausführen – die Migrationen sind bereits im Dump enthalten. + +7. **DNS umstellen.** A-Records für **beide** Domains auf die neue IP zeigen + lassen: `supabase.netralax.de` / `.cloud`, `livekit.netralax.de` / `.cloud`, + `turn.netralax.de`, `update.netralax.de` / `.cloud`. + +8. **Update-Artefakte spiegeln.** electron-updater-Dateien (`latest.yml`, + `*.exe`, `changelog.json`) unter `/var/www/updates/windows` ablegen, sodass + **sowohl** `update.netralax.de` **als auch** `update.netralax.cloud` sie + ausliefern. Nur so können alte (.cloud-)Clients die Umstiegs-Version ziehen. + +## Sicherheitshinweise + +- **`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`** müssen byteweise identisch + vom alten Server stammen, **bevor** der erste Client den neuen Server trifft – + sonst werden alle eingebauten Tokens abgelehnt und alle Sessions fliegen raus. +- **`POSTGRES_PASSWORD`** muss vor dem Restore identisch gesetzt sein, weil der + Dump die Rollen-Passwort-Hashes mitbringt. Sonst können sich die internen + Dienste (auth/rest/storage) nach dem Restore nicht mehr an Postgres anmelden. +- **VAPID-Schlüsselpaar** identisch übernehmen, sonst sind alle bestehenden + Web-Push-Abos ungültig. +- **Medien-/TURN-Ports** müssen in ufw offen sein (7880/7881 tcp, 50000-50100 + udp, coturn 3478 tcp+udp, 5349 tcp, 50200-50300 udp) – sonst haben Anrufe kein + Audio/Video. Diese Ports laufen **nicht** über Caddy. +- **TURNS auf 5349** braucht ein eigenes TLS-Zertifikat für `turn.netralax.de` + auf der Platte (Pfade in `coturn.conf`) – ein reines Caddy-Zertifikat reicht + nicht. +- **Alten VPS nicht abschalten**, bevor die `.cloud`-DNS-Einträge auf den neuen + Server zeigen und alte Clients Zeit zum Auto-Update hatten. +- Beim Restore werden harmlose `already exists`-Fehler für vorhandene Rollen + (`supabase_admin`, `postgres` …) ausgegeben – das ist gewollt + (`ON_ERROR_STOP=0`). `02-migrate-data.sh` scannt die Restore-Ausgabe + **automatisch** auf echte `ERROR/FATAL/PANIC` und **bricht vor dem Storage- + rsync ab**, wenn welche übrig bleiben (Override: `FORCE_RESTORE_OK=1`). + Danach macht es eine Zeilen-Paritätsprüfung (alt vs. neu) über die tragenden + Tabellen. diff --git a/scripts/migrate/config.sh b/scripts/migrate/config.sh new file mode 100644 index 0000000..e76386f --- /dev/null +++ b/scripts/migrate/config.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Shared config for the one-time netralax.cloud -> netralax.de server move. +# +# This is SEPARATE from scripts/prod/config.sh on purpose: the migration knows +# BOTH the old (.cloud) and the new (.de) host, whereas scripts/prod/config.sh +# only ever points at the live server. Source this in each migrate script: +# source "$(dirname "$0")/config.sh" +# +# Fill NEW_HOST once the netralax.de VPS exists. OLD_HOST is the .cloud VPS. + +# Old, currently-live VPS (Supabase + LiveKit on *.netralax.cloud). +export OLD_HOST="46.225.156.249" +export OLD_USER="prox" + +# New, empty VPS that will serve *.netralax.de (and keep serving *.netralax.cloud +# for already-installed clients). Fill in the IP before running 02-migrate-data.sh. +# NOTE: the login user on the new .de VPS is "debian" (the old .cloud VPS uses "prox"). +export NEW_HOST="141.95.34.204" +export NEW_USER="debian" + +# Paths on BOTH servers (same layout on old and new). +export SUPABASE_DIR="/opt/supabase" +export LIVEKIT_DIR="/opt/livekit" + +# SSH helper opts: accept new host keys on first connect without prompting. +# Override SSH_OPTS from the environment if you need a jumphost etc. +export SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}" + +# Convenience SSH targets. +export OLD_SSH="${OLD_USER}@${OLD_HOST}" +export NEW_SSH="${NEW_USER}@${NEW_HOST}" + +# Run a command on the OLD server. +old_remote() { + # shellcheck disable=SC2086 + ssh ${SSH_OPTS} "${OLD_SSH}" "$@" +} + +# Run a command on the NEW server. +new_remote() { + # shellcheck disable=SC2086 + ssh ${SSH_OPTS} "${NEW_SSH}" "$@" +} + +# Guard: refuse to run anything against the unfilled new-host placeholder. +require_new_host() { + if [[ "${NEW_HOST}" == "__NETRALAX_DE_SERVER_IP__" || -z "${NEW_HOST}" ]]; then + echo "NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first." >&2 + exit 1 + fi +} diff --git a/scripts/prod/README.md b/scripts/prod/README.md index a2af0d1..8d4ce3c 100644 --- a/scripts/prod/README.md +++ b/scripts/prod/README.md @@ -8,8 +8,9 @@ All commands read shared config from `config.sh`. 1. Copy your SSH key to the server so scripts don't prompt for a password: ``` ssh-keygen -t ed25519 # only if you don't already have one - ssh-copy-id prox@46.225.156.249 - ssh prox@46.225.156.249 'echo ok' + # PROD now points at the netralax.de VPS (user "debian"; see config.sh). + ssh-copy-id debian@141.95.34.204 + ssh debian@141.95.34.204 'echo ok' ``` 2. Make the scripts executable: ``` diff --git a/scripts/prod/config.sh b/scripts/prod/config.sh index 4a9b826..bbfc84c 100755 --- a/scripts/prod/config.sh +++ b/scripts/prod/config.sh @@ -5,16 +5,24 @@ # Customize here when the server IP / domains change — all other scripts pick # the values up automatically. -export PROD_SERVER="46.225.156.249" -export PROD_USER="prox" +# End state after the netralax.de migration. The old .cloud VPS was +# 46.225.156.249 — for the one-time move (data dump/restore, storage rsync) +# use scripts/migrate/, which knows the old host explicitly. Fill in the new +# server IP once the netralax.de VPS exists, then this becomes the live config +# for all push-migrations / push-edge-function / create-invite / logs scripts. +export PROD_SERVER="141.95.34.204" +# Login user on the new .de VPS is "debian" (the old .cloud VPS used "prox"). +export PROD_USER="debian" # Paths on the remote server. export PROD_SUPABASE_DIR="/opt/supabase" export PROD_LIVEKIT_DIR="/opt/livekit" -# Public domains (served via Caddy on the same VPS). -export PROD_DOMAIN_SUPABASE="supabase.netralax.cloud" -export PROD_DOMAIN_LIVEKIT="livekit.netralax.cloud" +# Public domains (served via Caddy on the new VPS). Caddy also keeps serving +# the legacy supabase.netralax.cloud / livekit.netralax.cloud vhosts (same +# backends) so already-installed clients keep working until they auto-update. +export PROD_DOMAIN_SUPABASE="supabase.netralax.de" +export PROD_DOMAIN_LIVEKIT="livekit.netralax.de" # SSH helper: forwards the standard `-o StrictHostKeyChecking=accept-new` so # first connections don't prompt. Override SSH_OPTS from the environment if