Ohne E-Mail-System erzeugt der Admin per CLI einen Link (/reset#<token>), gültig 72 h und einmal. Gespeichert wird nur der SHA-256 des Tokens; es steht im Fragment und geht nur im POST-Body an die API. Der Reset meldet alle alten Sessions ab und loggt direkt neu ein. Passwortregeln jetzt gemeinsam in checkNewPassword. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
7.2 KiB
Markdown
234 lines
7.2 KiB
Markdown
# Deployment mit Podman
|
|
|
|
## Was muss auf den Server?
|
|
|
|
Nur der Quellcode — das Image wird direkt auf dem Server gebaut.
|
|
|
|
```sh
|
|
# Einmalig: Repo klonen
|
|
git clone https://codeberg.org/<user>/kver.git
|
|
cd kver
|
|
```
|
|
|
|
Oder bei bestehendem Checkout:
|
|
|
|
```sh
|
|
git pull origin master
|
|
```
|
|
|
|
Bestehende Daten (DB + Uploads) liegen auf dem Server in Named Volumes und
|
|
bleiben bei Image-Updates unberührt — die müssen nicht mitübertragen werden.
|
|
Beim ersten Deployment: DB und Medien einmalig in die Volumes kopieren
|
|
(s. Abschnitt „Bestehende Daten übernehmen").
|
|
|
|
## Bauen
|
|
|
|
```sh
|
|
podman build -t kver .
|
|
```
|
|
|
|
Multi-Stage-Build (`Containerfile`): das React-Frontend wird in einer
|
|
Node-Stage gebaut (`npm ci && npm run build`), das Go-Binary statisch (CGO
|
|
aus); das Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer und enthält
|
|
nur Binary, gebautes Frontend (`web/`) und `static/`. Auf dem Server wird
|
|
also weder Node noch Go gebraucht, nur Podman.
|
|
|
|
Die Warnung `HEALTHCHECK is not supported for OCI image format` ist erwartbar:
|
|
Podman baut OCI-Images, dort gibt es kein eingebettetes HEALTHCHECK. Der
|
|
Healthcheck kommt stattdessen aus der Quadlet-Unit (s.u.); alternativ
|
|
`podman build --format=docker` oder `podman run --health-cmd=...`.
|
|
|
|
## Starten (Port 7777)
|
|
|
|
```sh
|
|
podman run -d --name kver \
|
|
-p 127.0.0.1:7777:8080 \
|
|
--env-file /etc/kver/kver.env \
|
|
-v kver-data:/app/data \
|
|
-v kver-media:/app/static/media \
|
|
kver
|
|
```
|
|
|
|
- Die Daten liegen in Postgres (`KVER_DSN`). `kver-data` hält nur noch die
|
|
GeoLite2-ASN-DB (und die alte SQLite-Datei), `kver-media` die hochgeladenen
|
|
Bilder. Beides Named Volumes → überleben Image-Updates.
|
|
- Port nur an localhost binden; nach außen geht es über den Reverse-Proxy.
|
|
- Der Container lauscht intern immer auf `:8080`; der externe Port (hier 7777)
|
|
ist nur das Host-seitige Mapping.
|
|
|
|
Konfiguration über Umgebungsvariablen:
|
|
|
|
| Variable | Default | Bedeutung |
|
|
|-------------|---------------------|----------------------|
|
|
| `KVER_ADDR` | `:8080` | Listen-Adresse im Container |
|
|
| `KVER_DSN` | _(Pflicht)_ | Postgres-Verbindung, z. B. `postgres://kver:pw@host:5432/kontrollverlust?sslmode=disable` |
|
|
| `KVER_GEOIP_ASN` | _(leer)_ | Pfad zur GeoLite2-ASN-`.mmdb`; leer = ASN-Auflösung aus |
|
|
| `KVER_BASE_URL` | _(leer)_ | Öffentliche Adresse für `kver reset-link`; leer = nur Pfad |
|
|
|
|
## GeoLite2-ASN-Datenbank (optional)
|
|
|
|
Für die netzwerktopologische Einordnung der Impressions (Spalte `impression.asn`).
|
|
Ohne diese DB läuft alles normal weiter, die Impressions haben dann nur kein ASN.
|
|
|
|
Die `GeoLite2-ASN.mmdb` gibt es kostenlos bei MaxMind (Konto + Lizenzschlüssel,
|
|
monatliche Updates). Datei ins Daten-Volume legen und den Pfad setzen:
|
|
|
|
```sh
|
|
podman unshare cp GeoLite2-ASN.mmdb \
|
|
"$(podman volume inspect kver-data --format '{{.Mountpoint}}')/"
|
|
```
|
|
|
|
Dann `KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb` setzen. Die Datei wird beim Start
|
|
einmalig gemappt; für aktualisierte Daten die Datei ersetzen und den Dienst neu
|
|
starten.
|
|
|
|
## Postgres
|
|
|
|
Die App erwartet eine Datenbank, die ihrem Login-User gehört. Einmalig als
|
|
Admin:
|
|
|
|
```sql
|
|
CREATE ROLE kver LOGIN PASSWORD '...';
|
|
CREATE DATABASE kontrollverlust OWNER kver;
|
|
REVOKE CONNECT, TEMPORARY ON DATABASE kontrollverlust FROM PUBLIC;
|
|
```
|
|
|
|
Tabellen und Indizes legt die App beim Start selbst an (`CREATE ... IF NOT
|
|
EXISTS`, als `kver` → gehören `kver`). Nicht als Admin anlegen.
|
|
|
|
## Bestehende Medien übernehmen
|
|
|
|
Medien einmalig ins Volume kopieren (Volumes müssen existieren;
|
|
Container kann laufen oder gestoppt sein):
|
|
|
|
```sh
|
|
podman volume create kver-data
|
|
podman volume create kver-media
|
|
|
|
# Rootless: podman unshare sorgt dafür, dass die UID ins User-Namespace-
|
|
# Mapping passt — ohne das bekommt der Container die Dateien nicht zu lesen.
|
|
podman unshare cp -r static/media/. \
|
|
"$(podman volume inspect kver-media --format '{{.Mountpoint}}')/"
|
|
```
|
|
|
|
Danach Container (neu) starten.
|
|
|
|
## Reverse-Proxy (TLS) — Caddy
|
|
|
|
Caddy ist die einfachste Wahl: automatisches TLS, setzt `X-Forwarded-Proto`
|
|
und `X-Forwarded-For` ohne Konfiguration, kein eigenes Body-Size-Problem.
|
|
|
|
`/etc/caddy/Caddyfile` (oder `~/.config/caddy/Caddyfile` bei rootless):
|
|
|
|
```caddyfile
|
|
kver.example.org {
|
|
# Unveränderliche Uploads cachen — Dateinamen sind einmalig (<unix>-<rand>),
|
|
# Browser lädt jede Datei genau einmal.
|
|
header /static/media/* Cache-Control "public, max-age=31536000, immutable"
|
|
|
|
reverse_proxy 127.0.0.1:7777
|
|
}
|
|
```
|
|
|
|
Das ist alles. Caddy kümmert sich um Let's-Encrypt-Zertifikat, HTTPS-Redirect
|
|
und setzt automatisch `X-Forwarded-Proto: https` → Session-Cookie bekommt das
|
|
`Secure`-Flag, Rate-Limit sieht echte Client-IPs.
|
|
|
|
Optional Body-Limit vor der App (App begrenzt selbst auf 17 MB):
|
|
|
|
```caddyfile
|
|
request_body {
|
|
max_size 20MB
|
|
}
|
|
```
|
|
|
|
### Warum /static/media/* nicht direkt aus Caddy serven?
|
|
|
|
Der `nosniff`-Header auf Uploads ist sicherheitsrelevant (animierte GIFs werden
|
|
nicht re-kodiert; ohne `nosniff` könnte ein präpariertes GIF als HTML/JS
|
|
interpretiert werden → Stored XSS). Serviert Caddy die Dateien direkt, ist die
|
|
App-Middleware außen vor. Mit `reverse_proxy` bleibt die App der einzige
|
|
Auslieferer und der Header sitzt immer drauf.
|
|
|
|
Außerdem liegt das Named Volume bei rootless Podman unter einem Subuid-Pfad,
|
|
den ein Systemdienst nicht ohne Weiteres lesen kann.
|
|
|
|
### nginx (alternativ)
|
|
|
|
```nginx
|
|
location / {
|
|
proxy_pass http://127.0.0.1:7777;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
client_max_body_size 20m; # muss über App-Limit (17 MB) liegen
|
|
}
|
|
```
|
|
|
|
## Autostart mit systemd (Quadlet)
|
|
|
|
`~/.config/containers/systemd/kver.container` (rootless):
|
|
|
|
```ini
|
|
[Unit]
|
|
Description=kver (Kontrollverlust)
|
|
|
|
[Container]
|
|
Image=localhost/kver:latest
|
|
PublishPort=127.0.0.1:7777:8080
|
|
Volume=kver-data:/app/data
|
|
Volume=kver-media:/app/static/media
|
|
# Optional: ASN-Auflösung der Impressions. Pfad zeigt ins kver-data-Volume,
|
|
# daher zur Laufzeit setzen (nicht ins Image backen). Zeile weglassen, wenn
|
|
# keine GeoLite2-ASN.mmdb vorhanden ist -> ASN-Auflösung bleibt still aus.
|
|
Environment=KVER_GEOIP_ASN=/app/data/GeoLite2-ASN.mmdb
|
|
# Healthcheck hier statt im Containerfile: Podman baut OCI-Images,
|
|
# die kennen kein eingebettetes HEALTHCHECK.
|
|
HealthCmd=wget -qO /dev/null http://localhost:8080/api/stats
|
|
HealthInterval=30s
|
|
|
|
[Service]
|
|
Restart=on-failure
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
```
|
|
|
|
Aktivieren:
|
|
|
|
```sh
|
|
systemctl --user daemon-reload
|
|
systemctl --user start kver
|
|
loginctl enable-linger "$USER" # damit der Dienst ohne Login läuft
|
|
```
|
|
|
|
Status prüfen:
|
|
|
|
```sh
|
|
systemctl --user status kver
|
|
podman healthcheck run kver
|
|
```
|
|
|
|
## Update einspielen
|
|
|
|
```sh
|
|
git pull origin master
|
|
podman build -t kver .
|
|
systemctl --user restart kver
|
|
```
|
|
|
|
Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende
|
|
bedient, dann wird die DB-Verbindung geschlossen).
|
|
|
|
## Passwort zurücksetzen
|
|
|
|
Ohne E-Mail-System erzeugt der Admin einen Einmal-Link und gibt ihn dem
|
|
Nutzer persönlich:
|
|
|
|
```sh
|
|
podman exec kver kver reset-link <nutzername>
|
|
```
|
|
|
|
Der Link (`/reset#<token>`) gilt 72 Stunden und nur einmal; ein neuer Link
|
|
macht ältere desselben Nutzers ungültig. Beim Setzen des neuen Passworts
|
|
werden alle bestehenden Sessions des Nutzers abgemeldet.
|