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
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user