Files
converge/converge-relay/webhook.go
T
jbrechtel 25881f3ff8 converge-relay: phase 1 — webhook-to-SSE relay server in Go
- Pub/sub broker with fan-out to SSE clients
- POST /webhook — receives Gitea push events, optional HMAC verification
- GET /events — SSE stream of push events
- GET /health — health check endpoint
- Zero external dependencies (stdlib only)
- Docker-based build via goctl wrapper (golang:1.26-alpine)
- systemd unit, README with deployment docs
- Tests: broker pub/sub, slow subscriber drop, ref parsing, signature verification
- 6.0M static binary
2026-05-14 13:54:26 -04:00

97 lines
2.5 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
)
// giteaPushPayload mirrors the relevant fields from Gitea's push webhook JSON.
type giteaPushPayload struct {
Ref string `json:"ref"`
Repository struct {
FullName string `json:"full_name"`
} `json:"repository"`
}
func webhookHandler(broker *Broker, secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB limit
if err != nil {
slog.Warn("failed to read webhook body", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Verify signature if a secret is configured
if secret != "" && !verifySignature(body, secret, r.Header) {
slog.Warn("webhook signature verification failed")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Only handle push events
eventType := r.Header.Get("X-Gitea-Event")
if eventType != "" && eventType != "push" {
slog.Debug("ignoring non-push event", "type", eventType)
w.WriteHeader(http.StatusOK)
return
}
var payload giteaPushPayload
if err := json.Unmarshal(body, &payload); err != nil {
slog.Warn("failed to parse webhook payload", "error", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
branch := refToBranch(payload.Ref)
if branch == "" {
slog.Debug("ignoring non-branch ref", "ref", payload.Ref)
w.WriteHeader(http.StatusOK)
return
}
broker.Publish(PushEvent{
FullName: payload.Repository.FullName,
Branch: branch,
Ref: payload.Ref,
})
w.WriteHeader(http.StatusOK)
}
}
// verifySignature checks the request body against the HMAC-SHA256 signature
// in X-Gitea-Signature or X-Hub-Signature-256 headers.
func verifySignature(body []byte, secret string, headers http.Header) bool {
sig := headers.Get("X-Gitea-Signature")
if sig == "" {
sig = headers.Get("X-Hub-Signature-256")
}
if sig == "" {
return false
}
// Strip "sha256=" prefix if present
sig = strings.TrimPrefix(sig, "sha256=")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
// refToBranch extracts the branch name from a git ref like "refs/heads/main".
func refToBranch(ref string) string {
const prefix = "refs/heads/"
if strings.HasPrefix(ref, prefix) {
return ref[len(prefix):]
}
return ""
}