cba2149aa9
Wraps up the workload refactor with the fixes that came out of the multi-agent code review (see docs/plans/workload-refactor.md "What actually shipped"). Backend: - store.ReconcileContainer: separate write path so the 30s reconciler tick no longer overwrites deployer-owned fields (subdomain, proxy_route_id, npm_proxy_id, image_tag). - Container.stage_id column + index; ListProxyRoutes / ListContainersByStageID join via stage_id (survives stage rename), with legacy fallback to (project_id, role=stage_name). - Reconciler: workload-existence check (rejects forged tinyforge.workload.id labels), skips inventing project-kind rows, child-context cancel before wg.Wait() on shutdown. - Transactional CRUD across projects / stacks / static_sites: parent UPDATE and workload sync land in one transaction so secret rotations are durable. - Webhook routing reads exclusively through workloads.webhook_secret; legacy GetProjectByWebhookSecret / GetStaticSiteByWebhookSecret fallback removed. - store.GetStackByComposeProjectName + indexed lookup (no more full-table stack scan per compose container per tick). - store.ListMissingSweepRows: filtered query for the missing-sweep. - /api/instances/* handlers verify (workload_id, role) match URL (project_id, stage_name) before mutating — closes the cross-project hijack the security review flagged. - extra_json no longer referenced from Go (column kept on disk for now). Frontend: - WorkloadContainers.svelte: generic detail-page panel reusable by stack and site detail pages. - Containers page polish: client-side kind/state filters over an unfiltered fetch, URL-synced filters, race-safe loads via sequence number, EN+RU i18n, sidebar counter via navCounts.containers. Misc: - scripts/dev-server.sh: tolerate empty netstat grep result. - .gitignore: ignore docker-watcher binaries, .claude/worktrees/, .facts-sync.json.
649 lines
22 KiB
Go
649 lines
22 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// signatureHeader is the canonical Gitea/GitHub-compatible header name for
|
|
// HMAC-SHA256 signatures over the raw request body. Tinyforge accepts the
|
|
// 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.
|
|
//
|
|
// Behavior:
|
|
// - signingSecret == "": signing not configured for this entity. The
|
|
// function returns (false, false) — the caller decides whether to
|
|
// enforce based on the require_signature flag.
|
|
// - header missing: returns (false, true) — caller-decided.
|
|
// - header malformed or signature mismatch: returns (false, true).
|
|
// - signature valid: returns (true, true).
|
|
//
|
|
// First return: whether the signature was successfully verified.
|
|
// Second return: whether the verification was attempted (i.e., a header was
|
|
// present or signing is configured). The caller uses this to distinguish
|
|
// "no signature submitted" from "wrong signature submitted".
|
|
func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified, attempted bool) {
|
|
if signingSecret == "" {
|
|
return false, false
|
|
}
|
|
if headerValue == "" {
|
|
return false, false
|
|
}
|
|
const prefix = "sha256="
|
|
if !strings.HasPrefix(headerValue, prefix) {
|
|
return false, true
|
|
}
|
|
provided, err := hex.DecodeString(headerValue[len(prefix):])
|
|
if err != nil {
|
|
return false, true
|
|
}
|
|
mac := hmac.New(sha256.New, []byte(signingSecret))
|
|
mac.Write(body)
|
|
expected := mac.Sum(nil)
|
|
return hmac.Equal(provided, expected), true
|
|
}
|
|
|
|
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
|
|
// webhooks. Above this limit, requests are rejected with 503.
|
|
const maxSiteConcurrentSyncs = 4
|
|
|
|
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
|
|
// /api routes already wrap the body with MaxBytesReader, but the webhook
|
|
// router relies on its own limit so changes to the parent middleware can't
|
|
// silently increase the cap.
|
|
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
|
|
|
|
// DeployTriggerer is called when a webhook determines a deploy should happen.
|
|
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
|
|
type DeployTriggerer interface {
|
|
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
}
|
|
|
|
// SiteSyncTriggerer is called when a static-site webhook determines a sync
|
|
// should happen. The manager handles the actual git-pull + redeploy.
|
|
type SiteSyncTriggerer interface {
|
|
Deploy(ctx context.Context, siteID string, force bool) error
|
|
}
|
|
|
|
// Payload is the expected JSON body for a project webhook request.
|
|
type Payload struct {
|
|
// Image is the full image reference including tag, e.g.
|
|
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
|
|
Image string `json:"image"`
|
|
}
|
|
|
|
// SitePayload is the expected JSON body for a static-site webhook request.
|
|
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
|
|
// matters for branch filtering. Body is optional — an empty body triggers
|
|
// a sync using the site's configured branch.
|
|
type SitePayload struct {
|
|
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
|
|
}
|
|
|
|
// ParsedImage holds the components extracted from a full image reference string.
|
|
type ParsedImage struct {
|
|
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
|
Registry string
|
|
// Owner is the namespace/org, e.g. "alexei".
|
|
Owner string
|
|
// Name is the repository name, e.g. "web-app-launcher".
|
|
Name string
|
|
// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
|
|
Tag string
|
|
}
|
|
|
|
// FullName returns "owner/name" (the image path without registry and tag).
|
|
func (p ParsedImage) FullName() string {
|
|
if p.Owner != "" {
|
|
return p.Owner + "/" + p.Name
|
|
}
|
|
return p.Name
|
|
}
|
|
|
|
// ParseImageRef splits a full image reference into its components.
|
|
// Accepted formats:
|
|
//
|
|
// registry.example.com/owner/name:tag
|
|
// registry.example.com/owner/name
|
|
// owner/name:tag
|
|
// name:tag
|
|
func ParseImageRef(ref string) (ParsedImage, error) {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
return ParsedImage{}, fmt.Errorf("empty image reference")
|
|
}
|
|
|
|
var parsed ParsedImage
|
|
|
|
// Split off tag.
|
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
|
// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
|
|
afterColon := ref[idx+1:]
|
|
if !strings.Contains(afterColon, "/") {
|
|
parsed.Tag = afterColon
|
|
ref = ref[:idx]
|
|
}
|
|
}
|
|
|
|
parts := strings.Split(ref, "/")
|
|
switch len(parts) {
|
|
case 1:
|
|
// "name"
|
|
parsed.Name = parts[0]
|
|
case 2:
|
|
// "owner/name"
|
|
parsed.Owner = parts[0]
|
|
parsed.Name = parts[1]
|
|
default:
|
|
// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
|
|
parsed.Registry = parts[0]
|
|
parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
|
|
parsed.Name = parts[len(parts)-1]
|
|
}
|
|
|
|
if parsed.Name == "" {
|
|
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
|
|
}
|
|
|
|
return parsed, nil
|
|
}
|
|
|
|
// Handler is the HTTP handler for webhook requests.
|
|
type Handler struct {
|
|
store *store.Store
|
|
deployer DeployTriggerer
|
|
sites SiteSyncTriggerer
|
|
|
|
// Site sync coordination — webhooks fire syncs in the background; Drain
|
|
// blocks until those goroutines finish, so a graceful shutdown does not
|
|
// kill an in-flight git fetch + container rebuild.
|
|
siteSyncCtx context.Context
|
|
siteSyncCancel context.CancelFunc
|
|
siteSyncWG sync.WaitGroup
|
|
siteSyncSem chan struct{}
|
|
}
|
|
|
|
// NewHandler creates a new webhook Handler. The sites triggerer is optional
|
|
// and may be nil (site webhooks will return 404).
|
|
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Handler{
|
|
store: st,
|
|
deployer: deployer,
|
|
sites: sites,
|
|
siteSyncCtx: ctx,
|
|
siteSyncCancel: cancel,
|
|
siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs),
|
|
}
|
|
}
|
|
|
|
// SetSiteSyncTriggerer injects the static-site manager after construction.
|
|
// The site manager depends on the store + docker client, which are wired up
|
|
// in the same startup path as the handler; this setter lets callers defer the
|
|
// dependency if needed.
|
|
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
|
|
h.sites = s
|
|
}
|
|
|
|
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
|
|
// Safe to call from a graceful-shutdown path.
|
|
func (h *Handler) Drain() {
|
|
h.siteSyncCancel()
|
|
h.siteSyncWG.Wait()
|
|
}
|
|
|
|
// Route returns a chi router with the webhook endpoints mounted.
|
|
//
|
|
// Routes:
|
|
//
|
|
// POST /{secret} — per-project deploy trigger
|
|
// POST /sites/{secret} — per-site sync trigger
|
|
func (h *Handler) Route() chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
|
r.Post("/{secret}", h.handleWebhook)
|
|
return r
|
|
}
|
|
|
|
// respondWebhookJSON writes a JSON response for webhook handlers.
|
|
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
|
}
|
|
|
|
// respondWebhookError writes a JSON error response for webhook handlers.
|
|
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
|
|
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
|
}
|
|
|
|
// handleWebhook processes an incoming project webhook request.
|
|
//
|
|
// URL: POST /api/webhook/{secret}
|
|
//
|
|
// The secret identifies exactly one project. Stage routing is delegated to
|
|
// the project's configured stages (tag_pattern match). Returns 404 for
|
|
// unknown secrets (no information leak).
|
|
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
|
|
}
|
|
|
|
// Resolve the secret via the workload row only. The project's own
|
|
// webhook_secret column is the source of truth, but lookups go through
|
|
// workloads.webhook_secret which is kept in lock-step by the
|
|
// transactional sync in the project CRUD path. Reading from workloads
|
|
// alone closes the rotation-durability gap: any rotation that didn't
|
|
// commit also didn't update the workload row, so an old secret
|
|
// surfaces here as 404 rather than being silently accepted.
|
|
var (
|
|
project store.Project
|
|
err error
|
|
)
|
|
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
|
|
if wErr == nil && wl.Kind == string(store.WorkloadKindProject) {
|
|
project, err = h.store.GetProjectByID(wl.RefID)
|
|
} else {
|
|
err = store.ErrNotFound
|
|
}
|
|
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.
|
|
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
|
|
}
|
|
|
|
if parsed.Tag == "" {
|
|
parsed.Tag = "latest"
|
|
}
|
|
|
|
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
|
|
slog.Warn("webhook: image mismatch",
|
|
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
|
|
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
|
|
}
|
|
|
|
slog.Info("webhook: received push",
|
|
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
|
|
|
|
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",
|
|
})
|
|
return
|
|
}
|
|
|
|
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,
|
|
})
|
|
return
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// handleSiteWebhook processes an incoming static-site webhook request.
|
|
//
|
|
// URL: POST /api/webhook/sites/{secret}
|
|
//
|
|
// The secret identifies exactly one static site. If the payload includes a
|
|
// ref (Git push event), it must match the site's configured branch (when the
|
|
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
|
|
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
|
|
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
|
|
}
|
|
|
|
// Workload-only lookup, mirroring the project handler. Reading from
|
|
// workloads.webhook_secret keeps rotation-durability honest — a
|
|
// rotation that didn't commit doesn't update the workload row, so the
|
|
// stale secret returns 404 instead of being silently accepted.
|
|
var (
|
|
site store.StaticSite
|
|
err error
|
|
)
|
|
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
|
|
if wErr == nil && wl.Kind == string(store.WorkloadKindSite) {
|
|
site, err = h.store.GetStaticSiteByID(wl.RefID)
|
|
} else {
|
|
err = store.ErrNotFound
|
|
}
|
|
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
|
|
|
|
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",
|
|
})
|
|
return
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
|
|
slog.Info("webhook: site ref does not match configured branch/tag",
|
|
"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",
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
h.siteSyncWG.Add(1)
|
|
go func(siteID, siteName string) {
|
|
defer h.siteSyncWG.Done()
|
|
defer func() { <-h.siteSyncSem }()
|
|
if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil {
|
|
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
|
|
}
|
|
}(site.ID, site.Name)
|
|
|
|
_ = 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,
|
|
})
|
|
}
|