c8e71a0c34
Three packages (api, reconciler, webhook) each carried a private 30-line toPluginWorkload() copy that had drifted — only the api version logged malformed public_faces JSON; the others swallowed it. Hoist the single implementation to plugin.WorkloadFromStore() (convert.go); store is already a plugin dependency so no new import edge or cycle forms. Likewise the dockerfile and static sources each had a private removeContainerByName() that disagreed (remove-all vs stop-at-first). Docker enforces unique container names, so the two were equivalent for every reachable state; converge on plugin.RemoveContainerByName() (container.go, stop-at-first) with a note on why remove-all was moot. Callers migrated; old copies removed. Adds convert_test.go pinning the field-by-field contract and JSON edge cases.
341 lines
11 KiB
Go
341 lines
11 KiB
Go
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
|
|
}
|