From 50be342899008ebcd60018e1a762933046747657 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Thu, 11 Jun 2026 07:00:09 +0200 Subject: [PATCH] =?UTF-8?q?Deployment-H=C3=A4rtung=20+=20Podman-Container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - http.Server mit Timeouts (Slowloris) und Graceful Shutdown (SIGTERM) - Adresse und DB-Pfad per Env (KVER_ADDR, KVER_DB) - Security-Header global: nosniff, X-Frame-Options, Referrer-Policy, CSP - Upload-Requests hart auf 17 MB gedeckelt (MaxBytesReader statt nur Multipart-Speichergrenze) - Indizes für Feed-, Thread- und Vote-Queries - middleware.RealIP für Logging/Rate-Limit hinter dem Reverse-Proxy - Containerfile (Multi-Stage, Alpine, non-root) + Deploy-Doku in notes/ Co-Authored-By: Claude Opus 4.8 --- .containerignore | 16 +++++++ Containerfile | 25 +++++++++++ db.go | 10 +++++ entry.go | 18 +++++--- main.go | 83 +++++++++++++++++++++++++++++------ notes/deploy.md | 111 +++++++++++++++++++++++++++++++++++++++++++++++ user.go | 3 ++ 7 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 .containerignore create mode 100644 Containerfile create mode 100644 notes/deploy.md diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..e196b6a --- /dev/null +++ b/.containerignore @@ -0,0 +1,16 @@ +# Nicht ins Image: lokale DB, Uploads, Backups, Werkzeug-Kram. +.git +.gitignore +prod_backup/ +notes/ +venv/ +__pycache__/ +kver +*.db +*.db-shm +*.db-wal +*.db.bak +static/media/ +migrate.sh +Containerfile +.containerignore diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..77411ab --- /dev/null +++ b/Containerfile @@ -0,0 +1,25 @@ +# Build-Stage: statisches Binary, CGO aus (modernc.org/sqlite ist pures Go). +FROM docker.io/library/golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /kver . + +# Laufzeit-Stage: minimales Alpine, eigener unprivilegierter Nutzer. +FROM docker.io/library/alpine:3.22 +RUN adduser -D -u 1000 kver +WORKDIR /app +COPY --from=build /kver /usr/local/bin/kver +COPY web/ web/ +COPY static/ static/ +# data: SQLite-DB; static/media: Uploads. Beide als Volume persistieren. +RUN mkdir -p data static/media && chown kver:kver data static/media +USER kver +ENV KVER_DB=/app/data/kver.db +ENV KVER_ADDR=:8080 +EXPOSE 8080 +VOLUME ["/app/data", "/app/static/media"] +HEALTHCHECK --interval=30s --timeout=3s \ + CMD wget -qO /dev/null http://localhost:8080/entry/feed/0 || exit 1 +ENTRYPOINT ["/usr/local/bin/kver"] diff --git a/db.go b/db.go index 7b2deb0..ccdf46d 100644 --- a/db.go +++ b/db.go @@ -48,6 +48,16 @@ CREATE TABLE IF NOT EXISTS vote ( mode TEXT, UNIQUE(uid, pid) ); + +-- Indizes für die häufigen Zugriffspfade; ohne sie werden Feed (ORDER BY +-- last_activity), Thread (WHERE reply_to) und Vote-Zähler (WHERE pid) mit +-- wachsender Tabelle zu Full-Table-Scans. username/session.value/vote(uid,pid) +-- sind über UNIQUE bereits indiziert. +CREATE INDEX IF NOT EXISTS idx_entry_last_activity ON entry(last_activity); +CREATE INDEX IF NOT EXISTS idx_entry_reply_to ON entry(reply_to); +CREATE INDEX IF NOT EXISTS idx_entry_uid ON entry(uid); +CREATE INDEX IF NOT EXISTS idx_vote_pid ON vote(pid); +CREATE INDEX IF NOT EXISTS idx_session_uid ON session(uid); ` func initDB(dsn string) error { diff --git a/entry.go b/entry.go index e702985..c3c6538 100644 --- a/entry.go +++ b/entry.go @@ -189,6 +189,10 @@ func handleThread(w http.ResponseWriter, r *http.Request) { func handleCreateEntry(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) + // MaxBytesReader kappt den gesamten Request hart: ParseMultipartForm + // begrenzt nur den Speicher, alles darüber liefe sonst unbegrenzt in + // Temp-Dateien auf die Platte. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) if err := r.ParseMultipartForm(16 << 20); err != nil && err != http.ErrNotMultipart { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return @@ -426,11 +430,15 @@ func handleVote(w http.ResponseWriter, r *http.Request) { } const ( - maxImageSize = 1024 // Zielkante beim Skalieren - maxImagePixels = 50_000_000 // pro Frame: Schutz vor Decompression-Bomb - maxGIFBytes = 16 << 20 // Obergrenze für durchgereichte GIFs - maxGIFFrames = 1000 // animiertes GIF: Frame-Budget - maxGIFPixels = 100_000_000 // animiertes GIF: Summe aller Frame-Pixel + maxImageSize = 1024 // Zielkante beim Skalieren + maxImagePixels = 50_000_000 // pro Frame: Schutz vor Decompression-Bomb + maxGIFBytes = 16 << 20 // Obergrenze für durchgereichte GIFs + maxGIFFrames = 1000 // animiertes GIF: Frame-Budget + maxGIFPixels = 100_000_000 // animiertes GIF: Summe aller Frame-Pixel + + // Hartes Limit für Upload-Requests insgesamt (Bild + Formularfelder + + // Multipart-Overhead), via http.MaxBytesReader. + maxUploadBytes = 17 << 20 ) // storeImage speichert ein hochgeladenes Bild unter static/media. diff --git a/main.go b/main.go index d00b940..465f54e 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,13 @@ package main import ( + "context" "encoding/json" "log" "net/http" + "os" + "os/signal" + "syscall" "time" "github.com/go-chi/chi/v5" @@ -12,22 +16,65 @@ import ( ) func main() { - if err := initDB("kver.db"); err != nil { + if err := initDB(envOr("KVER_DB", "kver.db")); err != nil { log.Fatalf("db init: %v", err) } defer db.Close() - const addr = ":8080" - log.Printf("kver listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, routes())) + srv := &http.Server{ + Addr: envOr("KVER_ADDR", ":8080"), + Handler: routes(), + // Timeouts gegen festhängende/langsame Verbindungen (Slowloris). Read + // und Write großzügig genug für 16-MB-Uploads über langsame Leitungen. + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 2 * time.Minute, + WriteTimeout: 2 * time.Minute, + IdleTimeout: 2 * time.Minute, + } + + // Graceful Shutdown: auf SIGINT/SIGTERM laufende Requests zu Ende bedienen, + // dann die DB sauber schließen (WAL-Checkpoint) statt hart abzubrechen. + idle := make(chan struct{}) + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + <-sig + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } + close(idle) + }() + + log.Printf("kver listening on %s", srv.Addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + <-idle +} + +// envOr liest eine Umgebungsvariable mit Fallback auf einen Default. +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback } // routes baut den HTTP-Handler mit allen Endpunkten. In main() und in den // Tests identisch verwendet. func routes() http.Handler { r := chi.NewRouter() + // RealIP übernimmt X-Forwarded-For/X-Real-IP vom Reverse-Proxy, damit + // Logging und das IP-Rate-Limit unten den Client statt den Proxy sehen. + // Voraussetzung: die App ist nur über den Proxy erreichbar (Deployment + // bindet an 127.0.0.1, siehe notes/deploy.md) — sonst wären die Header + // spoofbar. + r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(secHeaders) // --- Auth (öffentlich) --- // Login und Registrierung pro IP drosseln (Brute-Force-Bremse). bcrypt bremst @@ -65,10 +112,11 @@ func routes() http.Handler { r.Post("/user/delete", handleUserDelete) }) - // Hochgeladene Medien + alte statische Assets. nosniff verhindert, dass der - // Browser eine durchgereichte (nicht re-kodierte) Datei als HTML/JS - // interpretiert -> schließt Polyglot/Stored-XSS über Bild-Uploads. - r.Handle("/static/*", http.StripPrefix("/static/", noSniff(http.FileServer(http.Dir("static"))))) + // Hochgeladene Medien + alte statische Assets. nosniff (global via + // secHeaders) verhindert, dass der Browser eine durchgereichte (nicht + // re-kodierte) Datei als HTML/JS interpretiert -> schließt + // Polyglot/Stored-XSS über Bild-Uploads. + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // Neues JS-Frontend r.Handle("/*", http.FileServer(http.Dir("web"))) @@ -76,12 +124,21 @@ func routes() http.Handler { return r } -// noSniff setzt X-Content-Type-Options: nosniff, damit der Browser den -// deklarierten Content-Type nicht überstimmt (Schutz vor Content-Sniffing). -func noSniff(h http.Handler) http.Handler { +// secHeaders setzt Standard-Security-Header auf alle Antworten: +// - nosniff: Browser darf den deklarierten Content-Type nicht überstimmen +// (wichtig für durchgereichte GIF-Uploads, siehe storeImage). +// - Frame-Verbote gegen Clickjacking. +// - CSP als Defense-in-depth gegen XSS: das Frontend hat kein Inline-JS/CSS, +// daher reicht 'self' ohne unsafe-inline. +func secHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Content-Type-Options", "nosniff") - h.ServeHTTP(w, r) + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "same-origin") + h.Set("Content-Security-Policy", + "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + next.ServeHTTP(w, r) }) } diff --git a/notes/deploy.md b/notes/deploy.md new file mode 100644 index 0000000..c7eea73 --- /dev/null +++ b/notes/deploy.md @@ -0,0 +1,111 @@ +# Deployment mit Podman + +## Bauen + +```sh +podman build -t kver . +``` + +Multi-Stage-Build (`Containerfile`): statisches Go-Binary (CGO aus, modernc- +SQLite ist pures Go), Laufzeit-Image ist Alpine mit unprivilegiertem Nutzer. + +## Starten + +```sh +podman run -d --name kver \ + -p 127.0.0.1:8080:8080 \ + -v kver-data:/app/data \ + -v kver-media:/app/static/media \ + kver +``` + +- `kver-data` hält die SQLite-DB (`/app/data/kver.db`), `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. + +Konfiguration über Umgebungsvariablen: + +| Variable | Default | Bedeutung | +|-------------|---------------------|----------------------| +| `KVER_ADDR` | `:8080` | Listen-Adresse | +| `KVER_DB` | `/app/data/kver.db` | Pfad zur SQLite-Datei | + +## Bestehende Daten übernehmen + +DB und Medien einmalig in die Volumes kopieren (Container muss dafür kurz +gelaufen sein bzw. die Volumes existieren): + +```sh +podman volume create kver-data +podman volume create kver-media +podman cp kver.db kver:/app/data/kver.db # bei laufendem Container, oder: +cp kver.db "$(podman volume inspect kver-data -f '{{.Mountpoint}}')/" +cp static/media/* "$(podman volume inspect kver-media -f '{{.Mountpoint}}')/" +``` + +Danach Container neu starten. (Rootless: `podman unshare cp ...`, damit die +UID-Zuordnung ins User-Namespace-Mapping passt.) + +## Reverse-Proxy (TLS) + +Der Container spricht nur HTTP. Davor gehört ein TLS-terminierender Proxy +(Caddy/nginx), der `X-Forwarded-Proto: https` setzt — daran erkennt die App +HTTPS und setzt das `Secure`-Flag auf den Session-Cookie (siehe `isHTTPS`). + +nginx-Beispiel: + +```nginx +location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + client_max_body_size 20m; # Uploads bis 17 MB durchlassen +} +``` + +`client_max_body_size` muss über dem App-Limit (17 MB, `maxUploadBytes`) +liegen, sonst kappt nginx vor der App. + +Hinweis Rate-Limiting: `httprate.LimitByIP` auf den Auth-Routen sieht hinter +einem Proxy die Proxy-IP. Entweder den Proxy die echte Client-IP als +`X-Real-IP`/`X-Forwarded-For` setzen lassen und chi's `middleware.RealIP` +vorschalten — oder damit leben, dass das Limit global statt pro IP wirkt. + +## 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:8080:8080 +Volume=kver-data:/app/data +Volume=kver-media:/app/static/media + +[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 +``` + +## Update einspielen + +```sh +podman build -t kver . +systemctl --user restart kver # bzw. podman stop kver && podman run ... +``` + +Die App fährt bei SIGTERM sauber herunter (laufende Requests werden zu Ende +bedient, WAL-Checkpoint beim DB-Close). diff --git a/user.go b/user.go index 0a72c42..b458494 100644 --- a/user.go +++ b/user.go @@ -128,6 +128,9 @@ func handleUserRename(w http.ResponseWriter, r *http.Request) { func handleSetAvatar(w http.ResponseWriter, r *http.Request) { uid := uidFromContext(r.Context()) + // Wie bei handleCreateEntry: Request-Größe hart deckeln, bevor Multipart + // in Temp-Dateien streamen darf. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) if err := r.ParseMultipartForm(16 << 20); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return