Files
tiny-forge/internal/webhook/handler.go
T
alexei.dolgolyov 739b67856a
Build / build (push) Successful in 10m39s
feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.

Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
  static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
  stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
  workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
  rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
  dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
  internal/stack/manager.go gone (the rest of those packages stay as
  helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
  gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
  regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
  SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
  minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
  staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
  SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
  table (projects, stages, stage_env, volumes, deploys, deploy_logs,
  poll_states, stacks, stack_revisions, stack_deploys, static_sites,
  static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
  Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
  StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
  GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
  so api + store paths share one secret-generation impl (no
  panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
  + static-site label paths; only canonical tinyforge.workload.id
  dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
  path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
  private (no external callers)

Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
  helper + types (Project, Stage, Stack, StaticSite, Deploy,
  Instance, Volume, etc.); kept Workload, Container, App, Settings,
  Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
  api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
  /deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
  listWorkloads + listContainers only; 4-card stat grid
  (workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
  ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
  proxies/+page.svelte, containers/+page.svelte all rewired to the
  workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
  SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
  volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
  instance.*, confirm.* namespaces; en/ru parity preserved (1042
  keys each)

Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):

- Sec H1: dead-end workload webhook URL handlers (would mint URLs
  that 404 the new trigger-only ingress) deleted across backend +
  frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
  store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
  field names, workloadIDRow rationale, webhook_deliveries.target_type
  enum, WebhookDeliveryLog component header

Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
  items are now shipped. Next focus is Priority 3 polish (apps.* i18n
  + codemap entries) and Priority 4 tests.

Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
  /api/webhook/sites/{secret} return 404; CI configs must repoint to
  /api/webhook/triggers/{secret} (the trigger-split boot backfill
  lifted any embedded workload secret onto a Trigger row, so the
  secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
  links replaced with /apps and /triggers.
2026-05-16 06:00:21 +03:00

363 lines
12 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.
type PluginDispatcher interface {
DispatchPlugin(ctx context.Context, w pluginWorkload, intent pluginIntent) 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,
}
}