202 lines
7.6 KiB
Go
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
|
|
}
|