From 0f60a7a5db64816d2c7d7949cf1c52cf6433795b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 7 May 2026 02:40:39 +0300 Subject: [PATCH] feat(webhook): inbound delivery audit log 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 --- cmd/server/main.go | 16 ++ internal/api/router.go | 2 + internal/api/webhooks.go | 60 ++++++ internal/store/store.go | 18 ++ internal/store/webhook_deliveries.go | 84 +++++++++ internal/webhook/handler.go | 178 ++++++++++++++++-- web/src/lib/api.ts | 22 +++ .../lib/components/WebhookDeliveryLog.svelte | 165 ++++++++++++++++ web/src/lib/i18n/en.json | 27 +++ web/src/lib/i18n/ru.json | 27 +++ web/src/routes/projects/[id]/+page.svelte | 4 + web/src/routes/sites/[id]/+page.svelte | 4 + 12 files changed, 591 insertions(+), 16 deletions(-) create mode 100644 internal/store/webhook_deliveries.go create mode 100644 web/src/lib/components/WebhookDeliveryLog.svelte diff --git a/cmd/server/main.go b/cmd/server/main.go index a328f69..139e6a3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -183,6 +183,22 @@ func main() { }); err != nil { slog.Warn("failed to schedule event prune cron", "error", err) } + // Webhook delivery log: keep 14 days of audit trail. Same daily cadence + // so an admin always has a recent window for debugging without + // unbounded growth on a noisy CI. + if _, err := cronScheduler.AddFunc("@daily", func() { + cutoff := time.Now().UTC().AddDate(0, 0, -14).Format("2006-01-02 15:04:05") + pruned, err := db.PruneWebhookDeliveriesBefore(cutoff) + if err != nil { + slog.Error("webhook delivery prune failed", "error", err) + return + } + if pruned > 0 { + slog.Info("pruned old webhook deliveries", "count", pruned) + } + }); err != nil { + slog.Warn("failed to schedule webhook delivery prune cron", "error", err) + } cronScheduler.Start() // Subscribe to error events and forward notifications. diff --git a/internal/api/router.go b/internal/api/router.go index bc2f229..b580164 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -249,6 +249,7 @@ func (s *Server) Router() chi.Router { r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret) r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret) r.Put("/webhook/require-signature", s.updateProjectSigningRequirement) + r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries) // Per-project outgoing-webhook signing & test. r.Get("/notification-secret", s.getProjectNotificationSecret) @@ -332,6 +333,7 @@ func (s *Server) Router() chi.Router { r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret) r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret) r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement) + r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries) // Per-site outgoing-webhook signing & test. r.Get("/notification-secret", s.getStaticSiteNotificationSecret) diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go index b477ba3..e45287f 100644 --- a/internal/api/webhooks.go +++ b/internal/api/webhooks.go @@ -6,6 +6,7 @@ import ( "errors" "log/slog" "net/http" + "strconv" "github.com/go-chi/chi/v5" @@ -248,6 +249,65 @@ func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.R respondJSON(w, http.StatusOK, map[string]bool{"success": true}) } +// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries. +// Returns the most recent webhook deliveries for the project so users can +// debug "why didn't my deploy fire?" without grepping daemon logs. +func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := s.store.GetProjectByID(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "project") + return + } + respondError(w, http.StatusInternalServerError, "failed to get project") + return + } + limit := parseLimit(r.URL.Query().Get("limit"), 50, 200) + deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit) + if err != nil { + slog.Error("list project webhook deliveries", "project", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to list deliveries") + return + } + respondJSON(w, http.StatusOK, deliveries) +} + +// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries. +func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := s.store.GetStaticSiteByID(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + respondError(w, http.StatusInternalServerError, "failed to get static site") + return + } + limit := parseLimit(r.URL.Query().Get("limit"), 50, 200) + deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit) + if err != nil { + slog.Error("list site webhook deliveries", "site", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to list deliveries") + return + } + respondJSON(w, http.StatusOK, deliveries) +} + +// parseLimit clamps a query-string limit to [1, max], falling back to def. +func parseLimit(raw string, def, max int) int { + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return def + } + if n > max { + return max + } + return n +} + // updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature. func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") diff --git a/internal/store/store.go b/internal/store/store.go index 5a774f3..8913233 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -159,6 +159,24 @@ func (s *Store) runMigrations() error { `ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`, `ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, + // Webhook delivery audit log (2026-05-07). Persists every inbound + // webhook request (project or site) with its outcome so users can + // debug "why didn't my deploy fire?" without grepping daemon logs. + `CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL DEFAULT '', + target_name TEXT NOT NULL DEFAULT '', + received_at TEXT NOT NULL DEFAULT (datetime('now')), + source_ip TEXT NOT NULL DEFAULT '', + signature_state TEXT NOT NULL DEFAULT '', + status_code INTEGER NOT NULL DEFAULT 0, + outcome TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + body_size INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`, + `CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`, } // Additive stack tables (2026-04-16). Created here rather than in the diff --git a/internal/store/webhook_deliveries.go b/internal/store/webhook_deliveries.go new file mode 100644 index 0000000..7aa7d55 --- /dev/null +++ b/internal/store/webhook_deliveries.go @@ -0,0 +1,84 @@ +package store + +import ( + "fmt" +) + +// WebhookDelivery is one persisted inbound webhook hit. Recorded after the +// handler decides what to do so the row reflects the final outcome. +type WebhookDelivery struct { + ID int64 `json:"id"` + TargetType string `json:"target_type"` // "project" or "site" + TargetID string `json:"target_id"` + TargetName string `json:"target_name"` + ReceivedAt string `json:"received_at"` + SourceIP string `json:"source_ip"` + SignatureState string `json:"signature_state"` // "valid" / "invalid" / "missing" / "unconfigured" + StatusCode int `json:"status_code"` + Outcome string `json:"outcome"` // "deploy" / "skip" / "rejected" / etc. + Detail string `json:"detail"` + BodySize int `json:"body_size"` +} + +// InsertWebhookDelivery persists a single webhook delivery record. Best-effort +// — failures here must not block the actual webhook handler, so callers +// should log and continue rather than propagate. +func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error { + _, err := s.db.Exec( + `INSERT INTO webhook_deliveries + (target_type, target_id, target_name, source_ip, signature_state, + status_code, outcome, detail, body_size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + d.TargetType, d.TargetID, d.TargetName, d.SourceIP, d.SignatureState, + d.StatusCode, d.Outcome, d.Detail, d.BodySize, + ) + if err != nil { + return fmt.Errorf("insert webhook delivery: %w", err) + } + return nil +} + +// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a +// specific target. Used by the per-entity panel on the project / site detail +// pages. +func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.db.Query( + `SELECT id, target_type, target_id, target_name, received_at, source_ip, + signature_state, status_code, outcome, detail, body_size + FROM webhook_deliveries + WHERE target_type = ? AND target_id = ? + ORDER BY id DESC + LIMIT ?`, + targetType, targetID, limit, + ) + if err != nil { + return nil, fmt.Errorf("query webhook deliveries: %w", err) + } + defer rows.Close() + + out := []WebhookDelivery{} + for rows.Next() { + var d WebhookDelivery + if err := rows.Scan(&d.ID, &d.TargetType, &d.TargetID, &d.TargetName, &d.ReceivedAt, + &d.SourceIP, &d.SignatureState, &d.StatusCode, &d.Outcome, &d.Detail, &d.BodySize); err != nil { + return nil, fmt.Errorf("scan webhook delivery: %w", err) + } + out = append(out, d) + } + return out, rows.Err() +} + +// PruneWebhookDeliveriesBefore deletes rows older than the given timestamp. +// Returns the number of rows removed. Run on the same daily cron as the +// event log to keep the table from growing without bound. +func (s *Store) PruneWebhookDeliveriesBefore(beforeTS string) (int64, error) { + res, err := s.db.Exec(`DELETE FROM webhook_deliveries WHERE received_at < ?`, beforeTS) + if err != nil { + return 0, fmt.Errorf("prune webhook deliveries: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index eaa9acb..3c9527a 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -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, }) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 291aea6..850c81a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -376,6 +376,28 @@ export async function setStaticSiteRequireSignature(siteId: string, require: boo await put(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require }); } +export interface WebhookDelivery { + id: number; + target_type: 'project' | 'site'; + target_id: string; + target_name: string; + received_at: string; + source_ip: string; + signature_state: 'valid' | 'invalid' | 'missing' | 'unconfigured'; + status_code: number; + outcome: string; + detail: string; + body_size: number; +} + +export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise { + return get(`/api/projects/${projectId}/webhook/deliveries`, signal); +} + +export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise { + return get(`/api/sites/${siteId}/webhook/deliveries`, signal); +} + // ── Outgoing-webhook signing & test ──────────────────────────────── export interface NotificationSecretResponse { diff --git a/web/src/lib/components/WebhookDeliveryLog.svelte b/web/src/lib/components/WebhookDeliveryLog.svelte new file mode 100644 index 0000000..22bdd00 --- /dev/null +++ b/web/src/lib/components/WebhookDeliveryLog.svelte @@ -0,0 +1,165 @@ + + + +
+
+
+

{$t('webhookLog.title')}

+

{$t('webhookLog.description')}

+
+ +
+ + {#if loading} +
+
+
+
+
+ {:else if error} +

{error}

+ {:else if deliveries.length === 0} +

{$t('webhookLog.empty')}

+ {:else} +
+ + + + + + + + + + + + + {#each deliveries as d (d.id)} + + + + + + + + + {/each} + +
{$t('webhookLog.colTime')}{$t('webhookLog.colStatus')}{$t('webhookLog.colOutcome')}{$t('webhookLog.colSignature')}{$t('webhookLog.colDetail')}{$t('webhookLog.colSource')}
+ {$fmt.relative(toUtcIso(d.received_at))} + + + {d.status_code} + + + + {$t(`webhookLog.outcome.${d.outcome}`) || d.outcome} + + + + {$t(`webhookLog.sig.${d.signature_state}`) || d.signature_state} + + + {d.detail} + + {d.source_ip} +
+
+ {/if} +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 2716b6e..d40dfa7 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1172,6 +1172,33 @@ "incoming": "Incoming webhooks", "incomingMovedDesc": "Inbound webhooks are now scoped per entity. Open a project or static site to view and rotate its webhook URL." }, + "webhookLog": { + "title": "Recent webhook deliveries", + "description": "The last 14 days of inbound webhook hits — outcome, signature state, and reason. Refreshes every 30 seconds.", + "refresh": "Refresh", + "loadFailed": "Failed to load webhook deliveries", + "empty": "No webhook deliveries yet.", + "colTime": "When", + "colStatus": "Status", + "colOutcome": "Outcome", + "colSignature": "Signature", + "colDetail": "Detail", + "colSource": "Source", + "outcome": { + "deploy": "Deployed", + "skip": "Skipped", + "rejected": "Rejected", + "not_found": "Not found", + "bad_request": "Bad request", + "error": "Error" + }, + "sig": { + "valid": "valid", + "invalid": "invalid", + "missing": "missing", + "unconfigured": "off" + } + }, "webhookPanel": { "copy": "Copy", "copied": "Webhook URL copied to clipboard", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 1ebcff1..4e16a53 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1172,6 +1172,33 @@ "incoming": "Входящие вебхуки", "incomingMovedDesc": "Входящие вебхуки теперь привязаны к конкретному проекту или сайту. Откройте страницу проекта или статического сайта, чтобы увидеть и перегенерировать URL." }, + "webhookLog": { + "title": "Последние доставки вебхуков", + "description": "Последние 14 дней входящих вебхуков — результат, состояние подписи и причина. Обновляется каждые 30 секунд.", + "refresh": "Обновить", + "loadFailed": "Не удалось загрузить журнал доставок", + "empty": "Пока нет доставок.", + "colTime": "Когда", + "colStatus": "Статус", + "colOutcome": "Результат", + "colSignature": "Подпись", + "colDetail": "Подробности", + "colSource": "Источник", + "outcome": { + "deploy": "Развёрнуто", + "skip": "Пропущено", + "rejected": "Отклонено", + "not_found": "Не найдено", + "bad_request": "Неверный запрос", + "error": "Ошибка" + }, + "sig": { + "valid": "верна", + "invalid": "неверна", + "missing": "отсутствует", + "unconfigured": "выкл" + } + }, "webhookPanel": { "copy": "Копировать", "copied": "Webhook-URL скопирован в буфер обмена", diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 5c19289..615a416 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -14,6 +14,7 @@ import FormField from '$lib/components/FormField.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import WebhookPanel from '$lib/components/WebhookPanel.svelte'; + import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte'; import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte'; import type { EntityPickerItem } from '$lib/types'; @@ -811,6 +812,9 @@ setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)} /> + + api.listProjectWebhookDeliveries(projectId, signal)} /> + api.setStaticSiteRequireSignature(siteId!, require)} /> + + api.listStaticSiteWebhookDeliveries(siteId!, signal)} /> +

{$t('sites.outgoingUrlTitle')}