go-wiki/handler/webhook.go
2026-07-14 20:46:19 +02:00

202 lines
7.6 KiB
Go

// Dieses File implementiert den Webhook-Handler für automatische Git-Synchronisation.
//
// # Wie funktioniert ein Webhook?
//
// Ein Webhook ist ein HTTP-Request den ein externer Dienst (GitHub, GitLab, Bitbucket)
// automatisch an eine URL schickt sobald ein bestimmtes Ereignis eintritt — in unserem
// Fall: ein Push in das Wiki-Docs-Repository.
//
// Ablauf:
// 1. Nutzer pusht neue/geänderte Markdown-Dateien in das Git-Repository
// 2. GitHub/GitLab/Bitbucket schickt POST /api/webhook an den Wiki-Server
// 3. Der Server prüft die Signatur (war das wirklich GitHub/GitLab/Bitbucket?)
// 4. Der Server führt "git pull" im data-Wurzelverzeichnis aus (alle Teams liegen
// in einem gemeinsamen Git-Repository)
// 5. Der Cache jedes Teams wird neu aufgebaut
// 6. Änderungen sind sofort im Wiki sichtbar — kein Neustart nötig
//
// # Signaturprüfung
//
// Jeder Anbieter signiert seinen Webhook-Request mit einem gemeinsamen Secret
// das du beim Einrichten des Webhooks angibst. Der Server prüft diese Signatur
// bevor er irgendetwas tut — sonst könnte jeder einen Request schicken und
// einen git pull auslösen.
//
// GitHub: Header "X-Hub-Signature-256" — HMAC-SHA256 des Request-Body
// GitLab: Header "X-Gitlab-Token" — direkter Token-Vergleich
// Bitbucket: Header "X-Hub-Signature" — HMAC-SHA256 des Request-Body
//
// HMAC (Hash-based Message Authentication Code) ist ein kryptografisches Verfahren:
// Sender und Empfänger kennen ein gemeinsames Secret. Der Sender berechnet damit
// einen Hash des Inhalts — der Empfänger berechnet denselben Hash und vergleicht.
// Stimmen beide überein, ist der Absender verifiziert.
package handler
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strings"
)
// WebhookHandler verarbeitet eingehende Webhook-Requests von Git-Anbietern.
type WebhookHandler struct {
secret string // gemeinsames Secret mit dem Git-Anbieter
dataRoot string // Wurzelverzeichnis aller Teams (muss ein Git-Repository sein)
registry *TeamRegistry // wird nach dem Pull komplett neu aufgebaut
audit *AuditLogger
}
// NewWebhookHandler erstellt einen neuen WebhookHandler.
func NewWebhookHandler(dataRoot, secret string, registry *TeamRegistry, audit *AuditLogger) *WebhookHandler {
return &WebhookHandler{
secret: secret,
dataRoot: dataRoot,
registry: registry,
audit: audit,
}
}
// ServeHTTP ist der Haupt-Eintrittspunkt für alle Webhook-Requests.
func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
// Den Request-Body einmal komplett lesen und als Byte-Slice speichern.
// Wir brauchen die rohen Bytes für die HMAC-Berechnung — danach kann der Body
// nicht mehr gelesen werden (er ist ein Stream der nur einmal durchläuft).
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Body konnte nicht gelesen werden", http.StatusInternalServerError)
return
}
defer r.Body.Close()
// Den Git-Anbieter anhand der Headers erkennen und die Signatur prüfen.
provider, ok := h.detectAndVerify(r, body)
if !ok {
http.Error(w, "Ungültige Webhook-Signatur", http.StatusUnauthorized)
return
}
log.Printf("Webhook empfangen von: %s", provider)
// git pull ausführen und Cache aktualisieren.
if err := h.syncAndUpdate(); err != nil {
log.Printf("Webhook-Sync fehlgeschlagen: %v", err)
h.audit.Log(AuditEvent{Actor: "webhook", Action: "webhook_sync", Source: "webhook", Detail: err.Error()})
http.Error(w, fmt.Sprintf("Sync fehlgeschlagen: %v", err), http.StatusInternalServerError)
return
}
h.audit.Log(AuditEvent{Actor: "webhook", Action: "webhook_sync", Source: "webhook", Detail: provider})
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Sync erfolgreich")
}
// detectAndVerify erkennt den Git-Anbieter anhand der Request-Headers und prüft die Signatur.
// Gibt den Anbieternamen und true zurück wenn die Signatur gültig ist.
func (h *WebhookHandler) detectAndVerify(r *http.Request, body []byte) (string, bool) {
// GitHub: Header "X-Hub-Signature-256" mit Format "sha256=<hex>"
if sig := r.Header.Get("X-Hub-Signature-256"); sig != "" {
return "GitHub", verifyHMACSHA256(body, sig, h.secret)
}
// Bitbucket Cloud: Header "X-Hub-Signature" mit Format "sha256=<hex>"
// (gleiche Methode wie GitHub, anderer Header-Name)
if sig := r.Header.Get("X-Hub-Signature"); sig != "" {
return "Bitbucket", verifyHMACSHA256(body, sig, h.secret)
}
// GitLab: Header "X-Gitlab-Token" enthält den Token direkt (kein HMAC)
if token := r.Header.Get("X-Gitlab-Token"); token != "" {
// subtle.ConstantTimeCompare verhindert Timing-Angriffe (wie in middleware/auth.go)
valid := subtle.ConstantTimeCompare([]byte(token), []byte(h.secret)) == 1
return "GitLab", valid
}
// Kein bekannter Anbieter-Header gefunden
return "unbekannt", false
}
// syncAndUpdate führt "git pull" im data-Wurzelverzeichnis aus und baut danach
// den Cache jedes Teams neu auf.
//
// Anders als früher (ein Cache für alle Docs) reicht ein einfacher "diff --name-only"
// hier nicht mehr sauber aus, weil sich geänderte Dateien über mehrere Team-Ordner
// verteilen können — ein kompletter Rebuild pro Team ist einfacher und robust genug,
// da Webhook-Aufrufe selten sind (nur bei einem Git-Push).
func (h *WebhookHandler) syncAndUpdate() error {
pullOutput, err := runGit(h.dataRoot, "pull")
if err != nil {
return fmt.Errorf("git pull fehlgeschlagen: %w", err)
}
if strings.Contains(pullOutput, "Already up to date") {
log.Println("Webhook: Keine Änderungen im Repository")
return nil
}
if err := h.registry.RebuildAll(); err != nil {
return fmt.Errorf("Cache-Rebuild nach Pull fehlgeschlagen: %w", err)
}
log.Println("Webhook: alle Team-Caches neu aufgebaut")
return nil
}
// verifyHMACSHA256 prüft eine HMAC-SHA256-Signatur.
//
// HMAC-SHA256 funktioniert so:
// 1. Aus dem Secret und dem Body wird mit SHA-256 ein Hash berechnet
// 2. Der Anbieter schickt diesen Hash im Header mit
// 3. Wir berechnen denselben Hash und vergleichen
//
// Format der Signatur: "sha256=<hex-digest>"
// Wird für GitHub und Bitbucket verwendet.
func verifyHMACSHA256(body []byte, signature, secret string) bool {
// Die Signatur hat das Format "sha256=abc123..." — wir brauchen nur den Hex-Teil
parts := strings.SplitN(signature, "=", 2)
if len(parts) != 2 || parts[0] != "sha256" {
return false
}
// Unseren eigenen HMAC berechnen
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// Timing-sicherer Vergleich (wie in middleware/auth.go erklärt)
return subtle.ConstantTimeCompare([]byte(parts[1]), []byte(expected)) == 1
}
// runGit führt einen git-Befehl im angegebenen Verzeichnis aus und gibt die Ausgabe zurück.
// Das "-C <dir>" Flag sagt git in welchem Ordner es arbeiten soll.
func runGit(dir string, args ...string) (string, error) {
// exec.Command erstellt einen Subprozess — wie das Terminal einen Befehl ausführt.
// Wir hängen "-C dir" vor die eigentlichen Argumente damit git im richtigen Ordner läuft.
fullArgs := append([]string{"-C", dir}, args...)
cmd := exec.Command("git", fullArgs...)
// cmd.Output() startet den Prozess, wartet bis er fertig ist und gibt stdout zurück.
// Bei einem Fehler (Exit-Code != 0) ist err != nil und enthält stderr.
output, err := cmd.Output()
if err != nil {
// exec.ExitError enthält stderr — nützlich für Fehlermeldungen
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("%w\nstderr: %s", err, string(exitErr.Stderr))
}
return "", err
}
return string(output), nil
}