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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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