a4362b842d
Build / build (push) Successful in 11m42s
Security: - rate limit /api/webhook routes per-IP and cap concurrent site syncs - global SSE connection cap (256) with new sse_gate - validate ?tail= and cap JSON log responses at 4 MiB - strip ANSI/CSI/OSC and control bytes from streamed log lines - redact webhook secret from request log middleware - scrub host details from /api/health for non-admin viewers - drop container_id from /api/system/stats/top for non-admins - generate webhook secrets via crypto/rand; require >=32 chars on insert - verify iid path consistency in streamContainerLogs - LimitReader on site webhook body; reject malformed non-empty bodies Concurrency / correctness: - stats collector: Stop() no longer hangs without Start(), semaphore acquired in parent loop so ctx cancellation short-circuits the queue, in-flight tick cancellable via shared base context, zero-ts guard - webhook handler: replace fire-and-forget goroutine with WaitGroup-tracked workers + Drain() wired into graceful shutdown - $derived(() => ...) mis-idiom fixed in ContainerStats / InstanceCard / ProjectCard (returned function instead of value) - SystemResourcesCard: rename `window` and `t` locals to avoid shadowing globalThis.window and the i18n `t` import Quality / performance: - replace O(n^2) insertion sort with sort.Slice in stats top - runMigrations only swallows duplicate-column / already-exists errors - PruneStatsSamplesBefore wrapped in a transaction - collapse N+1 in unusedImageStats / pruneImages to one ListAllInstances pass; surface DB errors instead of silently treating them as inactive - run Docker Info + DiskUsage in parallel via errgroup - container log SSE emits `: ping` heartbeat every 20 s - imageMatches case-insensitive on registry host (RFC behaviour) - log warning on invalid stage tag pattern instead of silent skip - reject malformed non-empty site webhook payloads Frontend / i18n: - shared formatBytes utility replaces three local copies - statsInterval store drives dynamic "no samples / collection disabled" copy across ContainerStats and SystemResourcesCard - top consumers row now shows owner_name (project/stage or site name) - drop seven `as any` casts on the Settings type; add cloudflare_api_token write-only field - move "Service status", "Docker daemon", "Docker unreachable", "Proxy unreachable", "reachable", and "Docker daemon is not reachable." strings into en/ru i18n bundles
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
var payload Payload
|
|
dec := json.NewDecoder(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
|
if err := dec.Decode(&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
|
|
}
|
|
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,
|
|
})
|
|
}
|