// 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=" 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=" // (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=" // 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 " 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 }