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" // 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() secret := chi.URLParam(r, "secret") if secret == "" { http.NotFound(w, r) return } project, err := h.store.GetProjectByWebhookSecret(secret) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } slog.Error("webhook: project lookup failed", "error", err) http.NotFound(w, r) return } // Read body once so we can both verify HMAC and decode JSON. body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes)) if err != nil { respondWebhookError(w, http.StatusBadRequest, "failed to read request body") return } // 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)) if project.WebhookRequireSignature && !verified { slog.Warn("webhook: signature required but invalid/missing", "project", project.Name) respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") return } if attempted && !verified { slog.Warn("webhook: bad signature", "project", project.Name) respondWebhookError(w, http.StatusUnauthorized, "invalid signature") return } var payload Payload if err := json.Unmarshal(body, &payload); err != nil { respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload") return } if payload.Image == "" { respondWebhookError(w, http.StatusBadRequest, "missing image field") return } parsed, err := ParseImageRef(payload.Image) if err != nil { respondWebhookError(w, http.StatusBadRequest, "invalid image reference") return } if parsed.Tag == "" { 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)) 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) respondWebhookError(w, http.StatusInternalServerError, "internal error") return } if !found { slog.Info("webhook: no stage matches tag", "project", project.Name, "tag", 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) 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) respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed") return } slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", 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() if h.sites == nil { http.NotFound(w, r) return } secret := chi.URLParam(r, "secret") if secret == "" { http.NotFound(w, r) return } site, err := h.store.GetStaticSiteByWebhookSecret(secret) if err != nil { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } slog.Error("webhook: site lookup failed", "error", err) http.NotFound(w, r) return } // 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) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "sync": false, "site": site.Name, "reason": "sync_trigger is manual", }) 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 { respondWebhookError(w, http.StatusBadRequest, "failed to read request body") return } // 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)) if site.WebhookRequireSignature && !verified { slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name) respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature") return } if attempted && !verified { slog.Warn("webhook: site bad signature", "site", site.Name) respondWebhookError(w, http.StatusUnauthorized, "invalid signature") return } if len(body) > 0 { if err := json.Unmarshal(body, &payload); err != nil { 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) 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 } // Cap concurrent syncs so a runaway CI cannot fan out unbounded // git-clone goroutines. select { case h.siteSyncSem <- struct{}{}: default: 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) respondWebhookJSON(w, http.StatusOK, map[string]any{ "success": true, "sync": true, "site": site.Name, }) }