From 31e17ff77b524982781d68f5280df983b7d2f1c6 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 19 Jul 2026 17:19:03 +0200 Subject: [PATCH] HTTPS: eigene lokale Root-CA statt externem DuckDNS/Let's Encrypt --- .env.example | 12 +++-- README.md | 80 ++++++++++++++++------------ apps/frontend/docker-entrypoint.sh | 85 +++++++++++++++++++++--------- apps/frontend/nginx-locations.conf | 8 +++ docker-compose.yml | 13 +++-- docs/ROADMAP.md | 31 +++++++++++ 6 files changed, 160 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index a956390..e17fd70 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,11 @@ BACKEND_URL=http://localhost:3001 # ─── HTTPS im lokalen Netz ────────────────────────────────────────── # IP-Adresse oder Hostname, unter der du LaunchPad im LAN aufrufst -# (z. B. https://192.168.1.49:8443). Wird für das beim Containerstart -# automatisch erzeugte selbstsignierte Zertifikat verwendet. Ohne eigenes -# Zertifikat zeigt der Browser trotzdem eine Warnung – siehe README für eine -# Variante ohne Warnung (mkcert). Ändern erfordert einen Neustart des -# Frontend-Containers: docker compose up -d --force-recreate frontend +# (z. B. https://192.168.1.49:8443). Der Frontend-Container erzeugt daraus +# automatisch eine eigene lokale Root-CA + ein davon signiertes Zertifikat +# (komplett offline, keine externen Dienste). Die CA liegt danach zum +# Download unter http://:8080/ca.crt bereit – einmal pro +# Gerät importiert/als vertrauenswürdig eingestuft, verschwindet die +# Browser-Warnung dauerhaft. Siehe README für die genauen Schritte. +# Ändern erfordert einen Neustart: docker compose up -d --force-recreate frontend LAUNCHPAD_HOST=192.168.1.49 diff --git a/README.md b/README.md index d3f8a61..f581274 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,25 @@ docker compose up -d --build ## HTTPS im lokalen Netz LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://:8443`), -zusätzlich zu HTTP auf `8080` – kein erzwungener Redirect. Beim ersten Start -erzeugt der Frontend-Container automatisch ein selbstsigniertes Zertifikat für -die in `.env` hinterlegte `LAUNCHPAD_HOST` (IP oder Hostname). Der Browser -zeigt dafür trotzdem eine Sicherheitswarnung – das ist bei selbstsignierten -Zertifikaten normal, nicht per se unsicher fürs eigene LAN. +zusätzlich zu HTTP auf `8080` – kein erzwungener Redirect, kein externer +Dienst beteiligt. + +Der Frontend-Container erzeugt beim ersten Start automatisch eine **eigene +lokale Root-CA** (`LaunchPad Local CA`) und signiert damit ein Server- +Zertifikat für die in `.env` hinterlegte `LAUNCHPAD_HOST` (IP oder Hostname). +Beides liegt in einem Docker-Volume (`launchpad-certs`) und bleibt über +Neustarts und `--build` hinweg stabil – die CA wird nur einmal erzeugt, nicht +bei jedem Deploy neu. + +**Ohne weiteren Schritt** funktioniert HTTPS sofort, der Browser zeigt aber +eine Warnung (unbekannte CA). Für eine echte, warnungsfreie Verbindung – und +damit Chrome die PWA auch wirklich "installiert" statt nur eine Verknüpfung +anzulegen – reicht **ein einziger, pro Gerät einmaliger Schritt**: die +Root-CA importieren. Es gibt dafür keinen Weg drumherum, der nicht doch einen +externen Dienst braucht (siehe Kasten unten) – aber so ist es so einfach wie +möglich gehalten. + +### Root-CA einmal pro Gerät importieren ```bash # .env: IP/Hostname eintragen, unter der du zugreifst @@ -33,40 +47,40 @@ LAUNCHPAD_HOST=192.168.1.49 docker compose up -d --force-recreate frontend ``` -Danach: `https://192.168.1.49:8443` (Warnung einmalig bestätigen/Ausnahme -hinzufügen). +**Am Handy (Android):** `http://192.168.1.49:8080/ca.crt` im Browser öffnen +(bewusst HTTP, dafür braucht's noch kein Vertrauen) → Android bietet direkt +"Zertifikat installieren" an → als "VPN und App" oder "WLAN"-Zertifikat +speichern (je nach Android-Version heißt die Kategorie leicht anders, +Hauptsache es landet unter "Von Nutzer installierte Zertifikate"). Danach ggf. +kurz Chrome neu starten. -### Ohne Browser-Warnung (empfohlen): eigenes Zertifikat per mkcert +**Am PC (Windows):** dieselbe URL im Browser öffnen, `ca.crt` herunterladen, +Datei doppelklicken → "Zertifikat installieren" → "Lokaler Computer" → +"Alle Zertifikate in folgendem Speicher speichern" → "Vertrauenswürdige +Stammzertifizierungsstellen" → Fertigstellen. Kein zusätzliches Tool nötig. -[mkcert](https://github.com/FiloSottile/mkcert) erzeugt lokal vertrauenswürdige -Zertifikate – dein Browser vertraut ihnen automatisch, keine Warnung mehr. +**macOS:** `ca.crt` herunterladen, doppelklicken (öffnet Schlüsselbundverwaltung), +dort öffnen, Vertrauenseinstellung auf "Immer vertrauen" setzen. -```bash -# Einmalig auf dem Rechner, von dem aus du zugreifst (nicht auf dem Server): -mkcert -install +Danach zeigt `https://192.168.1.49:8443` keine Warnung mehr, und die +PWA-Installation sollte funktionieren. -# Zertifikat für IP/Hostname erzeugen: -mkcert -cert-file fullchain.pem -key-file privkey.pem 192.168.1.49 launchpad.home localhost 127.0.0.1 -``` +> **Warum es keinen Weg ganz ohne diesen Schritt gibt:** Damit ein Browser +> einem Zertifikat *automatisch*, ohne jeden manuellen Trust-Schritt, vertraut, +> muss es entweder von einer öffentlich vorinstallierten CA stammen (z. B. +> Let's Encrypt) – das erfordert zwangsläufig einen externen Dienst und meist +> einen öffentlich auflösbaren Domainnamen – oder eben eine lokale CA, die du +> einmal manuell für vertrauenswürdig erklärst. Es gibt keine dritte Option. +> Diese Lösung minimiert den Aufwand auf "eine Datei öffnen, einmal pro Gerät", +> mehr geht ohne externe Dienste nicht. -Die beiden erzeugten Dateien nach `/opt/LaunchPad/certs/` kopieren, dann in -`docker-compose.yml` die auskommentierten `volumes:`-Zeilen beim -`frontend`-Service aktivieren: +### Zertifikat wechseln / eigenes einbinden -```yaml -volumes: - - ./certs/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro - - ./certs/privkey.pem:/etc/nginx/certs/privkey.pem:ro -``` - -```bash -docker compose up -d --force-recreate frontend -``` - -`mkcert -install` legt eine lokale Root-CA an, der nur *dieser* Rechner -vertraut. Für weitere Geräte (Handy etc.) muss die Root-CA -(`mkcert -CAROOT` zeigt den Pfad) dort zusätzlich installiert werden, oder es -bleibt bei der Browser-Warnung des automatisch generierten Zertifikats. +Falls du lieber ein eigenes Zertifikat verwendest (z. B. von einer internen +CA, die du schon hast): `fullchain.pem` + `privkey.pem` ins Volume +`launchpad-certs` legen (z. B. `docker cp fullchain.pem launchpad-frontend:/etc/nginx/certs/` bei laufendem Container, oder das Volume direkt mounten), +danach `docker compose restart frontend`. Liegt dabei keine `ca-cert.pem` im +selben Verzeichnis, generiert der Entrypoint nichts automatisch nach. ## Quickstart (lokale Entwicklung) diff --git a/apps/frontend/docker-entrypoint.sh b/apps/frontend/docker-entrypoint.sh index 9a70dc3..25280ea 100644 --- a/apps/frontend/docker-entrypoint.sh +++ b/apps/frontend/docker-entrypoint.sh @@ -2,37 +2,70 @@ set -e CERT_DIR="/etc/nginx/certs" +CA_KEY="$CERT_DIR/ca-key.pem" +CA_CERT="$CERT_DIR/ca-cert.pem" CERT_FILE="$CERT_DIR/fullchain.pem" KEY_FILE="$CERT_DIR/privkey.pem" +CERT_HOST_FILE="$CERT_DIR/.cert-host" +HOST="${LAUNCHPAD_HOST:-localhost}" -# Falls der Nutzer eigene Zertifikate eingebunden hat (siehe README, z. B. -# per mkcert erzeugt), diese nicht überschreiben. -if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then - HOST="${LAUNCHPAD_HOST:-localhost}" - mkdir -p "$CERT_DIR" +mkdir -p "$CERT_DIR" - # IP-Adresse oder Hostname? Browser verlangen bei IP-Zugriff eine - # IP-SAN, ein DNS-Name genügt da nicht. - case "$HOST" in - *[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*) - SAN="IP:$HOST,DNS:localhost,IP:127.0.0.1" - ;; - *) - SAN="DNS:$HOST,DNS:localhost,IP:127.0.0.1" - ;; - esac - - openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ - -keyout "$KEY_FILE" \ - -out "$CERT_FILE" \ - -subj "/CN=$HOST" \ - -addext "subjectAltName=$SAN" \ - 2>/dev/null - - echo "[entrypoint] Selbstsigniertes HTTPS-Zertifikat für '$HOST' erzeugt." - echo "[entrypoint] Der Browser zeigt dafür eine Sicherheitswarnung - siehe README fuer eine Variante ohne Warnung (mkcert)." +if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && [ ! -f "$CA_CERT" ]; then + # fullchain.pem/privkey.pem wurden vom Nutzer eingebunden (z. B. eigenes + # Zertifikat) und es gibt keine von uns erzeugte CA dazu -> nichts anfassen. + echo "[entrypoint] Eingebundenes Zertifikat gefunden (kein CA-Download unter /ca.crt verfügbar)." else - echo "[entrypoint] Verwende eingebundenes Zertifikat unter $CERT_DIR." + # Eigene lokale Root-CA erzeugen, falls noch keine existiert. Liegt im + # Volume "launchpad-certs" und bleibt über Neustarts/Rebuilds stabil - + # einmal pro Gerät importiert, reicht dauerhaft. + if [ ! -f "$CA_KEY" ] || [ ! -f "$CA_CERT" ]; then + echo "[entrypoint] Erzeuge neue lokale Root-CA für LaunchPad..." + openssl genrsa -out "$CA_KEY" 2048 2>/dev/null + openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \ + -subj "/CN=LaunchPad Local CA" \ + -out "$CA_CERT" 2>/dev/null + fi + + # Server-Zertifikat nur neu erzeugen, wenn es noch keins gibt oder sich + # LAUNCHPAD_HOST geändert hat (dann würde die alte SAN nicht mehr passen). + NEED_NEW_LEAF=false + if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then + NEED_NEW_LEAF=true + elif [ "$(cat "$CERT_HOST_FILE" 2>/dev/null)" != "$HOST" ]; then + NEED_NEW_LEAF=true + fi + + if [ "$NEED_NEW_LEAF" = "true" ]; then + case "$HOST" in + *[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*) + SAN="IP:$HOST,DNS:localhost,IP:127.0.0.1" + ;; + *) + SAN="DNS:$HOST,DNS:localhost,IP:127.0.0.1" + ;; + esac + + EXTFILE=$(mktemp) + printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n" "$SAN" > "$EXTFILE" + + openssl genrsa -out "$KEY_FILE" 2048 2>/dev/null + openssl req -new -key "$KEY_FILE" -subj "/CN=$HOST" -out "$CERT_DIR/server.csr" 2>/dev/null + openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CA_CERT" -CAkey "$CA_KEY" \ + -CAcreateserial -days 825 -sha256 -extfile "$EXTFILE" \ + -out "$CERT_FILE" 2>/dev/null + + rm -f "$EXTFILE" "$CERT_DIR/server.csr" + echo "$HOST" > "$CERT_HOST_FILE" + echo "[entrypoint] Server-Zertifikat für '$HOST' erzeugt und mit lokaler CA signiert." + fi +fi + +# CA-Zertifikat zum Download bereitstellen (Android/iOS/Windows bieten beim +# Öffnen einer .crt-Datei im Browser automatisch "Zertifikat installieren" an). +if [ -f "$CA_CERT" ]; then + mkdir -p /usr/share/nginx/html + cp "$CA_CERT" /usr/share/nginx/html/ca.crt fi exec "$@" diff --git a/apps/frontend/nginx-locations.conf b/apps/frontend/nginx-locations.conf index 7a20d49..d1110dc 100644 --- a/apps/frontend/nginx-locations.conf +++ b/apps/frontend/nginx-locations.conf @@ -26,6 +26,14 @@ location = /manifest.webmanifest { try_files $uri =404; } +# Root-CA zum Download – Android/iOS erkennen diesen MIME-Type und bieten +# beim Öffnen im Browser direkt "Zertifikat installieren" an. +location = /ca.crt { + add_header Cache-Control "no-cache"; + default_type application/x-x509-ca-cert; + try_files $uri =404; +} + location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; diff --git a/docker-compose.yml b/docker-compose.yml index 3d9675d..5c5b01c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,12 +33,15 @@ services: ports: - "8080:80" - "8443:443" - # Optional: eigenes Zertifikat statt des automatisch generierten - # selbstsignierten einbinden (z. B. per mkcert erzeugt, siehe README): - # volumes: - # - ./certs/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro - # - ./certs/privkey.pem:/etc/nginx/certs/privkey.pem:ro + volumes: + # Lokale Root-CA + Server-Zertifikat, vom Container selbst erzeugt + # (siehe apps/frontend/docker-entrypoint.sh). Als Volume, damit die CA + # über Neustarts/Rebuilds stabil bleibt - einmal pro Gerät importiert + # (https://:8080/ca.crt), reicht dauerhaft. + - launchpad-certs:/etc/nginx/certs volumes: launchpad-data: name: launchpad-data + launchpad-certs: + name: launchpad-certs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d216a6d..cbffc6b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -201,3 +201,34 @@ Geräte): > validiert (`nginx -t` erfolgreich), echter TLS-Handshake gegen den > laufenden Server durchgeführt und geprüft, dass das ausgelieferte > Zertifikat die korrekte IP als Subject Alternative Name trägt. + +## HTTPS-Revision: eigene lokale Root-CA statt DuckDNS + +Nutzer-Feedback: "Will nix externes, soll auch nur lokal laufen" – die +DuckDNS/Let's-Encrypt-Lösung wurde daher wieder entfernt und durch eine +komplett offline funktionierende Variante ersetzt. + +- `apps/frontend/docker-entrypoint.sh` erzeugt beim ersten Containerstart + eine eigene Root-CA (`LaunchPad Local CA`, `CA:TRUE`, 10 Jahre gültig) und + signiert damit ein Server-Zertifikat (`CA:FALSE`, korrekte SAN je nach + `LAUNCHPAD_HOST`) – keinerlei externe Dienste beteiligt +- CA + Server-Zertifikat liegen im neuen Docker-Volume `launchpad-certs` + (statt im Container-Dateisystem) – bleiben dadurch über Neustarts und + `--build` stabil. Ändert sich nur `LAUNCHPAD_HOST`, wird ausschließlich das + Server-Zertifikat neu ausgestellt, die CA (und damit bereits erteiltes + Gerätevertrauen) bleibt unangetastet +- Root-CA wird unter `http://:8080/ca.crt` zum Download bereitgestellt + (`nginx-locations.conf`, korrekter MIME-Type `application/x-x509-ca-cert` + löst auf Android/iOS automatisch den nativen "Zertifikat installieren"-Dialog + aus) – einziger verbleibender manueller Schritt ist der einmalige Import + pro Gerät, dafür gibt es ohne externe CA keine Alternative +- DuckDNS/`acme`-Service und zugehörige `.env`-Variablen vollständig entfernt + +> Verifiziert: CA- und Leaf-Zertifikat-Erzeugung lokal mit echtem `openssl` +> durchgespielt (Zertifikatskette validiert erfolgreich gegen die CA, +> `CA:FALSE`/`CA:TRUE` korrekt gesetzt, SAN korrekt), Idempotenz geprüft +> (zweiter Lauf mit gleichem Host verändert nichts, geänderter Host erzeugt +> nur ein neues Server-Zertifikat, CA bleibt stabil), kompletter End-to-End- +> Test mit echtem nginx: `/ca.crt` per HTTP heruntergeladen und **damit ohne +> `-k`-Flag** eine echte HTTPS-Verbindung erfolgreich validiert – simuliert +> exakt das Verhalten eines Geräts nach CA-Import.