feat(webhook): inbound delivery audit log
Build / build (push) Successful in 10m35s

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:
2026-05-07 02:40:39 +03:00
parent 831b5c1a43
commit 0f60a7a5db
12 changed files with 591 additions and 16 deletions
+16
View File
@@ -183,6 +183,22 @@ func main() {
}); err != nil { }); err != nil {
slog.Warn("failed to schedule event prune cron", "error", err) 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() cronScheduler.Start()
// Subscribe to error events and forward notifications. // Subscribe to error events and forward notifications.
+2
View File
@@ -249,6 +249,7 @@ func (s *Server) Router() chi.Router {
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret) r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret) r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement) r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
// Per-project outgoing-webhook signing & test. // Per-project outgoing-webhook signing & test.
r.Get("/notification-secret", s.getProjectNotificationSecret) 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.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret) r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement) r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
// Per-site outgoing-webhook signing & test. // Per-site outgoing-webhook signing & test.
r.Get("/notification-secret", s.getStaticSiteNotificationSecret) r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
+60
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi/v5" "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}) 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. // updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) { func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
+18
View File
@@ -159,6 +159,24 @@ func (s *Store) runMigrations() error {
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, `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_signing_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, `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 // Additive stack tables (2026-04-16). Created here rather than in the
+84
View File
@@ -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
}
+162 -16
View File
@@ -24,6 +24,62 @@ import (
// same header so existing CI integrations work unchanged. // same header so existing CI integrations work unchanged.
const signatureHeader = "X-Hub-Signature-256" 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 // verifyHMAC validates the X-Hub-Signature-256 header against the raw body
// using HMAC-SHA256. The function does the comparison in constant time. // 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) { func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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") secret := chi.URLParam(r, "secret")
if secret == "" { if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -253,50 +322,79 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
project, err := h.store.GetProjectByWebhookSecret(secret) project, err := h.store.GetProjectByWebhookSecret(secret)
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
slog.Error("webhook: project lookup failed", "error", err) slog.Error("webhook: project lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
delivery.TargetID = project.ID
delivery.TargetName = project.Name
// Read body once so we can both verify HMAC and decode JSON. // Read body once so we can both verify HMAC and decode JSON.
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil { 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") respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return return
} }
delivery.BodySize = len(body)
// HMAC enforcement: a configured signing secret + the require_signature // HMAC enforcement: a configured signing secret + the require_signature
// flag together produce a hard reject on missing/invalid signatures. // flag together produce a hard reject on missing/invalid signatures.
// When the flag is off we still verify any submitted signature so a // When the flag is off we still verify any submitted signature so a
// CI misconfiguration surfaces as a 401 rather than silent acceptance. // 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 { if project.WebhookRequireSignature && !verified {
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name) 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") respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return return
} }
if attempted && !verified { if attempted && !verified {
slog.Warn("webhook: bad signature", "project", project.Name) 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") respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return return
} }
var payload Payload var payload Payload
if err := json.Unmarshal(body, &payload); err != nil { 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") respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return return
} }
if payload.Image == "" { if payload.Image == "" {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "missing image field"
respondWebhookError(w, http.StatusBadRequest, "missing image field") respondWebhookError(w, http.StatusBadRequest, "missing image field")
return return
} }
parsed, err := ParseImageRef(payload.Image) parsed, err := ParseImageRef(payload.Image)
if err != nil { if err != nil {
delivery.StatusCode = http.StatusBadRequest
delivery.Outcome = outcomeBadRequest
delivery.Detail = "invalid image reference"
respondWebhookError(w, http.StatusBadRequest, "invalid image reference") respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return return
} }
@@ -305,15 +403,13 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
parsed.Tag = "latest" 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()) { if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
slog.Warn("webhook: image mismatch", slog.Warn("webhook: image mismatch",
"project", project.Name, "expected", project.Image, "received", parsed.FullName()) "project", project.Name, "expected", project.Image, "received", parsed.FullName())
respondWebhookError(w, http.StatusBadRequest, delivery.StatusCode = http.StatusBadRequest
fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)) 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 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) stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil { if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err) 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") respondWebhookError(w, http.StatusInternalServerError, "internal error")
return return
} }
if !found { if !found {
slog.Info("webhook: no stage matches tag", slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.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{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name, "success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag", "reason": "no stage pattern matched tag",
@@ -339,6 +439,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
if !stage.AutoDeploy { if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping", slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name) "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{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "success": true, "deploy": false,
"project": project.Name, "stage": stage.Name, "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 { if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
slog.Error("webhook: deploy trigger failed", "error", err) 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") respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return return
} }
slog.Info("webhook: triggered deploy", slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag) "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{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true, "success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag, "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) { func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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 { if h.sites == nil {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "static site manager not configured"
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
secret := chi.URLParam(r, "secret") secret := chi.URLParam(r, "secret")
if secret == "" { if secret == "" {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -385,18 +505,26 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
site, err := h.store.GetStaticSiteByWebhookSecret(secret) site, err := h.store.GetStaticSiteByWebhookSecret(secret)
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeNotFound
delivery.Detail = "unknown webhook secret"
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
slog.Error("webhook: site lookup failed", "error", err) slog.Error("webhook: site lookup failed", "error", err)
delivery.StatusCode = http.StatusNotFound
delivery.Outcome = outcomeError
delivery.Detail = "lookup failed"
http.NotFound(w, r) http.NotFound(w, r)
return 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" { if site.SyncTrigger == "manual" {
slog.Info("webhook: site sync_trigger=manual, skipping", slog.Info("webhook: site sync_trigger=manual, skipping",
"site", site.Name) "site", site.Name)
delivery.Detail = "sync_trigger=manual"
respondWebhookJSON(w, http.StatusOK, map[string]any{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name, "success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual", "reason": "sync_trigger is manual",
@@ -404,32 +532,42 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
return 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 var payload SitePayload
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil { 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") respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return return
} }
delivery.BodySize = len(body)
// HMAC enforcement matches the project flow: hard reject when required, header := r.Header.Get(signatureHeader)
// soft reject when an invalid signature is supplied without enforcement. verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, r.Header.Get(signatureHeader)) delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
if site.WebhookRequireSignature && !verified { if site.WebhookRequireSignature && !verified {
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name) 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") respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return return
} }
if attempted && !verified { if attempted && !verified {
slog.Warn("webhook: site bad signature", "site", site.Name) 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") respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return return
} }
if len(body) > 0 { if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil { 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") respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return return
} }
@@ -440,6 +578,7 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
"site", site.Name, "ref", payload.Ref, "site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern, "branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger) "trigger", site.SyncTrigger)
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name, "success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern", "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 return
} }
// Cap concurrent syncs so a runaway CI cannot fan out unbounded
// git-clone goroutines.
select { select {
case h.siteSyncSem <- struct{}{}: case h.siteSyncSem <- struct{}{}:
default: default:
delivery.StatusCode = http.StatusServiceUnavailable
delivery.Outcome = outcomeError
delivery.Detail = "site sync queue full"
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full") respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
return return
} }
@@ -467,6 +607,12 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
_ = ctx _ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref) 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{ respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name, "success": true, "sync": true, "site": site.Name,
}) })
+22
View File
@@ -376,6 +376,28 @@ export async function setStaticSiteRequireSignature(siteId: string, require: boo
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require }); await put<void>(`/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<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/projects/${projectId}/webhook/deliveries`, signal);
}
export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(`/api/sites/${siteId}/webhook/deliveries`, signal);
}
// ── Outgoing-webhook signing & test ──────────────────────────────── // ── Outgoing-webhook signing & test ────────────────────────────────
export interface NotificationSecretResponse { export interface NotificationSecretResponse {
@@ -0,0 +1,165 @@
<!--
WebhookDeliveryLog
Recent inbound webhook activity panel. Used on the project + site detail
pages so users can debug "why didn't my deploy fire?" without grepping
daemon logs. Polls the audit table every 30s while the panel is mounted.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { WebhookDelivery } from '$lib/api';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
import { IconRefresh, IconLoader } from '$lib/components/icons';
interface Props {
fetchDeliveries: (signal?: AbortSignal) => Promise<WebhookDelivery[]>;
}
const { fetchDeliveries }: Props = $props();
let deliveries = $state<WebhookDelivery[]>([]);
let loading = $state(true);
let refreshing = $state(false);
let error = $state('');
let controller = new AbortController();
let interval: ReturnType<typeof setInterval> | null = null;
async function load(refresh = false) {
controller.abort();
controller = new AbortController();
if (refresh) {
refreshing = true;
}
try {
deliveries = (await fetchDeliveries(controller.signal)) ?? [];
error = '';
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('webhookLog.loadFailed');
} finally {
loading = false;
refreshing = false;
}
}
function outcomeBadge(outcome: string): string {
switch (outcome) {
case 'deploy':
return 'badge-success';
case 'rejected':
case 'error':
return 'badge-danger';
case 'bad_request':
case 'not_found':
return 'badge-warning';
default:
return 'badge-neutral';
}
}
function signatureBadge(state: string): string {
switch (state) {
case 'valid':
return 'badge-success';
case 'invalid':
return 'badge-danger';
case 'missing':
return 'badge-warning';
default:
return 'badge-neutral';
}
}
/** Backend returns naive (no-offset) timestamps — treat as UTC. */
function toUtcIso(s: string): string {
if (!s) return '';
return /Z|[+-]\d{2}:?\d{2}$/.test(s) ? s : s.replace(' ', 'T') + 'Z';
}
onMount(() => {
load();
interval = setInterval(() => load(), 30_000);
});
onDestroy(() => {
controller.abort();
if (interval) clearInterval(interval);
});
</script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="mb-4 flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('webhookLog.title')}</h2>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('webhookLog.description')}</p>
</div>
<button
type="button"
onclick={() => load(true)}
disabled={refreshing || loading}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
{#if refreshing}<IconLoader size={14} />{:else}<IconRefresh size={14} />{/if}
<span>{$t('webhookLog.refresh')}</span>
</button>
</div>
{#if loading}
<div class="space-y-2">
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
<div class="h-10 rounded-lg bg-[var(--surface-card-hover)]"></div>
</div>
{:else if error}
<p class="text-sm text-[var(--color-danger)]">{error}</p>
{:else if deliveries.length === 0}
<p class="text-sm italic text-[var(--text-tertiary)]">{$t('webhookLog.empty')}</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)] text-xs uppercase tracking-wide text-[var(--text-secondary)]">
<th class="px-3 py-2 text-left">{$t('webhookLog.colTime')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colStatus')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colOutcome')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colSignature')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colDetail')}</th>
<th class="px-3 py-2 text-left">{$t('webhookLog.colSource')}</th>
</tr>
</thead>
<tbody>
{#each deliveries as d (d.id)}
<tr class="border-b border-[var(--border-primary)] last:border-b-0 hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-3 py-2 whitespace-nowrap text-[var(--text-secondary)]" title={$fmt.dateTime(toUtcIso(d.received_at))}>
{$fmt.relative(toUtcIso(d.received_at))}
</td>
<td class="px-3 py-2 tabular-nums">
<span class="font-mono {d.status_code >= 500 ? 'text-[var(--color-danger)]' : d.status_code >= 400 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'}">
{d.status_code}
</span>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {outcomeBadge(d.outcome)}">
{$t(`webhookLog.outcome.${d.outcome}`) || d.outcome}
</span>
</td>
<td class="px-3 py-2">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {signatureBadge(d.signature_state)}">
{$t(`webhookLog.sig.${d.signature_state}`) || d.signature_state}
</span>
</td>
<td class="px-3 py-2 text-[var(--text-secondary)]">
<span class="truncate" title={d.detail}>{d.detail}</span>
</td>
<td class="px-3 py-2 font-mono text-xs text-[var(--text-tertiary)]">
{d.source_ip}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
+27
View File
@@ -1172,6 +1172,33 @@
"incoming": "Incoming webhooks", "incoming": "Incoming webhooks",
"incomingMovedDesc": "Inbound webhooks are now scoped per entity. Open a project or static site to view and rotate its webhook URL." "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": { "webhookPanel": {
"copy": "Copy", "copy": "Copy",
"copied": "Webhook URL copied to clipboard", "copied": "Webhook URL copied to clipboard",
+27
View File
@@ -1172,6 +1172,33 @@
"incoming": "Входящие вебхуки", "incoming": "Входящие вебхуки",
"incomingMovedDesc": "Входящие вебхуки теперь привязаны к конкретному проекту или сайту. Откройте страницу проекта или статического сайта, чтобы увидеть и перегенерировать URL." "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": { "webhookPanel": {
"copy": "Копировать", "copy": "Копировать",
"copied": "Webhook-URL скопирован в буфер обмена", "copied": "Webhook-URL скопирован в буфер обмена",
@@ -14,6 +14,7 @@
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte'; import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte'; import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types'; import type { EntityPickerItem } from '$lib/types';
@@ -811,6 +812,9 @@
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)} setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
/> />
<!-- Recent inbound webhook activity (debug + audit). -->
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). --> <!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
<OutgoingWebhookPanel <OutgoingWebhookPanel
title={$t('projectDetail.outgoingWebhookTitle')} title={$t('projectDetail.outgoingWebhookTitle')}
+4
View File
@@ -10,6 +10,7 @@
import ForgeHero from '$lib/components/ForgeHero.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import WebhookPanel from '$lib/components/WebhookPanel.svelte'; import WebhookPanel from '$lib/components/WebhookPanel.svelte';
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte'; import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte'; import ContainerStats from '$lib/components/ContainerStats.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte'; import ContainerLogs from '$lib/components/ContainerLogs.svelte';
@@ -317,6 +318,9 @@
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)} setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
/> />
<!-- Recent inbound webhook activity (debug + audit). -->
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listStaticSiteWebhookDeliveries(siteId!, signal)} />
<!-- Outgoing notification URL (per-site override; falls through to global). --> <!-- Outgoing notification URL (per-site override; falls through to global). -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2> <h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>