25881f3ff8
- 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
97 lines
2.5 KiB
Go
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 ""
|
|
}
|