Persists every inbound webhook hit (project + site) so users can debug
"why didn't my deploy fire?" without grepping daemon logs. Surfaces a
14-day rolling history under the WebhookPanel on each project + site
detail page; refreshes every 30s while open. Daily cron prunes records
older than 14 days alongside the existing event log prune.
Schema:
- webhook_deliveries(id, target_type, target_id, target_name, received_at,
source_ip, signature_state, status_code, outcome, detail, body_size)
- indexes on (target_type,target_id,received_at) and (received_at)
Backend:
- store: WebhookDelivery model + Insert/List/Prune helpers
- webhook/handler: deferred recordDelivery() captures the final outcome
on every return path including HMAC rejects, image mismatch, no-stage,
auto_deploy=false, and successful deploys; signatureStateFor()
classifies "unconfigured" vs "missing" vs "invalid" vs "valid"
- api: GET /api/{projects,sites}/{id}/webhook/deliveries with
parseLimit() helper (default 50, max 200)
- main: daily prune cron retains the last 14 days
Frontend:
- WebhookDeliveryLog.svelte: panel with refresh button, status code +
outcome + signature badges, relative time tooltip-on-hover for
absolute time, source IP column
- Mounted below WebhookPanel on project + site detail pages
- en/ru i18n strings for outcome/signature enums and column labels
This commit is contained in:
+162
-16
@@ -24,6 +24,62 @@ import (
|
||||
// same header so existing CI integrations work unchanged.
|
||||
const signatureHeader = "X-Hub-Signature-256"
|
||||
|
||||
// signature verification states recorded in the webhook delivery log.
|
||||
const (
|
||||
sigStateUnconfigured = "unconfigured"
|
||||
sigStateMissing = "missing"
|
||||
sigStateInvalid = "invalid"
|
||||
sigStateValid = "valid"
|
||||
)
|
||||
|
||||
// outcome values for the delivery log. Stable identifiers — frontend keys
|
||||
// off these for badge colouring + i18n.
|
||||
const (
|
||||
outcomeDeploy = "deploy"
|
||||
outcomeSkip = "skip"
|
||||
outcomeRejected = "rejected"
|
||||
outcomeNotFound = "not_found"
|
||||
outcomeBadRequest = "bad_request"
|
||||
outcomeError = "error"
|
||||
)
|
||||
|
||||
// signatureStateFor classifies the HMAC verification result for the delivery
|
||||
// log: distinguishes "no signing secret configured" from "secret configured
|
||||
// but caller sent nothing" so users can spot mis-configured CIs.
|
||||
func signatureStateFor(signingSecret, header string, verified, attempted bool) string {
|
||||
if signingSecret == "" {
|
||||
return sigStateUnconfigured
|
||||
}
|
||||
if header == "" {
|
||||
return sigStateMissing
|
||||
}
|
||||
if attempted && verified {
|
||||
return sigStateValid
|
||||
}
|
||||
return sigStateInvalid
|
||||
}
|
||||
|
||||
// recordDelivery persists a single inbound webhook delivery as a best-effort
|
||||
// audit record. Errors are logged but never propagate — the user-visible
|
||||
// response must not be affected by audit-log churn.
|
||||
func (h *Handler) recordDelivery(d store.WebhookDelivery) {
|
||||
if err := h.store.InsertWebhookDelivery(d); err != nil {
|
||||
slog.Warn("webhook: record delivery", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP returns the most-trusted source IP for logging. Strips the
|
||||
// Forwarded-For chain to its first hop and falls back to RemoteAddr.
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i >= 0 {
|
||||
return strings.TrimSpace(fwd[:i])
|
||||
}
|
||||
return strings.TrimSpace(fwd)
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// verifyHMAC validates the X-Hub-Signature-256 header against the raw body
|
||||
// using HMAC-SHA256. The function does the comparison in constant time.
|
||||
//
|
||||
@@ -244,8 +300,21 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
|
||||
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Build the audit record incrementally; record on every return path so
|
||||
// users can debug "why didn't my deploy fire?" without grepping logs.
|
||||
delivery := store.WebhookDelivery{
|
||||
TargetType: "project",
|
||||
SourceIP: clientIP(r),
|
||||
SignatureState: sigStateUnconfigured,
|
||||
StatusCode: http.StatusOK,
|
||||
Outcome: outcomeSkip,
|
||||
}
|
||||
defer func() { h.recordDelivery(delivery) }()
|
||||
|
||||
secret := chi.URLParam(r, "secret")
|
||||
if secret == "" {
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeNotFound
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -253,50 +322,79 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
project, err := h.store.GetProjectByWebhookSecret(secret)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeNotFound
|
||||
delivery.Detail = "unknown webhook secret"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("webhook: project lookup failed", "error", err)
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "lookup failed"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
delivery.TargetID = project.ID
|
||||
delivery.TargetName = project.Name
|
||||
|
||||
// Read body once so we can both verify HMAC and decode JSON.
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
||||
if err != nil {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "failed to read request body"
|
||||
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
delivery.BodySize = len(body)
|
||||
|
||||
// HMAC enforcement: a configured signing secret + the require_signature
|
||||
// flag together produce a hard reject on missing/invalid signatures.
|
||||
// When the flag is off we still verify any submitted signature so a
|
||||
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
|
||||
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
|
||||
header := r.Header.Get(signatureHeader)
|
||||
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header)
|
||||
delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted)
|
||||
if project.WebhookRequireSignature && !verified {
|
||||
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
|
||||
delivery.StatusCode = http.StatusUnauthorized
|
||||
delivery.Outcome = outcomeRejected
|
||||
delivery.Detail = "invalid or missing signature"
|
||||
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
|
||||
return
|
||||
}
|
||||
if attempted && !verified {
|
||||
slog.Warn("webhook: bad signature", "project", project.Name)
|
||||
delivery.StatusCode = http.StatusUnauthorized
|
||||
delivery.Outcome = outcomeRejected
|
||||
delivery.Detail = "invalid signature"
|
||||
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
var payload Payload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "invalid JSON payload"
|
||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
|
||||
if payload.Image == "" {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "missing image field"
|
||||
respondWebhookError(w, http.StatusBadRequest, "missing image field")
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := ParseImageRef(payload.Image)
|
||||
if err != nil {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "invalid image reference"
|
||||
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
|
||||
return
|
||||
}
|
||||
@@ -305,15 +403,13 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
parsed.Tag = "latest"
|
||||
}
|
||||
|
||||
// Guardrail: refuse payloads whose image doesn't match the project's
|
||||
// configured image. Not a security control (the secret already scopes
|
||||
// access) — just a misconfiguration check that prevents accidental
|
||||
// cross-project deploys from a misaimed CI pipeline.
|
||||
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
|
||||
slog.Warn("webhook: image mismatch",
|
||||
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
|
||||
respondWebhookError(w, http.StatusBadRequest,
|
||||
fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image))
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)
|
||||
respondWebhookError(w, http.StatusBadRequest, delivery.Detail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -323,12 +419,16 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
|
||||
if err != nil {
|
||||
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
|
||||
delivery.StatusCode = http.StatusInternalServerError
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "stage match failed"
|
||||
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
slog.Info("webhook: no stage matches tag",
|
||||
"project", project.Name, "tag", parsed.Tag)
|
||||
delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": false, "project": project.Name,
|
||||
"reason": "no stage pattern matched tag",
|
||||
@@ -339,6 +439,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if !stage.AutoDeploy {
|
||||
slog.Info("webhook: auto_deploy disabled, skipping",
|
||||
"project", project.Name, "stage", stage.Name)
|
||||
delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": false,
|
||||
"project": project.Name, "stage": stage.Name,
|
||||
@@ -348,12 +449,17 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
||||
slog.Error("webhook: deploy trigger failed", "error", err)
|
||||
delivery.StatusCode = http.StatusInternalServerError
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "deploy trigger failed: " + err.Error()
|
||||
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("webhook: triggered deploy",
|
||||
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
||||
delivery.Outcome = outcomeDeploy
|
||||
delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "deploy": true,
|
||||
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
|
||||
@@ -371,13 +477,27 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
delivery := store.WebhookDelivery{
|
||||
TargetType: "site",
|
||||
SourceIP: clientIP(r),
|
||||
SignatureState: sigStateUnconfigured,
|
||||
StatusCode: http.StatusOK,
|
||||
Outcome: outcomeSkip,
|
||||
}
|
||||
defer func() { h.recordDelivery(delivery) }()
|
||||
|
||||
if h.sites == nil {
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeNotFound
|
||||
delivery.Detail = "static site manager not configured"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
secret := chi.URLParam(r, "secret")
|
||||
if secret == "" {
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeNotFound
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -385,18 +505,26 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
site, err := h.store.GetStaticSiteByWebhookSecret(secret)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeNotFound
|
||||
delivery.Detail = "unknown webhook secret"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("webhook: site lookup failed", "error", err)
|
||||
delivery.StatusCode = http.StatusNotFound
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "lookup failed"
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
delivery.TargetID = site.ID
|
||||
delivery.TargetName = site.Name
|
||||
|
||||
// Manual sites do not auto-sync via webhook. Return success but skip.
|
||||
if site.SyncTrigger == "manual" {
|
||||
slog.Info("webhook: site sync_trigger=manual, skipping",
|
||||
"site", site.Name)
|
||||
delivery.Detail = "sync_trigger=manual"
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "sync": false, "site": site.Name,
|
||||
"reason": "sync_trigger is manual",
|
||||
@@ -404,32 +532,42 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Body is optional. We attempt to decode but accept an empty body (no Ref
|
||||
// filter); a malformed non-empty body is treated as bad-request to avoid
|
||||
// silently bypassing the branch/tag filter.
|
||||
var payload SitePayload
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
||||
if err != nil {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "failed to read request body"
|
||||
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
delivery.BodySize = len(body)
|
||||
|
||||
// HMAC enforcement matches the project flow: hard reject when required,
|
||||
// soft reject when an invalid signature is supplied without enforcement.
|
||||
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
|
||||
header := r.Header.Get(signatureHeader)
|
||||
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
|
||||
delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
|
||||
if site.WebhookRequireSignature && !verified {
|
||||
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
|
||||
delivery.StatusCode = http.StatusUnauthorized
|
||||
delivery.Outcome = outcomeRejected
|
||||
delivery.Detail = "invalid or missing signature"
|
||||
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
|
||||
return
|
||||
}
|
||||
if attempted && !verified {
|
||||
slog.Warn("webhook: site bad signature", "site", site.Name)
|
||||
delivery.StatusCode = http.StatusUnauthorized
|
||||
delivery.Outcome = outcomeRejected
|
||||
delivery.Detail = "invalid signature"
|
||||
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
delivery.StatusCode = http.StatusBadRequest
|
||||
delivery.Outcome = outcomeBadRequest
|
||||
delivery.Detail = "invalid JSON payload"
|
||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||
return
|
||||
}
|
||||
@@ -440,6 +578,7 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
"site", site.Name, "ref", payload.Ref,
|
||||
"branch", site.Branch, "tag_pattern", site.TagPattern,
|
||||
"trigger", site.SyncTrigger)
|
||||
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "sync": false, "site": site.Name,
|
||||
"reason": "ref does not match configured branch or tag pattern",
|
||||
@@ -447,11 +586,12 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cap concurrent syncs so a runaway CI cannot fan out unbounded
|
||||
// git-clone goroutines.
|
||||
select {
|
||||
case h.siteSyncSem <- struct{}{}:
|
||||
default:
|
||||
delivery.StatusCode = http.StatusServiceUnavailable
|
||||
delivery.Outcome = outcomeError
|
||||
delivery.Detail = "site sync queue full"
|
||||
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
|
||||
return
|
||||
}
|
||||
@@ -467,6 +607,12 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_ = ctx
|
||||
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
|
||||
delivery.Outcome = outcomeDeploy
|
||||
if payload.Ref != "" {
|
||||
delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref)
|
||||
} else {
|
||||
delivery.Detail = "no ref filter"
|
||||
}
|
||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
||||
"success": true, "sync": true, "site": site.Name,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user