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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -376,6 +376,28 @@ export async function setStaticSiteRequireSignature(siteId: string, require: boo
|
||||
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 ────────────────────────────────
|
||||
|
||||
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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 скопирован в буфер обмена",
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
|
||||
|
||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||
<OutgoingWebhookPanel
|
||||
title={$t('projectDetail.outgoingWebhookTitle')}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import ForgeHero from '$lib/components/ForgeHero.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 ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||
@@ -317,6 +318,9 @@
|
||||
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). -->
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user