diff --git a/cmd/server/main.go b/cmd/server/main.go index 12b74be..b805bce 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -206,7 +206,7 @@ func main() { if err != nil || currentSettings.NotificationURL == "" { continue } - notifier.Send(currentSettings.NotificationURL, notify.Event{ + notifier.SendSigned(currentSettings.NotificationURL, currentSettings.NotificationSecret, notify.TierSettings, notify.Event{ Type: p.Source + "_error", Project: p.Source, Error: p.Message, @@ -284,7 +284,7 @@ func main() { statsCollector.Start() // Initialize static site manager and health checker. - staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey) + staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey) webhookHandler.SetSiteSyncTriggerer(staticSiteMgr) staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr) if err := staticSiteHealth.Start("2m"); err != nil { @@ -304,7 +304,7 @@ func main() { } // Build API server. - apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey) + apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey) apiServer.SetStaticSiteManager(staticSiteMgr) if stackMgr != nil { apiServer.SetStackManager(stackMgr) diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..b25c7db --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,159 @@ +# Outgoing webhooks + +Tinyforge posts JSON events to a configured URL when deploys and static-site +syncs finish. Receivers can verify each request was sent by Tinyforge and +not tampered with by checking the **HMAC-SHA256** signature on the body. + +## Tiers and resolution + +A single global URL is rarely enough — different teams own different +projects, and operators often want to route prod failures somewhere noisier +than dev failures. Tinyforge supports four tiers: + +| Tier | Where set | Used for | +|-----------|----------------------------------------|-------------------------| +| `stage` | Stage edit form | Per-stage deploys | +| `project` | Project edit form | All stages of a project | +| `site` | Static-site detail page | Static-site sync events | +| `settings`| Settings → Integrations | Global fallback | + +Resolution order: + +* **Deploys**: `stage → project → settings` +* **Sites**: `site → settings` + +The most-specific tier with a non-empty URL wins. The signing secret +travels with the URL that sourced it: a stage can sign even when the +project and global URLs are unsigned. + +## Signature scheme + +Every request includes: + +``` +POST /your/handler HTTP/1.1 +Content-Type: application/json +User-Agent: Tinyforge-Webhook/1 +X-Hub-Signature-256: sha256= +X-Tinyforge-Event: deploy_success +X-Tinyforge-Delivery: 0f3a…-uuid +X-Tinyforge-Timestamp: 2026-05-07T12:34:56Z +X-Tinyforge-Tier: stage +``` + +The signature is `HMAC-SHA256(secret, raw_body)`, hex-encoded, with the +`sha256=` prefix — the GitHub `X-Hub-Signature-256` format. Receivers +already built for GitHub-style webhooks (Gitea, Forgejo, n8n, Hookdeck, +the service-to-notification-bridge generic webhook provider) verify it +without modification. + +When no signing secret is configured for the resolved tier, the +signature header is omitted and the request goes out unsigned. This is +intentional back-compat for receivers that don't speak HMAC. + +## Receiver requirements + +A correct verifier: + +1. **Reads the raw body bytes** before any JSON parse / re-serialise. +2. Computes `HMAC-SHA256(secret, body)` and compares to the value after + `sha256=` in `X-Hub-Signature-256`. +3. Uses a **constant-time** comparator (`hmac.compare_digest` / + `crypto.timingSafeEqual` / `hmac.Equal`). +4. Returns 401/403 on mismatch — Tinyforge surfaces the receiver's + status code in the UI when the operator clicks **Send test**. + +### Node / TypeScript + +```ts +import { createHmac, timingSafeEqual } from 'crypto'; + +export function verify(secret: string, rawBody: Buffer, header: string): boolean { + const got = header.startsWith('sha256=') ? header.slice(7) : header; + const want = createHmac('sha256', secret).update(rawBody).digest('hex'); + if (got.length !== want.length) return false; + return timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(want, 'hex')); +} +``` + +### Python + +```python +import hmac +import hashlib + +def verify(secret: str, raw_body: bytes, header: str) -> bool: + got = header[7:] if header.startswith("sha256=") else header + want = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(got, want) +``` + +### Go + +```go +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" +) + +func verify(secret string, body []byte, header string) bool { + got := strings.TrimPrefix(header, "sha256=") + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(got), []byte(want)) +} +``` + +## Event payload + +```json +{ + "type": "deploy_success", + "project": "demo-app", + "stage": "prod", + "image_tag": "v1.4.2", + "subdomain": "stage-prod-demo-app", + "url": "https://stage-prod-demo-app.example.com", + "timestamp": "2026-05-07T12:34:56Z" +} +``` + +`type` values: +- `deploy_success`, `deploy_failure` — sent by the deployer. +- `site_sync_success`, `site_sync_failure` — sent by the static-site manager. + Use the `project` field as the site name; `stage` and `image_tag` are empty. +- `test` — sent by the **Send test** button. Treat it as a no-op or + surface it in your operator log; never as a real deploy event. + +## Configuring the service-to-notification-bridge + +If you're sending Tinyforge events to the +[service-to-notification-bridge](https://github.com/) generic webhook +provider: + +1. Create a **Generic Webhook** provider. +2. Set `auth_mode = hmac_sha256`. +3. Paste the **same secret** Tinyforge generated (revealed via the + Outgoing webhook panel). +4. Set `event_type_path = type` so deploys and site syncs map to + distinct event types in the bridge. +5. Add `payload_mappings` for `project`, `stage`, `image_tag`, `url`, + `error` and reference them as `{{ extra.project }}` in your + notification templates. + +The bridge accepts `X-Hub-Signature-256` natively (no header rename +needed) and reads the raw body before parsing, so step 1 of the +receiver requirements is already met. + +## Rotating secrets + +Click **Regenerate** in the Outgoing webhook panel to rotate. The old +secret is invalidated immediately — update receivers in lock-step or +expect a brief window of 401s. There is no soft rollover today. + +To send unsigned events to a legacy receiver that can't verify, click +**Disable signing**. Tinyforge will keep dispatching events without the +`X-Hub-Signature-256` header until you regenerate. diff --git a/internal/api/notifications.go b/internal/api/notifications.go new file mode 100644 index 0000000..e862967 --- /dev/null +++ b/internal/api/notifications.go @@ -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 +} diff --git a/internal/api/projects.go b/internal/api/projects.go index c7dae97..8b3b99f 100644 --- a/internal/api/projects.go +++ b/internal/api/projects.go @@ -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) diff --git a/internal/api/router.go b/internal/api/router.go index ff46c84..91b57dc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/settings.go b/internal/api/settings.go index 9db947c..fef7e3b 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -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 != "", diff --git a/internal/api/static_sites.go b/internal/api/static_sites.go index f59badb..6ecfda5 100644 --- a/internal/api/static_sites.go +++ b/internal/api/static_sites.go @@ -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 != "" { diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 1471e5c..ceb4eba 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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 { diff --git a/internal/deployer/resolver_test.go b/internal/deployer/resolver_test.go new file mode 100644 index 0000000..54a52df --- /dev/null +++ b/internal/deployer/resolver_test.go @@ -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) + } + }) + } +} diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go index 081c7e6..ce423e7 100644 --- a/internal/notify/notifier.go +++ b/internal/notify/notifier.go @@ -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=" 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 } diff --git a/internal/notify/notifier_test.go b/internal/notify/notifier_test.go new file mode 100644 index 0000000..ae7d192 --- /dev/null +++ b/internal/notify/notifier_test.go @@ -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) +} diff --git a/internal/staticsite/manager.go b/internal/staticsite/manager.go index a83c1d6..62782e7 100644 --- a/internal/staticsite/manager.go +++ b/internal/staticsite/manager.go @@ -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 diff --git a/internal/staticsite/resolver_test.go b/internal/staticsite/resolver_test.go new file mode 100644 index 0000000..e2e57ea --- /dev/null +++ b/internal/staticsite/resolver_test.go @@ -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) + } + }) + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 808fec5..6c581c7 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` } diff --git a/internal/store/projects.go b/internal/store/projects.go index eb2fa0e..c7caf21 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -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) diff --git a/internal/store/settings.go b/internal/store/settings.go index 7e2e9fa..45413d2 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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 +} diff --git a/internal/store/stages.go b/internal/store/stages.go index 7c01098..1605b9e 100644 --- a/internal/store/stages.go +++ b/internal/store/stages.go @@ -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) diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index 2566ecb..8c0afcb 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -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) { diff --git a/internal/store/store.go b/internal/store/store.go index 27f0977..6c6ffa6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 ( diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4f0f73a..59d5f80 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -346,6 +346,80 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise(`/api/sites/${siteId}/webhook/regenerate`); } +// ── Outgoing-webhook signing & test ──────────────────────────────── + +export interface NotificationSecretResponse { + secret: string; + has_secret: boolean; +} + +export interface NotificationTestResult { + url: string; + tier: 'settings' | 'project' | 'stage' | 'site'; + status_code: number; + latency_ms: number; + signature_sent: boolean; + delivery_id: string; + response_snippet: string; + error?: string; +} + +// Settings (global) tier. +export function getSettingsNotificationSecret(): Promise { + return get('/api/settings/notification-secret'); +} +export function regenerateSettingsNotificationSecret(): Promise { + return post('/api/settings/notification-secret/regenerate'); +} +export function disableSettingsNotificationSigning(): Promise { + return post('/api/settings/notification-secret/disable'); +} +export function testSettingsNotification(): Promise { + return post('/api/settings/notification-test'); +} + +// Project tier. +export function getProjectNotificationSecret(projectId: string): Promise { + return get(`/api/projects/${projectId}/notification-secret`); +} +export function regenerateProjectNotificationSecret(projectId: string): Promise { + return post(`/api/projects/${projectId}/notification-secret/regenerate`); +} +export function disableProjectNotificationSigning(projectId: string): Promise { + return post(`/api/projects/${projectId}/notification-secret/disable`); +} +export function testProjectNotification(projectId: string): Promise { + return post(`/api/projects/${projectId}/notification-test`); +} + +// Stage tier. +export function getStageNotificationSecret(projectId: string, stageId: string): Promise { + return get(`/api/projects/${projectId}/stages/${stageId}/notification-secret`); +} +export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise { + return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`); +} +export function disableStageNotificationSigning(projectId: string, stageId: string): Promise { + return post(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`); +} +export function testStageNotification(projectId: string, stageId: string): Promise { + return post(`/api/projects/${projectId}/stages/${stageId}/notification-test`); +} + +// Static-site tier. +export function getStaticSiteNotificationSecret(siteId: string): Promise { + return get(`/api/sites/${siteId}/notification-secret`); +} +export function regenerateStaticSiteNotificationSecret(siteId: string): Promise { + return post(`/api/sites/${siteId}/notification-secret/regenerate`); +} +export function disableStaticSiteNotificationSigning(siteId: string): Promise { + return post(`/api/sites/${siteId}/notification-secret/disable`); +} +export function testStaticSiteNotification(siteId: string): Promise { + return post(`/api/sites/${siteId}/notification-test`); +} + // ── Proxy Routes ─────────────────────────────────────────────────── export function listProxyRoutes(): Promise { diff --git a/web/src/lib/components/OutgoingWebhookPanel.svelte b/web/src/lib/components/OutgoingWebhookPanel.svelte new file mode 100644 index 0000000..45ff822 --- /dev/null +++ b/web/src/lib/components/OutgoingWebhookPanel.svelte @@ -0,0 +1,367 @@ + + + +
+
+
+

{title}

+

{description}

+
+ + {#if hasSecret} + + + {$t('outgoingWebhook.signingOn')} + + {:else} + + + {$t('outgoingWebhook.signingOff')} + + {/if} +
+ + + {#if !hasUrl} +
+ + + {#if fallbackLabel} + {$t('outgoingWebhook.fallbackTo', { label: fallbackLabel })} + {:else} + {$t('outgoingWebhook.noUrlConfigured')} + {/if} + +
+ {/if} + + +
+ {$t('outgoingWebhook.signingSecret')} +
+
+ + {#if revealed && secret} + {secret} + {:else if hasSecret} + •••••••••••••••••••••••••••••••• + {:else} + {$t('outgoingWebhook.noSecret')} + {/if} + + {#if revealed && secret} + + {:else} + + {/if} +
+ + +
+ + {#if hasSecret} + + {/if} +
+ + +
+
+
+
{$t('outgoingWebhook.sendTestTitle')}
+

{$t('outgoingWebhook.sendTestHelp')}

+
+ +
+ + {#if testResult} + {@const cls = resultClass(testResult)} +
+ +
+ + {#if cls === 'success'}{:else}{/if} + {testResult.status_code || $t('outgoingWebhook.networkError')} + + + {testResult.latency_ms}ms + + + {$t('outgoingWebhook.tier')}: {testResult.tier} + + {#if testResult.signature_sent} + + + {$t('outgoingWebhook.signed')} + + {:else} + {$t('outgoingWebhook.unsigned')} + {/if} +
+ + +
+ {#if testResult.error} +
+ {testResult.error} +
+ {/if} +
+ {$t('outgoingWebhook.deliveryId')}: + {testResult.delivery_id} +
+ {#if testResult.response_snippet} +
+
+ {$t('outgoingWebhook.responseBody')} +
+
{testResult.response_snippet}
+
+ {/if} +
+
+ {/if} +
+
+ + + (confirmRegenerate = false)} +/> + + (confirmDisable = false)} +/> diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index c23fd0b..9ca2071 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -120,6 +120,16 @@ "projectDetail": { "webhookTitle": "Project webhook", "webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.", + "outgoingWebhookTitle": "Outgoing webhook (project)", + "outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.", + "outgoingFallbackGlobal": "the global integrations setting", + "notificationUrlLabel": "Outgoing webhook URL", + "notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.", + "stageNotificationUrlLabel": "Outgoing webhook URL (this stage)", + "stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.", + "stageOutgoingTitle": "Outgoing webhook (stage)", + "stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.", + "stageFallbackLabel": "the project or global settings", "deleteProject": "Delete Project", "envVars": "Environment Variables", "volumes": "Volume Mounts", @@ -618,6 +628,11 @@ "sites": { "webhookTitle": "Site webhook", "webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.", + "outgoingUrlTitle": "Outgoing webhook URL (this site)", + "outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.", + "outgoingWebhookTitle": "Outgoing webhook (site)", + "outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.", + "outgoingFallbackGlobal": "the global integrations setting", "title": "Static Sites", "addSite": "New Site", "newSite": "New Static Site", @@ -1168,6 +1183,43 @@ "confirmYes": "Regenerate", "confirmNo": "Cancel" }, + "outgoingWebhook": { + "signingOn": "Signed", + "signingOff": "Unsigned", + "signingSecret": "HMAC signing secret", + "noSecret": "No signing secret — outgoing events are not signed.", + "reveal": "Reveal", + "generate": "Generate", + "copy": "Copy", + "copied": "Signing secret copied to clipboard", + "copyFailed": "Failed to copy to clipboard", + "loadFailed": "Failed to load signing secret", + "regenerate": "Regenerate", + "regenerated": "Signing secret regenerated", + "regenerateFailed": "Failed to regenerate signing secret", + "confirmRegenerateTitle": "Rotate signing secret?", + "confirmRegenerate": "The current secret is invalidated immediately. Every receiver verifying it must be updated in lock-step or it will start rejecting events.", + "confirmDisableTitle": "Disable HMAC signing?", + "confirmDisable": "Future events go out without the X-Hub-Signature-256 header. Receivers that require signatures will reject them.", + "confirmYes": "Confirm", + "confirmNo": "Cancel", + "disable": "Disable signing", + "disabled": "Signing disabled", + "disableFailed": "Failed to disable signing", + "sendTestTitle": "Send a test event", + "sendTestHelp": "Fires a synthetic \"test\" event to the resolved URL using the current secret.", + "sendTest": "Send test", + "sending": "Sending…", + "testFailed": "Failed to send test event", + "tier": "Tier", + "signed": "Signed", + "unsigned": "Unsigned", + "deliveryId": "Delivery", + "responseBody": "Response body", + "networkError": "Network error", + "fallbackTo": "No URL set on this tier — events will fall through to {label}.", + "noUrlConfigured": "No URL set. Configure one above before sending a test." + }, "settingsMaintenance": { "title": "Maintenance", "thresholds": "Thresholds", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 23c79e2..04a7644 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -120,6 +120,16 @@ "projectDetail": { "webhookTitle": "Webhook проекта", "webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.", + "outgoingWebhookTitle": "Исходящий webhook (проект)", + "outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.", + "outgoingFallbackGlobal": "глобальной настройки интеграций", + "notificationUrlLabel": "URL исходящего webhook", + "notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.", + "stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)", + "stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.", + "stageOutgoingTitle": "Исходящий webhook (стейдж)", + "stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.", + "stageFallbackLabel": "проектной или глобальной настройки", "deleteProject": "Удалить проект", "envVars": "Переменные окружения", "volumes": "Тома", @@ -618,6 +628,11 @@ "sites": { "webhookTitle": "Webhook сайта", "webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.", + "outgoingUrlTitle": "URL исходящего webhook (этот сайт)", + "outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.", + "outgoingWebhookTitle": "Исходящий webhook (сайт)", + "outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.", + "outgoingFallbackGlobal": "глобальной настройки интеграций", "title": "Статические сайты", "addSite": "Новый сайт", "newSite": "Новый статический сайт", @@ -1168,6 +1183,43 @@ "confirmYes": "Перегенерировать", "confirmNo": "Отмена" }, + "outgoingWebhook": { + "signingOn": "Подпись включена", + "signingOff": "Без подписи", + "signingSecret": "HMAC-секрет", + "noSecret": "Секрет не задан — исходящие события отправляются без подписи.", + "reveal": "Показать", + "generate": "Сгенерировать", + "copy": "Копировать", + "copied": "Секрет скопирован в буфер обмена", + "copyFailed": "Не удалось скопировать", + "loadFailed": "Не удалось загрузить секрет", + "regenerate": "Перегенерировать", + "regenerated": "Секрет перегенерирован", + "regenerateFailed": "Не удалось перегенерировать секрет", + "confirmRegenerateTitle": "Перегенерировать секрет?", + "confirmRegenerate": "Текущий секрет инвалидируется немедленно. Все получатели должны быть обновлены синхронно — иначе начнут отклонять события.", + "confirmDisableTitle": "Отключить HMAC-подпись?", + "confirmDisable": "Будущие события пойдут без заголовка X-Hub-Signature-256. Получатели, требующие подпись, начнут их отклонять.", + "confirmYes": "Подтвердить", + "confirmNo": "Отмена", + "disable": "Отключить подпись", + "disabled": "Подпись отключена", + "disableFailed": "Не удалось отключить подпись", + "sendTestTitle": "Отправить тестовое событие", + "sendTestHelp": "Отправляет синтетическое событие \"test\" на разрешённый URL с текущим секретом.", + "sendTest": "Отправить тест", + "sending": "Отправка…", + "testFailed": "Не удалось отправить тестовое событие", + "tier": "Уровень", + "signed": "Подписано", + "unsigned": "Без подписи", + "deliveryId": "Доставка", + "responseBody": "Тело ответа", + "networkError": "Сетевая ошибка", + "fallbackTo": "URL не задан на этом уровне — события унаследуются от {label}.", + "noUrlConfigured": "URL не задан. Настройте его выше перед тестом." + }, "settingsMaintenance": { "title": "Обслуживание", "thresholds": "Пороги", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 222d398..66059e8 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -10,6 +10,7 @@ export interface Project { env: string; volumes: string; npm_access_list_id: number; + notification_url: string; created_at: string; updated_at: string; } @@ -25,6 +26,7 @@ export interface Stage { enable_proxy: boolean; promote_from: string; subdomain: string; + notification_url: string; cpu_limit: number; memory_limit: number; created_at: string; @@ -391,6 +393,7 @@ export interface StaticSite { error: string; storage_enabled: boolean; storage_limit_mb: number; + notification_url: string; created_at: string; updated_at: string; } diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 6de4e61..b7044fb 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -14,6 +14,7 @@ import FormField from '$lib/components/FormField.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import WebhookPanel from '$lib/components/WebhookPanel.svelte'; + import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte'; import type { EntityPickerItem } from '$lib/types'; import { IconShield } from '$lib/components/icons'; @@ -43,6 +44,7 @@ let editStageMaxInstances = $state('1'); let editStageCpuLimit = $state(''); let editStageMemoryLimit = $state(''); + let editStageNotificationUrl = $state(''); let savingStage = $state(false); function startEditStage(stage: Stage) { @@ -54,6 +56,7 @@ editStageMaxInstances = String(stage.max_instances); editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : ''; editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : ''; + editStageNotificationUrl = stage.notification_url ?? ''; } async function handleUpdateStage() { @@ -68,6 +71,7 @@ max_instances: parseInt(editStageMaxInstances) || 1, cpu_limit: parseFloat(editStageCpuLimit) || 0, memory_limit: parseInt(editStageMemoryLimit) || 0, + notification_url: editStageNotificationUrl.trim(), }); toasts.success($t('projectDetail.stageUpdated')); editingStageId = ''; @@ -122,6 +126,7 @@ let editHealthcheck = $state(''); let editAccessListId = $state(0); let editAccessListName = $state(''); + let editNotificationUrl = $state(''); let accessListPickerOpen = $state(false); let accessListPickerItems = $state([]); let loadingAccessLists = $state(false); @@ -162,6 +167,7 @@ editHealthcheck = project.healthcheck || ''; editAccessListId = project.npm_access_list_id || 0; editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : ''; + editNotificationUrl = project.notification_url ?? ''; editing = true; // Resolve access list name in background. if (editAccessListId > 0) { @@ -182,6 +188,7 @@ port: parseInt(editPort) || 0, healthcheck: editHealthcheck.trim(), npm_access_list_id: editAccessListId, + notification_url: editNotificationUrl.trim(), }); toasts.success($t('projectDetail.projectUpdated')); editing = false; @@ -467,6 +474,7 @@ +
@@ -617,6 +625,15 @@
+
+ +
+ +
+ api.getStageNotificationSecret(projectId, stage.id)} + regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)} + disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)} + sendTest={() => api.testStageNotification(projectId, stage.id)} + /> +
{:else}
@@ -768,7 +800,7 @@
{/if} - + api.regenerateProjectWebhook(projectId)} /> + + api.getProjectNotificationSecret(projectId)} + regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)} + disableSigning={() => api.disableProjectNotificationSigning(projectId)} + sendTest={() => api.testProjectNotification(projectId)} + /> +

{$t('projectDetail.recentDeploys')}

diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte index 40a76d9..9cbb3be 100644 --- a/web/src/routes/settings/integrations/+page.svelte +++ b/web/src/routes/settings/integrations/+page.svelte @@ -1,14 +1,23 @@