package webhook import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/workload/plugin" ) // Local aliases — keep the interface surface small and avoid leaking // plugin types into every consumer of the webhook package's API. type ( pluginWorkload = plugin.Workload pluginIntent = plugin.DeploymentIntent pluginDeps = plugin.Deps ) // 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 } // 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 // PluginDispatcher is what the plugin-workload webhook handler needs from // the deployer: the canonical Source-dispatch entry point plus access to // the same Deps bundle so Trigger.Match can read store / crypto. // // DispatchTeardown is required so the preview-deploy flow can tear down // an ephemeral per-branch child workload when its upstream branch is // deleted. Same teardown path the API /workloads/{id} DELETE route uses; // nil error on a clean teardown lets the caller delete the workload row. type PluginDispatcher interface { DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) error DispatchTeardown(ctx context.Context, w pluginWorkload) error PluginDeps() pluginDeps } // parsedImage holds the components extracted from a full image reference // string. Package-private — the only callers are buildInboundEvent and the // vendor parsers in this package. 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. After the legacy // project / site webhook routes were dropped, the only inbound path is // the trigger fan-out — every project / site / stack webhook was lifted // into a first-class Trigger row by the boot backfill. type Handler struct { store *store.Store plugins PluginDispatcher // optional; nil disables /triggers/{secret} } // NewHandler creates a new webhook Handler bound to a store. func NewHandler(st *store.Store) *Handler { return &Handler{store: st} } // SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this // is called the /triggers/{secret} route returns 503 — preventing partial // initialization from silently dropping deploys. func (h *Handler) SetPluginDispatcher(d PluginDispatcher) { h.plugins = d } // Drain is a no-op kept for symmetry with the previous shutdown path. // The trigger fan-out runs synchronously inside the request goroutine, // so there is nothing to drain at the handler level. func (h *Handler) Drain() {} // Route returns a chi router with the single inbound webhook endpoint // mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret} // routes were removed in the hard cutover; their secrets were lifted // into Trigger rows on boot. func (h *Handler) Route() chi.Router { r := chi.NewRouter() r.Post("/triggers/{secret}", h.handleTriggerWebhook) 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}) } // buildInboundEvent normalizes the incoming webhook body into the // plugin.InboundEvent shape. The dispatch order is: // // 1. Empty body → manual event (used by the test-trigger UI button). // 2. Vendor-specific parsers (Gitea package, GitHub registry_package, // GitHub/Gitea/GitLab push) — short-circuit on a recognized // X-*-Event header. Vendor parsers can fully populate richer fields // (image digest, vendor tag, branch) the generic parser cannot. // 3. Generic simple-body parser: top-level `image` for registry pushes, // top-level `ref` for git pushes. This is what the legacy webhook // CIs already send and what the operator-facing API surface // documents. // // RawBody and Headers are always attached so trigger plugins can do // their own vendor-specific parsing if they need fields outside this // normalized envelope. func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, error) { evt := plugin.InboundEvent{ RawBody: body, Headers: headers, } if len(body) == 0 { evt.Kind = "manual" evt.Manual = &plugin.ManualEvent{Actor: "webhook"} return evt, nil } // Try vendor-specific parsers first. A vendor parser claiming the // request (ok=true) is authoritative — we don't fall through even // if it returned an error, because the operator's CI is sending a // known vendor payload and silently re-parsing as generic would // hide the real cause. if res := tryVendorParsers(body, headers); res.ok { if res.err != nil { return plugin.InboundEvent{}, res.err } res.event.RawBody = body res.event.Headers = headers return res.event, nil } // Generic simple-body fallback: covers the canonical `{image: ...}` // and `{ref: ..., repository: {...}}` payloads documented in // docs/webhooks.md. var probe struct { Image string `json:"image"` } if err := json.Unmarshal(body, &probe); err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload") } if probe.Image != "" { parsed, err := parseImageRef(probe.Image) if err != nil { return plugin.InboundEvent{}, fmt.Errorf("invalid image reference") } evt.Kind = "image-push" evt.Image = &plugin.ImagePushEvent{ Registry: parsed.Registry, Repo: parsed.fullName(), Tag: parsed.Tag, } return evt, nil } gitEvt, err := parseGenericGitPush(body) if err != nil { // "missing ref" here means we got JSON with neither `image` nor // `ref` — surface the operator-facing message documented in // docs/webhooks.md rather than the lower-level parser error. if strings.Contains(err.Error(), "missing ref") { return plugin.InboundEvent{}, fmt.Errorf("payload must include either 'image' or 'ref'") } return plugin.InboundEvent{}, err } gitEvt.RawBody = body gitEvt.Headers = headers return gitEvt, nil } // toPluginWorkload mirrors the api-layer converter but kept local so the // webhook package does not depend on internal/api. Inlining is cheap and // avoids elevating that converter to a shared package. func toPluginWorkload(w store.Workload) plugin.Workload { var faces []plugin.PublicFace if w.PublicFaces != "" { _ = json.Unmarshal([]byte(w.PublicFaces), &faces) } return plugin.Workload{ ID: w.ID, Name: w.Name, GroupID: w.AppID, ParentWorkloadID: w.ParentWorkloadID, SourceKind: w.SourceKind, SourceConfig: json.RawMessage(w.SourceConfig), TriggerKind: w.TriggerKind, TriggerConfig: json.RawMessage(w.TriggerConfig), PublicFaces: faces, NotificationURL: w.NotificationURL, NotificationSecret: w.NotificationSecret, WebhookSecret: w.WebhookSecret, WebhookSigningSecret: w.WebhookSigningSecret, WebhookRequireSignature: w.WebhookRequireSignature, CreatedAt: w.CreatedAt, UpdatedAt: w.UpdatedAt, } }