feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s

Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.

Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.

Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.

Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).

API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.

UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.

Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.

Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
2026-05-07 02:03:32 +03:00
parent 134fe22fde
commit 0405ecd9ce
27 changed files with 2190 additions and 84 deletions
+403
View File
@@ -0,0 +1,403 @@
package api
// Outgoing-webhook signing-secret + send-test endpoints. There are four
// tiers — settings, project, stage, site — each exposing the same three
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
// event. Returning a 200 from "send test" doesn't mean the receiver
// processed the event correctly — only that it answered with 2xx. The UI
// surfaces the receiver's status code + body preview so operators can
// distinguish "wired" from "wired and accepted".
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// notificationSecretResponse is what the GET / regenerate endpoints return.
// The secret is revealed in cleartext exactly once per request — UI is
// expected to copy or hash it for display, not store it long-term.
type notificationSecretResponse struct {
Secret string `json:"secret"`
HasSecret bool `json:"has_secret"`
}
// testEventTimeout caps how long we wait for the receiver before declaring
// the test failed. Mirrors the production notifier's per-request timeout
// (10s) so test results are predictive of real send behaviour.
const testEventTimeout = 10 * time.Second
// buildTestEvent constructs the synthetic payload used by every "send
// test" endpoint. Marking it as type "test" prevents a misconfigured
// receiver from mistaking a wiring check for a real deploy event.
func buildTestEvent(project, stage string) notify.Event {
return notify.Event{
Type: "test",
Project: project,
Stage: stage,
}
}
// ---------------------------------------------------------------------------
// Global / settings tier
// ---------------------------------------------------------------------------
// getSettingsNotificationSecret handles GET /api/settings/notification-secret.
// Lazily generates a secret if one was never set (typical for sites
// upgrading from a pre-signing build).
func (s *Server) getSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
secret, err := s.store.EnsureSettingsNotificationSecret()
if err != nil {
slog.Error("get settings notification secret", "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
// regenerateSettingsNotificationSecret handles POST
// /api/settings/notification-secret/regenerate. Replaces the existing
// secret with a fresh one, invalidating signatures verified against the
// old secret.
func (s *Server) regenerateSettingsNotificationSecret(w http.ResponseWriter, r *http.Request) {
secret := generateWebhookSecret()
if err := s.store.SetSettingsNotificationSecret(secret); err != nil {
slog.Error("regenerate settings notification secret", "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("settings notification secret rotated")
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
// disableSettingsNotificationSigning handles POST
// /api/settings/notification-secret/disable. Clears the secret so further
// outgoing notifications are unsigned. Useful for receivers that don't
// support HMAC verification.
func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *http.Request) {
if err := s.store.SetSettingsNotificationSecret(""); err != nil {
slog.Error("disable settings notification signing", "error", err)
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
// settingsNotificationTest handles POST /api/settings/notification-test.
// Sends a synthetic test event to the global webhook URL using the global
// secret. No tier resolution — that's the whole point: each tier's test
// button proves *that* tier is wired correctly.
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
if settings.NotificationURL == "" {
respondError(w, http.StatusBadRequest, "no global notification URL configured")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, settings.NotificationURL, settings.NotificationSecret, notify.TierSettings,
buildTestEvent("__tinyforge__", ""),
)
respondJSON(w, http.StatusOK, result)
}
// ---------------------------------------------------------------------------
// Project tier
// ---------------------------------------------------------------------------
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureProjectNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
slog.Error("get project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetProjectByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
secret := generateWebhookSecret()
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
slog.Error("regenerate project notification secret", "project", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("project notification secret rotated", "project", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := s.store.GetProjectByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveProjectTestTarget(project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
// resolution but without a stage in scope. Used by the project-level test
// button so the operator sees exactly what a project-only event would do.
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Stage tier
// ---------------------------------------------------------------------------
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
secret, err := s.store.EnsureStageNotificationSecret(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
slog.Error("get stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if _, err := s.store.GetStageByID(stageID); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("stage notification secret rotated", "stage", stageID)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
stageID := chi.URLParam(r, "stage")
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
stageID := chi.URLParam(r, "stage")
stage, err := s.store.GetStageByID(stageID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "stage")
return
}
respondError(w, http.StatusInternalServerError, "failed to load stage")
return
}
project, err := s.store.GetProjectByID(projectID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project")
return
}
respondError(w, http.StatusInternalServerError, "failed to load project")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
// Reuse the production resolver so the test button exercises the exact
// fall-through logic a real deploy would.
url, secret, tier := resolveDeployTarget(stage, project, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(project.Name, stage.Name),
)
respondJSON(w, http.StatusOK, result)
}
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
// than imported to avoid an api → deployer dependency cycle and to keep the
// test-endpoint code self-contained. If divergence becomes a risk we can
// move this into a shared internal/notify subpackage.
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
if stage.NotificationURL != "" {
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
}
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// ---------------------------------------------------------------------------
// Static-site tier
// ---------------------------------------------------------------------------
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
slog.Error("get static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to load secret")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
}
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if _, err := s.store.GetStaticSiteByID(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
secret := generateWebhookSecret()
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
slog.Error("regenerate static site notification secret", "site", id, "error", err)
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
return
}
slog.Info("static site notification secret rotated", "site", id)
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
}
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to disable signing")
return
}
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
}
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
site, err := s.store.GetStaticSiteByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "static site")
return
}
respondError(w, http.StatusInternalServerError, "failed to load static site")
return
}
settings, err := s.store.GetSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load settings")
return
}
url, secret, tier := resolveSiteTestTarget(site, settings)
if url == "" {
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
return
}
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
defer cancel()
result := s.notifier.SendSyncForTest(
ctx, url, secret, tier,
buildTestEvent(site.Name, ""),
)
respondJSON(w, http.StatusOK, result)
}
func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
if site.NotificationURL != "" {
return site.NotificationURL, site.NotificationSecret, notify.TierSite
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
+12 -8
View File
@@ -13,14 +13,15 @@ import (
// projectRequest is the expected JSON body for creating/updating a project.
type projectRequest struct {
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"`
Volumes string `json:"volumes"`
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
Name string `json:"name"`
Registry string `json:"registry"`
Image string `json:"image"`
Port int `json:"port"`
Healthcheck string `json:"healthcheck"`
Env string `json:"env"`
Volumes string `json:"volumes"`
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
NotificationURL *string `json:"notification_url,omitempty"`
}
// listProjects handles GET /api/projects.
@@ -157,6 +158,9 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
if req.NpmAccessListID != nil {
updated.NpmAccessListID = *req.NpmAccessListID
}
if req.NotificationURL != nil {
updated.NotificationURL = *req.NotificationURL
}
if err := s.store.UpdateProject(updated); err != nil {
slog.Error("failed to update project", "error", err)
+28
View File
@@ -13,6 +13,7 @@ import (
"github.com/alexei/tinyforge/internal/dns"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/npm"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/stack"
@@ -33,6 +34,7 @@ type Server struct {
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
proxyProvider proxy.Provider
deployer DeployTriggerer
notifier *notify.Notifier
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
@@ -61,6 +63,7 @@ func NewServer(
npmClient *npm.Client,
proxyProvider proxy.Provider,
deployer DeployTriggerer,
notifier *notify.Notifier,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
encKey [32]byte,
@@ -73,6 +76,7 @@ func NewServer(
npm: npmClient,
proxyProvider: proxyProvider,
deployer: deployer,
notifier: notifier,
webhook: webhookHandler,
eventBus: eventBus,
encKey: encKey,
@@ -242,11 +246,23 @@ func (s *Server) Router() chi.Router {
r.Get("/webhook", s.getProjectWebhook)
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
// Per-project outgoing-webhook signing & test.
r.Get("/notification-secret", s.getProjectNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
r.Post("/notification-test", s.projectNotificationTest)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Per-stage outgoing-webhook signing & test.
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
// Stage env override endpoints.
r.Post("/stages/{stage}/env", s.createStageEnv)
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
@@ -309,6 +325,12 @@ func (s *Server) Router() chi.Router {
r.Post("/start", s.startStaticSite)
r.Get("/webhook", s.getStaticSiteWebhook)
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
// Per-site outgoing-webhook signing & test.
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
r.Post("/notification-test", s.staticSiteNotificationTest)
r.Post("/secrets", s.createStaticSiteSecret)
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
@@ -394,6 +416,12 @@ func (s *Server) Router() chi.Router {
// Settings endpoints.
r.Put("/settings", s.updateSettings)
// Global outgoing-webhook signing & test.
r.Get("/settings/notification-secret", s.getSettingsNotificationSecret)
r.Post("/settings/notification-secret/regenerate", s.regenerateSettingsNotificationSecret)
r.Post("/settings/notification-secret/disable", s.disableSettingsNotificationSigning)
r.Post("/settings/notification-test", s.settingsNotificationTest)
// Docker management.
r.Post("/docker/prune-images", s.pruneImages)
+1
View File
@@ -66,6 +66,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"network": settings.Network,
"subdomain_pattern": settings.SubdomainPattern,
"notification_url": settings.NotificationURL,
"has_notification_secret": settings.NotificationSecret != "",
"npm_url": settings.NpmURL,
"npm_email": settings.NpmEmail,
"has_npm_password": settings.NpmPassword != "",
+22 -15
View File
@@ -48,21 +48,22 @@ func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
// ── Create ──────────────────────────────────────────────────────────
type createStaticSiteRequest struct {
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
Name string `json:"name"`
Provider string `json:"provider"`
GiteaURL string `json:"gitea_url"`
RepoOwner string `json:"repo_owner"`
RepoName string `json:"repo_name"`
Branch string `json:"branch"`
FolderPath string `json:"folder_path"`
AccessToken string `json:"access_token"`
Domain string `json:"domain"`
Mode string `json:"mode"`
RenderMarkdown bool `json:"render_markdown"`
SyncTrigger string `json:"sync_trigger"`
TagPattern string `json:"tag_pattern"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"`
NotificationURL *string `json:"notification_url,omitempty"`
}
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
@@ -115,6 +116,9 @@ func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
StorageLimitMB: req.StorageLimitMB,
Status: "idle",
}
if req.NotificationURL != nil {
site.NotificationURL = *req.NotificationURL
}
created, err := s.store.CreateStaticSite(site)
if err != nil {
@@ -180,6 +184,9 @@ func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
existing.TagPattern = req.TagPattern
existing.StorageEnabled = req.StorageEnabled
existing.StorageLimitMB = req.StorageLimitMB
if req.NotificationURL != nil {
existing.NotificationURL = *req.NotificationURL
}
// Update access token only if a new one is provided.
if req.AccessToken != "" {
+19 -2
View File
@@ -181,7 +181,8 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error())
d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID)
d.notifier.Send(settings.NotificationURL, notify.Event{
url, secret, tier := resolveDeployTarget(stage, project, settings)
d.notifier.SendSigned(url, secret, tier, notify.Event{
Type: "deploy_failure",
Project: project.Name,
Stage: stage.Name,
@@ -202,7 +203,8 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
d.notifier.Send(settings.NotificationURL, notify.Event{
url, secret, tier := resolveDeployTarget(stage, project, settings)
d.notifier.SendSigned(url, secret, tier, notify.Event{
Type: "deploy_success",
Project: project.Name,
Stage: stage.Name,
@@ -214,6 +216,21 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
return nil
}
// resolveDeployTarget picks the most-specific (URL, secret, tier) for a
// deploy notification: stage > project > global. An empty URL at a tier
// means "fall through to the next" — never "send unsigned to nowhere". The
// secret is always paired with the URL that sourced it, so a stage can sign
// even when project and global are unsigned (and vice versa).
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
if stage.NotificationURL != "" {
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
}
if project.NotificationURL != "" {
return project.NotificationURL, project.NotificationSecret, notify.TierProject
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
// It validates inputs, creates a deploy record, and delegates to runDeploy.
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
+89
View File
@@ -0,0 +1,89 @@
package deployer
import (
"testing"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// TestResolveDeployTarget locks the stage→project→global precedence. The
// most-specific tier with a non-empty URL wins, and the secret travels
// with the URL that sourced it (so a stage can sign even when project and
// global are unsigned). A regression here misroutes notifications and
// silently leaks events to the wrong receiver — worth catching.
func TestResolveDeployTarget(t *testing.T) {
cases := []struct {
name string
stage store.Stage
project store.Project
settings store.Settings
wantURL string
wantSec string
wantTier notify.Tier
}{
{
name: "stage wins when set",
stage: store.Stage{NotificationURL: "https://stage.example/wh", NotificationSecret: "stage-key"},
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://stage.example/wh",
wantSec: "stage-key",
wantTier: notify.TierStage,
},
{
name: "stage URL empty → project wins",
stage: store.Stage{NotificationURL: "", NotificationSecret: "stage-key"}, // secret without URL ignored
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://project.example/wh",
wantSec: "project-key",
wantTier: notify.TierProject,
},
{
name: "stage and project empty → global wins",
stage: store.Stage{},
project: store.Project{},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://global.example/wh",
wantSec: "global-key",
wantTier: notify.TierSettings,
},
{
name: "all empty → returns settings tier with empty URL (caller skips)",
stage: store.Stage{},
project: store.Project{},
settings: store.Settings{},
wantURL: "",
wantSec: "",
wantTier: notify.TierSettings,
},
{
name: "stage signs even when global is unsigned",
stage: store.Stage{
NotificationURL: "https://stage.example/wh",
NotificationSecret: "stage-only-key",
},
project: store.Project{},
settings: store.Settings{NotificationURL: "https://global.example/wh"},
wantURL: "https://stage.example/wh",
wantSec: "stage-only-key",
wantTier: notify.TierStage,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotSec, gotTier := resolveDeployTarget(tc.stage, tc.project, tc.settings)
if gotURL != tc.wantURL {
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
}
if gotSec != tc.wantSec {
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
}
if gotTier != tc.wantTier {
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
}
})
}
}
+199 -17
View File
@@ -3,17 +3,28 @@ package notify
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sync"
"time"
"github.com/google/uuid"
)
// Event represents a deployment notification payload.
// Event represents a deployment / site-sync notification payload.
//
// Field naming preserves backwards compatibility with the original
// deploy_success/deploy_failure events; site events reuse Project for the
// site name and leave Stage/ImageTag empty.
type Event struct {
Type string `json:"type"` // "deploy_success" or "deploy_failure"
Type string `json:"type"` // deploy_success, deploy_failure, site_sync_success, site_sync_failure, test
Project string `json:"project"`
Stage string `json:"stage"`
ImageTag string `json:"image_tag"`
@@ -23,8 +34,54 @@ type Event struct {
Timestamp string `json:"timestamp"`
}
// Notifier sends webhook notifications for deploy events.
// Notifications are fire-and-forget — failures are logged but do not propagate.
// Tier identifies which configuration layer supplied the URL+secret used for
// a particular dispatch. Recorded in logs and the test-endpoint response so
// operators can debug fall-through behaviour.
type Tier string
const (
TierSettings Tier = "settings"
TierProject Tier = "project"
TierStage Tier = "stage"
TierSite Tier = "site"
)
// Header names for outgoing webhooks. The signature header name matches
// GitHub/Gitea/Forgejo so receivers built for those providers (and the
// service-to-notification-bridge generic webhook provider) verify out of the
// box. The X-Tinyforge-* headers are advisory and not covered by the HMAC.
const (
HeaderSignature = "X-Hub-Signature-256"
HeaderEvent = "X-Tinyforge-Event"
HeaderDelivery = "X-Tinyforge-Delivery"
HeaderTimestamp = "X-Tinyforge-Timestamp"
HeaderTier = "X-Tinyforge-Tier"
)
// userAgent is reported on every outgoing webhook request so operators can
// filter their access logs by source. Versioned tag is added later if/when
// we wire build-time variables; for now a static identifier is enough.
const userAgent = "Tinyforge-Webhook/1"
// TestResult is what /api/.../notification-test returns to the UI: the
// receiver's status code, latency, a short response preview, and whether a
// signature was sent (so the operator can tell at a glance if signing is
// configured for this tier).
type TestResult struct {
URL string `json:"url"`
Tier Tier `json:"tier"`
StatusCode int `json:"status_code"`
LatencyMs int64 `json:"latency_ms"`
SignatureSent bool `json:"signature_sent"`
DeliveryID string `json:"delivery_id"`
ResponseSnippet string `json:"response_snippet"`
Error string `json:"error,omitempty"`
}
// Notifier sends webhook notifications for deploy and site-sync events.
// Notifications are fire-and-forget by default — failures are logged but do
// not propagate. SendSyncForTest is the exception, used only by the manual
// test endpoint.
type Notifier struct {
httpClient *http.Client
wg sync.WaitGroup
@@ -44,9 +101,20 @@ func (n *Notifier) Drain() {
n.wg.Wait()
}
// Send sends a notification event to the given webhook URL in a background goroutine.
// It does not block the caller. Errors are logged, not returned.
// Send dispatches an unsigned event to the given URL in the background.
// Retained for callsites that don't yet have access to a signing secret;
// new code should prefer SendSigned which records the resolution tier.
func (n *Notifier) Send(webhookURL string, event Event) {
n.SendSigned(webhookURL, "", TierSettings, event)
}
// SendSigned dispatches an event, signing it with HMAC-SHA256 if secret is
// non-empty. The signature is computed over the exact JSON bytes sent on the
// wire (so receivers must verify the raw body, not a re-serialised copy).
//
// Empty secret => unsigned send (no X-Hub-Signature-256 header), preserving
// the legacy behaviour for receivers that pre-date HMAC support.
func (n *Notifier) SendSigned(webhookURL, secret string, tier Tier, event Event) {
if webhookURL == "" {
return
}
@@ -54,40 +122,154 @@ func (n *Notifier) Send(webhookURL string, event Event) {
if event.Timestamp == "" {
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
delivery := uuid.NewString()
n.wg.Add(1)
go func() {
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := n.doSend(ctx, webhookURL, event); err != nil {
slog.Warn("notify: failed to send webhook", "url", webhookURL, "error", err)
_, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
// URL host only — never log the secret or full URL with user-info.
host := safeHost(webhookURL)
if err != nil {
slog.Warn("notify: webhook send failed",
"tier", tier, "host", host, "delivery", delivery,
"event", event.Type, "signed", secret != "", "error", err)
return
}
slog.Info("notify: webhook dispatched",
"tier", tier, "host", host, "delivery", delivery,
"event", event.Type, "signed", secret != "")
}()
}
// doSend performs the actual HTTP POST to the webhook URL.
func (n *Notifier) doSend(ctx context.Context, webhookURL string, event Event) error {
// SendSyncForTest performs a synchronous, single-shot send for the "Send
// test" UI button. Returns a TestResult describing what the receiver
// answered with so the operator can confirm wiring without watching server
// logs. Errors are reported via the Error field rather than the returned
// error to keep the API ergonomic for the handler.
func (n *Notifier) SendSyncForTest(ctx context.Context, webhookURL, secret string, tier Tier, event Event) TestResult {
if event.Timestamp == "" {
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
delivery := uuid.NewString()
result := TestResult{
URL: webhookURL,
Tier: tier,
SignatureSent: secret != "",
DeliveryID: delivery,
}
if webhookURL == "" {
result.Error = "no webhook URL configured for this tier"
return result
}
start := time.Now()
resp, err := n.doSend(ctx, webhookURL, secret, tier, delivery, event)
result.LatencyMs = time.Since(start).Milliseconds()
if err != nil {
result.Error = err.Error()
if resp != nil {
result.StatusCode = resp.StatusCode
result.ResponseSnippet = resp.BodyPreview
}
return result
}
result.StatusCode = resp.StatusCode
result.ResponseSnippet = resp.BodyPreview
return result
}
// sendResponse captures the small subset of the receiver's response we want
// to surface back to the operator (status + a body preview). Distinct from
// http.Response so callers don't accidentally hold an unread body.
type sendResponse struct {
StatusCode int
BodyPreview string
}
// doSend performs the HTTP POST, signs the body if a secret is configured,
// and returns either a sendResponse (for the test path) or an error.
//
// The request body bytes are computed once so the HMAC signature matches
// exactly what travels on the wire. Receivers MUST verify against the raw
// body, not a re-serialised copy.
func (n *Notifier) doSend(ctx context.Context, webhookURL, secret string, tier Tier, delivery string, event Event) (*sendResponse, error) {
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal notification: %w", err)
return nil, fmt.Errorf("marshal notification: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create notification request: %w", err)
return nil, fmt.Errorf("create notification request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)
req.Header.Set(HeaderEvent, event.Type)
req.Header.Set(HeaderDelivery, delivery)
req.Header.Set(HeaderTimestamp, event.Timestamp)
req.Header.Set(HeaderTier, string(tier))
if secret != "" {
req.Header.Set(HeaderSignature, "sha256="+sign(secret, body))
}
resp, err := n.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send notification: %w", err)
return nil, fmt.Errorf("send notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("notification webhook returned status %d", resp.StatusCode)
preview, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
out := &sendResponse{
StatusCode: resp.StatusCode,
BodyPreview: string(preview),
}
return nil
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return out, fmt.Errorf("notification webhook returned status %d", resp.StatusCode)
}
return out, nil
}
// sign returns the lowercase-hex HMAC-SHA256 of body using secret as the
// key. The "sha256=" prefix is added by the caller to match GitHub's
// X-Hub-Signature-256 wire format.
func sign(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifySignature is the receiver-side counterpart to sign(). Exported so
// our own tests (and any future incoming-webhook receiver in this repo) can
// re-use the exact construction without duplicating the HMAC code.
//
// signatureHeader accepts either the raw hex digest or the GitHub-style
// "sha256=<hex>" envelope.
func VerifySignature(secret string, body []byte, signatureHeader string) bool {
if secret == "" || signatureHeader == "" {
return false
}
got := signatureHeader
if len(got) > 7 && got[:7] == "sha256=" {
got = got[7:]
}
want := sign(secret, body)
// hmac.Equal is the constant-time comparator; bytes.Equal would leak
// timing information about the first differing byte.
return hmac.Equal([]byte(got), []byte(want))
}
// safeHost extracts the host (and optional port) from a webhook URL for
// logging. Returns the input unchanged if parsing fails so we never silently
// swallow a malformed URL — operators see the failure mode either way.
func safeHost(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return "(unparseable)"
}
return u.Host
}
+234
View File
@@ -0,0 +1,234 @@
package notify_test
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/alexei/tinyforge/internal/notify"
)
// TestSignedRoundTrip is the canonical "the receiver can verify what we
// sent" check. Sender signs the body with a secret; the test server reads
// the raw body and the X-Hub-Signature-256 header and verifies via
// VerifySignature. A regression here means receivers built against our
// docs would silently reject real notifications.
func TestSignedRoundTrip(t *testing.T) {
const secret = "super-secret-test-key-not-used-in-prod"
var receivedBody []byte
var receivedSig string
var receivedEvent string
var receivedDelivery string
var receivedTier string
var receivedTimestamp string
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
body, _ := io.ReadAll(r.Body)
receivedBody = body
receivedSig = r.Header.Get(notify.HeaderSignature)
receivedEvent = r.Header.Get(notify.HeaderEvent)
receivedDelivery = r.Header.Get(notify.HeaderDelivery)
receivedTier = r.Header.Get(notify.HeaderTier)
receivedTimestamp = r.Header.Get(notify.HeaderTimestamp)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
n := notify.New()
n.SendSigned(srv.URL, secret, notify.TierStage, notify.Event{
Type: "deploy_success",
Project: "demo",
Stage: "prod",
})
n.Drain()
<-done
if !notify.VerifySignature(secret, receivedBody, receivedSig) {
t.Fatalf("receiver could not verify signature: header=%q body=%q", receivedSig, receivedBody)
}
if receivedEvent != "deploy_success" {
t.Errorf("event header = %q, want deploy_success", receivedEvent)
}
if receivedDelivery == "" {
t.Errorf("delivery ID header missing")
}
if receivedTier != string(notify.TierStage) {
t.Errorf("tier header = %q, want %q", receivedTier, notify.TierStage)
}
if receivedTimestamp == "" {
t.Errorf("timestamp header missing")
}
// Sanity: payload roundtrips through JSON unchanged.
var got notify.Event
if err := json.Unmarshal(receivedBody, &got); err != nil {
t.Fatalf("decode body: %v", err)
}
if got.Project != "demo" || got.Stage != "prod" {
t.Errorf("body fields lost in transit: %+v", got)
}
}
// TestUnsignedSendOmitsSignatureHeader covers the back-compat path: a
// caller that hasn't yet generated a signing secret should still be able to
// dispatch events, just without the signature header. Existing receivers
// must not break when a Tinyforge instance upgrades but hasn't enabled
// signing yet.
func TestUnsignedSendOmitsSignatureHeader(t *testing.T) {
var sigHeader string
done := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(done)
sigHeader = r.Header.Get(notify.HeaderSignature)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
n := notify.New()
n.SendSigned(srv.URL, "", notify.TierSettings, notify.Event{Type: "test"})
n.Drain()
<-done
if sigHeader != "" {
t.Errorf("expected no signature header on unsigned send, got %q", sigHeader)
}
}
// TestVerifyRejectsTamperedBody is the negative half of the round-trip:
// flipping a single byte in the signed body must fail verification.
// Catches accidental MAC truncation / wrong hash family / non-constant-time
// compares (the last only weakly, but the round-trip already guards
// correctness; this just locks the contract).
func TestVerifyRejectsTamperedBody(t *testing.T) {
const secret = "abc"
body := []byte(`{"type":"deploy_success"}`)
sig := "sha256=" + hexEncode(hmacSha256(secret, body))
if !notify.VerifySignature(secret, body, sig) {
t.Fatalf("control: legit signature failed to verify")
}
tampered := append([]byte(nil), body...)
tampered[1] = 'X' // flip one byte
if notify.VerifySignature(secret, tampered, sig) {
t.Errorf("verifier accepted tampered body — signature scheme is broken")
}
if notify.VerifySignature("wrong-secret", body, sig) {
t.Errorf("verifier accepted wrong secret")
}
if notify.VerifySignature(secret, body, "") {
t.Errorf("verifier accepted empty signature header")
}
if notify.VerifySignature("", body, sig) {
t.Errorf("verifier accepted empty secret")
}
}
// TestSendSyncForTestReturnsReceiverStatus is the "send test" UI button
// contract: when the receiver returns a non-2xx status, we must surface
// both the status code and the body preview rather than swallowing them.
// Operators rely on this to debug mis-pointed receivers.
func TestSendSyncForTestReturnsReceiverStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("invalid signature"))
}))
defer srv.Close()
n := notify.New()
res := n.SendSyncForTest(context.Background(), srv.URL, "secret", notify.TierProject, notify.Event{Type: "test"})
if res.StatusCode != http.StatusForbidden {
t.Errorf("status_code = %d, want 403", res.StatusCode)
}
if res.ResponseSnippet != "invalid signature" {
t.Errorf("response_snippet = %q, want 'invalid signature'", res.ResponseSnippet)
}
if !res.SignatureSent {
t.Errorf("signature_sent should be true when secret is provided")
}
if res.Tier != notify.TierProject {
t.Errorf("tier = %q, want project", res.Tier)
}
if res.Error == "" {
t.Errorf("Error field should be set on 4xx response")
}
}
// TestSendSyncForTestEmptyURL is the guard for the test endpoint when no
// URL is configured at any tier. The handler relies on Error being non-empty
// to render the "no URL configured" message, so this contract must hold.
func TestSendSyncForTestEmptyURL(t *testing.T) {
n := notify.New()
res := n.SendSyncForTest(context.Background(), "", "secret", notify.TierSettings, notify.Event{Type: "test"})
if res.Error == "" {
t.Errorf("Error field should be set when URL is empty")
}
if res.StatusCode != 0 {
t.Errorf("StatusCode should remain 0 when no request was made, got %d", res.StatusCode)
}
}
// TestConcurrentSendsAllArrive guards the WaitGroup contract on Drain — a
// regression where Drain returns before in-flight goroutines complete would
// drop notifications during graceful shutdown.
func TestConcurrentSendsAllArrive(t *testing.T) {
const fanout = 20
var (
mu sync.Mutex
received int
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
received++
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
n := notify.New()
for i := 0; i < fanout; i++ {
n.SendSigned(srv.URL, "key", notify.TierSettings, notify.Event{Type: "test"})
}
n.Drain()
mu.Lock()
defer mu.Unlock()
if received != fanout {
t.Errorf("received %d sends, want %d", received, fanout)
}
}
// --- helpers ----------------------------------------------------------
// hmacSha256 + hexEncode duplicate the production sign() body so the
// negative tests don't depend on the un-exported helper. If the exported
// VerifySignature contract changes, this is the canary.
func hmacSha256(secret string, body []byte) []byte {
h := hmac.New(sha256.New, []byte(secret))
h.Write(body)
return h.Sum(nil)
}
func hexEncode(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, x := range b {
out[i*2] = hexdigits[x>>4]
out[i*2+1] = hexdigits[x&0x0f]
}
return string(out)
}
+60 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/alexei/tinyforge/internal/crypto"
"github.com/alexei/tinyforge/internal/docker"
"github.com/alexei/tinyforge/internal/events"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/proxy"
"github.com/alexei/tinyforge/internal/staticsite/deno"
"github.com/alexei/tinyforge/internal/store"
@@ -26,6 +27,7 @@ type Manager struct {
docker *docker.Client
proxyProvider proxy.Provider
eventBus *events.Bus
notifier *notify.Notifier
encKey [32]byte
}
@@ -35,6 +37,7 @@ func NewManager(
dockerClient *docker.Client,
proxyProvider proxy.Provider,
eventBus *events.Bus,
notifier *notify.Notifier,
encKey [32]byte,
) *Manager {
return &Manager{
@@ -42,6 +45,7 @@ func NewManager(
docker: dockerClient,
proxyProvider: proxyProvider,
eventBus: eventBus,
notifier: notifier,
encKey: encKey,
}
}
@@ -623,7 +627,9 @@ func (m *Manager) removeContainerByName(ctx context.Context, name string) {
}
// updateStatus updates the site status in the database.
// On failure, it also publishes an event to the event log.
// On failure, it also publishes an event to the event log. On terminal
// state transitions (deployed / failed), it dispatches an outgoing
// notification using the per-site URL+secret with fall-through to global.
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
@@ -638,6 +644,59 @@ func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
}
m.publishEvent(id, siteName, "failed: "+errMsg)
}
if status == "deployed" || status == "failed" {
m.dispatchSiteNotification(id, status, errMsg)
}
}
// dispatchSiteNotification emits a site_sync_success or site_sync_failure
// event to the configured outgoing webhook. Resolution: per-site URL+secret
// first, falling through to the global settings.notification_url/secret.
// Always best-effort — failures are logged but never block status updates.
func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) {
if m.notifier == nil {
return
}
site, err := m.store.GetStaticSiteByID(siteID)
if err != nil {
slog.Warn("static site: notify lookup failed", "site", siteID, "error", err)
return
}
settings, err := m.store.GetSettings()
if err != nil {
slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err)
return
}
url, secret, tier := resolveSiteTarget(site, settings)
if url == "" {
return
}
eventType := "site_sync_success"
if status == "failed" {
eventType = "site_sync_failure"
}
siteURL := ""
if site.Domain != "" {
siteURL = "https://" + site.Domain
}
m.notifier.SendSigned(url, secret, tier, notify.Event{
Type: eventType,
Project: site.Name,
URL: siteURL,
Error: errMsg,
})
}
// resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site
// URL beats global, secret travels with the URL that sourced it.
func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
if site.NotificationURL != "" {
return site.NotificationURL, site.NotificationSecret, notify.TierSite
}
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
}
// publishEvent publishes a static site status event on the event bus
+63
View File
@@ -0,0 +1,63 @@
package staticsite
import (
"testing"
"github.com/alexei/tinyforge/internal/notify"
"github.com/alexei/tinyforge/internal/store"
)
// TestResolveSiteTarget locks the per-site → global precedence for static
// site sync notifications. Distinct from the deploy resolver because there
// is no project tier between site and settings; a regression that swapped
// the order would silently route per-site events to the global receiver.
func TestResolveSiteTarget(t *testing.T) {
cases := []struct {
name string
site store.StaticSite
settings store.Settings
wantURL string
wantSec string
wantTier notify.Tier
}{
{
name: "site wins when URL set",
site: store.StaticSite{NotificationURL: "https://site.example/wh", NotificationSecret: "site-key"},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://site.example/wh",
wantSec: "site-key",
wantTier: notify.TierSite,
},
{
name: "site URL empty → global wins",
site: store.StaticSite{},
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
wantURL: "https://global.example/wh",
wantSec: "global-key",
wantTier: notify.TierSettings,
},
{
name: "both empty → empty URL with settings tier",
site: store.StaticSite{},
settings: store.Settings{},
wantURL: "",
wantSec: "",
wantTier: notify.TierSettings,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotSec, gotTier := resolveSiteTarget(tc.site, tc.settings)
if gotURL != tc.wantURL {
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
}
if gotSec != tc.wantSec {
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
}
if gotTier != tc.wantTier {
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
}
})
}
}
+12 -6
View File
@@ -10,8 +10,10 @@ type Project struct {
Healthcheck string `json:"healthcheck"`
Env string `json:"env"` // JSON-encoded map
Volumes string `json:"volumes"` // JSON-encoded map
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -27,8 +29,9 @@ type Stage struct {
Confirm bool `json:"confirm"`
EnableProxy bool `json:"enable_proxy"`
PromoteFrom string `json:"promote_from"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
Subdomain string `json:"subdomain"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
CreatedAt string `json:"created_at"`
@@ -54,7 +57,8 @@ type Settings struct {
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
@@ -250,7 +254,9 @@ type StaticSite struct {
Error string `json:"error"`
StorageEnabled bool `json:"storage_enabled"`
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+47 -10
View File
@@ -31,7 +31,7 @@ func generateWebhookSecret() string {
// projectCols is the canonical column list for projects queries.
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
npm_access_list_id, webhook_secret, created_at, updated_at`
npm_access_list_id, webhook_secret, notification_url, notification_secret, created_at, updated_at`
// CreateProject inserts a new project and returns it. A webhook secret is
// generated automatically if one is not already set on the input.
@@ -47,9 +47,9 @@ func (s *Store) CreateProject(p Project) (Project, error) {
_, err := s.db.Exec(
`INSERT INTO projects (`+projectCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.WebhookSecret, p.CreatedAt, p.UpdatedAt,
p.NpmAccessListID, p.WebhookSecret, p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return Project{}, fmt.Errorf("insert project: %w", err)
@@ -63,7 +63,7 @@ func (s *Store) GetProjectByID(id string) (Project, error) {
err := s.db.QueryRow(
`SELECT `+projectCols+` FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt)
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
}
@@ -83,7 +83,7 @@ func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
err := s.db.QueryRow(
`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret,
).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt)
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Project{}, ErrNotFound
}
@@ -107,7 +107,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
@@ -129,7 +129,7 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
&p.NpmAccessListID, &p.WebhookSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
@@ -138,15 +138,16 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
}
// UpdateProject updates an existing project's mutable fields. Webhook secret
// is intentionally not updated here — use SetProjectWebhookSecret instead.
// and notification_secret are intentionally not updated here — use the
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?,
npm_access_list_id=?, updated_at=?
npm_access_list_id=?, notification_url=?, updated_at=?
WHERE id=?`,
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
p.NpmAccessListID, p.UpdatedAt, p.ID,
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
)
if err != nil {
return fmt.Errorf("update project: %w", err)
@@ -193,6 +194,42 @@ func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
return secret, nil
}
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this project (notifications
// still send unsigned, falling through to the parent tier's secret if any).
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set project notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("project %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
// secret, generating one lazily if missing. Used when an operator first opens
// the outgoing-webhook panel for a project that predates this feature.
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
project, err := s.GetProjectByID(id)
if err != nil {
return "", err
}
if project.NotificationSecret != "" {
return project.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetProjectNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
func (s *Store) DeleteProject(id string) error {
result, err := s.db.Exec(`DELETE FROM projects WHERE id = ?`, id)
+18
View File
@@ -10,6 +10,7 @@ func (s *Store) GetSettings() (Settings, error) {
var wildcardDNS, npmRemote, backupEnabled int
err := s.db.QueryRow(
`SELECT domain, server_ip, public_ip, network, subdomain_pattern, notification_url,
notification_secret,
npm_url, npm_email, npm_password, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, wildcard_dns, dns_provider,
@@ -22,6 +23,7 @@ func (s *Store) GetSettings() (Settings, error) {
updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.PublicIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NotificationSecret,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.PollingInterval,
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
@@ -59,6 +61,7 @@ func (s *Store) UpdateSettings(st Settings) error {
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, public_ip=?, network=?, subdomain_pattern=?, notification_url=?,
notification_secret=?,
npm_url=?, npm_email=?, npm_password=?, polling_interval=?,
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
@@ -71,6 +74,7 @@ func (s *Store) UpdateSettings(st Settings) error {
updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.PublicIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NotificationSecret,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.PollingInterval,
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
@@ -87,3 +91,17 @@ func (s *Store) UpdateSettings(st Settings) error {
}
return nil
}
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
// signing secret on the singleton settings row. Pass an empty string to
// disable signing globally (notifications still send, just without HMAC).
func (s *Store) SetSettingsNotificationSecret(secret string) error {
_, err := s.db.Exec(
`UPDATE settings SET notification_secret=?, updated_at=? WHERE id = 1`,
secret, Now(),
)
if err != nil {
return fmt.Errorf("set settings notification secret: %w", err)
}
return nil
}
+42 -2
View File
@@ -8,7 +8,7 @@ import (
"github.com/google/uuid"
)
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, cpu_limit, memory_limit, created_at, updated_at`
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
@@ -17,9 +17,10 @@ func (s *Store) CreateStage(st Stage) (Stage, error) {
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.NotificationSecret,
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
@@ -57,6 +58,7 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
@@ -80,6 +82,8 @@ func (s *Store) UpdateStage(st Stage) error {
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
)
// notification_secret is intentionally not updated here — use the
// dedicated SetStageNotificationSecret rotation helper.
if err != nil {
return fmt.Errorf("update stage: %w", err)
}
@@ -103,6 +107,41 @@ func (s *Store) DeleteStage(id string) error {
return nil
}
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
// secret. Empty string disables HMAC signing for this stage (notifications
// still send unsigned, falling through to project/global resolution).
func (s *Store) SetStageNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set stage notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
stage, err := s.GetStageByID(id)
if err != nil {
return "", err
}
if stage.NotificationSecret != "" {
return stage.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStageNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// BoolToInt converts a bool to an integer for SQLite storage.
func BoolToInt(b bool) int {
if b {
@@ -117,6 +156,7 @@ func scanStage(rows *sql.Rows) (Stage, error) {
var autoDeploy, confirm, enableProxy int
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
&st.NotificationSecret,
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
if err != nil {
return Stage{}, fmt.Errorf("scan stage: %w", err)
+70 -7
View File
@@ -13,7 +13,9 @@ import (
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
storage_enabled, storage_limit_mb, webhook_secret, created_at, updated_at`
storage_enabled, storage_limit_mb, webhook_secret,
notification_url, notification_secret,
created_at, updated_at`
// CreateStaticSite inserts a new static site and returns it. A webhook secret
// is generated automatically if one is not already set on the input.
@@ -29,13 +31,15 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
_, err := s.db.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret, site.CreatedAt, site.UpdatedAt,
site.WebhookSecret,
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
@@ -103,18 +107,21 @@ func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite
}
// UpdateStaticSite updates an existing static site's configuration fields.
// notification_secret is intentionally not updated here — use the dedicated
// SetStaticSiteNotificationSecret rotation helper.
func (s *Store) UpdateStaticSite(site StaticSite) error {
site.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=?
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
notification_url=?, updated_at=?
WHERE id=?`,
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.UpdatedAt, site.ID,
site.NotificationURL, site.UpdatedAt, site.ID,
)
if err != nil {
return fmt.Errorf("update static site: %w", err)
@@ -228,7 +235,9 @@ func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
&site.WebhookSecret,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, err
@@ -248,7 +257,9 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret, &site.CreatedAt, &site.UpdatedAt,
&site.WebhookSecret,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
if err != nil {
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
@@ -258,6 +269,58 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
return site, nil
}
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
// signing secret. Empty string disables HMAC signing for this site
// (notifications still send unsigned, falling through to global resolution).
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site notification secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
// signing secret, generating one lazily if missing.
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
site, err := s.GetStaticSiteByID(id)
if err != nil {
return "", err
}
if site.NotificationSecret != "" {
return site.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
return "", err
}
return secret, nil
}
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
// secret, generating one lazily if missing.
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
st, err := s.GetSettings()
if err != nil {
return "", err
}
if st.NotificationSecret != "" {
return st.NotificationSecret, nil
}
secret := generateWebhookSecret()
if err := s.SetSettingsNotificationSecret(secret); err != nil {
return "", err
}
return secret, nil
}
// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret.
// Returns ErrNotFound if no site has this secret (including empty).
func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) {
+23 -8
View File
@@ -138,6 +138,15 @@ func (s *Store) runMigrations() error {
// retention in hours. 0 in either disables collection.
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
// tokens (matches the inbound webhook_secret pattern). Empty = no
// signing; existing rows stay unsigned on upgrade for back-compat.
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
}
// Additive stack tables (2026-04-16). Created here rather than in the
@@ -284,7 +293,9 @@ CREATE TABLE IF NOT EXISTS projects (
healthcheck TEXT NOT NULL DEFAULT '',
env TEXT NOT NULL DEFAULT '{}',
volumes TEXT NOT NULL DEFAULT '{}',
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -299,8 +310,9 @@ CREATE TABLE IF NOT EXISTS stages (
confirm INTEGER NOT NULL DEFAULT 0,
enable_proxy INTEGER NOT NULL DEFAULT 1,
promote_from TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
subdomain TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
cpu_limit REAL NOT NULL DEFAULT 0,
memory_limit INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -326,6 +338,7 @@ CREATE TABLE IF NOT EXISTS settings (
network TEXT NOT NULL DEFAULT 'tinyforge',
subdomain_pattern TEXT NOT NULL DEFAULT 'stage-{stage}-{project}',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
npm_url TEXT NOT NULL DEFAULT '',
npm_email TEXT NOT NULL DEFAULT '',
npm_password TEXT NOT NULL DEFAULT '',
@@ -487,11 +500,13 @@ CREATE TABLE IF NOT EXISTS static_sites (
container_id TEXT NOT NULL DEFAULT '',
proxy_route_id TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'idle',
last_sync_at TEXT NOT NULL DEFAULT '',
last_commit_sha TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
last_sync_at TEXT NOT NULL DEFAULT '',
last_commit_sha TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
notification_url TEXT NOT NULL DEFAULT '',
notification_secret TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS static_site_secrets (