HTTPS: eigene lokale Root-CA statt externem DuckDNS/Let's Encrypt

This commit is contained in:
2026-07-19 17:19:03 +02:00
parent a695159b1d
commit 31e17ff77b
6 changed files with 160 additions and 69 deletions

View File

@@ -42,9 +42,11 @@ BACKEND_URL=http://localhost:3001
# ─── HTTPS im lokalen Netz ────────────────────────────────────────── # ─── HTTPS im lokalen Netz ──────────────────────────────────────────
# IP-Adresse oder Hostname, unter der du LaunchPad im LAN aufrufst # 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 # (z. B. https://192.168.1.49:8443). Der Frontend-Container erzeugt daraus
# automatisch erzeugte selbstsignierte Zertifikat verwendet. Ohne eigenes # automatisch eine eigene lokale Root-CA + ein davon signiertes Zertifikat
# Zertifikat zeigt der Browser trotzdem eine Warnung siehe README für eine # (komplett offline, keine externen Dienste). Die CA liegt danach zum
# Variante ohne Warnung (mkcert). Ändern erfordert einen Neustart des # Download unter http://<LAUNCHPAD_HOST>:8080/ca.crt bereit einmal pro
# Frontend-Containers: docker compose up -d --force-recreate frontend # 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 LAUNCHPAD_HOST=192.168.1.49

View File

@@ -20,11 +20,25 @@ docker compose up -d --build
## HTTPS im lokalen Netz ## HTTPS im lokalen Netz
LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://<deine-ip>:8443`), LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://<deine-ip>:8443`),
zusätzlich zu HTTP auf `8080` kein erzwungener Redirect. Beim ersten Start zusätzlich zu HTTP auf `8080` kein erzwungener Redirect, kein externer
erzeugt der Frontend-Container automatisch ein selbstsigniertes Zertifikat für Dienst beteiligt.
die in `.env` hinterlegte `LAUNCHPAD_HOST` (IP oder Hostname). Der Browser
zeigt dafür trotzdem eine Sicherheitswarnung das ist bei selbstsignierten Der Frontend-Container erzeugt beim ersten Start automatisch eine **eigene
Zertifikaten normal, nicht per se unsicher fürs eigene LAN. 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 ```bash
# .env: IP/Hostname eintragen, unter der du zugreifst # .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 docker compose up -d --force-recreate frontend
``` ```
Danach: `https://192.168.1.49:8443` (Warnung einmalig bestätigen/Ausnahme **Am Handy (Android):** `http://192.168.1.49:8080/ca.crt` im Browser öffnen
hinzufügen). (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 **macOS:** `ca.crt` herunterladen, doppelklicken (öffnet Schlüsselbundverwaltung),
Zertifikate dein Browser vertraut ihnen automatisch, keine Warnung mehr. dort öffnen, Vertrauenseinstellung auf "Immer vertrauen" setzen.
```bash Danach zeigt `https://192.168.1.49:8443` keine Warnung mehr, und die
# Einmalig auf dem Rechner, von dem aus du zugreifst (nicht auf dem Server): PWA-Installation sollte funktionieren.
mkcert -install
# Zertifikat für IP/Hostname erzeugen: > **Warum es keinen Weg ganz ohne diesen Schritt gibt:** Damit ein Browser
mkcert -cert-file fullchain.pem -key-file privkey.pem 192.168.1.49 launchpad.home localhost 127.0.0.1 > 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 ### Zertifikat wechseln / eigenes einbinden
`docker-compose.yml` die auskommentierten `volumes:`-Zeilen beim
`frontend`-Service aktivieren:
```yaml Falls du lieber ein eigenes Zertifikat verwendest (z. B. von einer internen
volumes: CA, die du schon hast): `fullchain.pem` + `privkey.pem` ins Volume
- ./certs/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro `launchpad-certs` legen (z. B. `docker cp fullchain.pem launchpad-frontend:/etc/nginx/certs/` bei laufendem Container, oder das Volume direkt mounten),
- ./certs/privkey.pem:/etc/nginx/certs/privkey.pem:ro danach `docker compose restart frontend`. Liegt dabei keine `ca-cert.pem` im
``` selben Verzeichnis, generiert der Entrypoint nichts automatisch nach.
```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.
## Quickstart (lokale Entwicklung) ## Quickstart (lokale Entwicklung)

View File

@@ -2,37 +2,70 @@
set -e set -e
CERT_DIR="/etc/nginx/certs" 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" CERT_FILE="$CERT_DIR/fullchain.pem"
KEY_FILE="$CERT_DIR/privkey.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. mkdir -p "$CERT_DIR"
# per mkcert erzeugt), diese nicht überschreiben.
if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
HOST="${LAUNCHPAD_HOST:-localhost}"
mkdir -p "$CERT_DIR"
# IP-Adresse oder Hostname? Browser verlangen bei IP-Zugriff eine if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ] && [ ! -f "$CA_CERT" ]; then
# IP-SAN, ein DNS-Name genügt da nicht. # fullchain.pem/privkey.pem wurden vom Nutzer eingebunden (z. B. eigenes
case "$HOST" in # Zertifikat) und es gibt keine von uns erzeugte CA dazu -> nichts anfassen.
*[0-9]*.*[0-9]*.*[0-9]*.*[0-9]*) echo "[entrypoint] Eingebundenes Zertifikat gefunden (kein CA-Download unter /ca.crt verfügbar)."
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)."
else 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 fi
exec "$@" exec "$@"

View File

@@ -26,6 +26,14 @@ location = /manifest.webmanifest {
try_files $uri =404; 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?)$ { location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?)$ {
expires 7d; expires 7d;
add_header Cache-Control "public, max-age=604800, immutable"; add_header Cache-Control "public, max-age=604800, immutable";

View File

@@ -33,12 +33,15 @@ services:
ports: ports:
- "8080:80" - "8080:80"
- "8443:443" - "8443:443"
# Optional: eigenes Zertifikat statt des automatisch generierten volumes:
# selbstsignierten einbinden (z. B. per mkcert erzeugt, siehe README): # Lokale Root-CA + Server-Zertifikat, vom Container selbst erzeugt
# volumes: # (siehe apps/frontend/docker-entrypoint.sh). Als Volume, damit die CA
# - ./certs/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro # über Neustarts/Rebuilds stabil bleibt - einmal pro Gerät importiert
# - ./certs/privkey.pem:/etc/nginx/certs/privkey.pem:ro # (https://<host>:8080/ca.crt), reicht dauerhaft.
- launchpad-certs:/etc/nginx/certs
volumes: volumes:
launchpad-data: launchpad-data:
name: launchpad-data name: launchpad-data
launchpad-certs:
name: launchpad-certs

View File

@@ -201,3 +201,34 @@ Geräte):
> validiert (`nginx -t` erfolgreich), echter TLS-Handshake gegen den > validiert (`nginx -t` erfolgreich), echter TLS-Handshake gegen den
> laufenden Server durchgeführt und geprüft, dass das ausgelieferte > laufenden Server durchgeführt und geprüft, dass das ausgelieferte
> Zertifikat die korrekte IP als Subject Alternative Name trägt. > 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://<host>: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.