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 ──────────────────────────────────────────
# 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://<LAUNCHPAD_HOST>: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

View File

@@ -20,11 +20,25 @@ docker compose up -d --build
## HTTPS im lokalen Netz
LaunchPad ist per HTTPS auf Port `8443` erreichbar (`https://<deine-ip>: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)

View File

@@ -2,17 +2,41 @@
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"
# 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
CERT_HOST_FILE="$CERT_DIR/.cert-host"
HOST="${LAUNCHPAD_HOST:-localhost}"
mkdir -p "$CERT_DIR"
# IP-Adresse oder Hostname? Browser verlangen bei IP-Zugriff eine
# IP-SAN, ein DNS-Name genügt da nicht.
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
# 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"
@@ -22,17 +46,26 @@ if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
;;
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
EXTFILE=$(mktemp)
printf "subjectAltName=%s\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n" "$SAN" > "$EXTFILE"
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
echo "[entrypoint] Verwende eingebundenes Zertifikat unter $CERT_DIR."
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 "$@"

View File

@@ -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";

View File

@@ -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://<host>:8080/ca.crt), reicht dauerhaft.
- launchpad-certs:/etc/nginx/certs
volumes:
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
> 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://<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.