feat(notify): HMAC-signed outgoing webhooks with per-tier secrets and test sender
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Outgoing notifications were bare POSTs with no auth and no way to verify
they came from Tinyforge. They also went out from one global URL only,
even though stages had a notification_url field, and static-site sync
emitted no events at all.
Schema: add notification_url + notification_secret (lazy-generated) to
settings, projects, stages and static_sites. Migrations are additive.
Notifier: SendSigned computes HMAC-SHA256 over the exact body bytes and
sends X-Hub-Signature-256 (GitHub-compatible — receivers built for
GitHub/Gitea/Forgejo verify out of the box). Aux headers
X-Tinyforge-Event/Delivery/Timestamp/Tier are advisory and not signed.
Empty secret => unsigned send for back-compat.
Resolution: deploys fall through stage > project > settings, sites fall
through site > settings. The secret travels with the URL that sourced
it, so any tier can sign even when its parents are unsigned. Site sync
events now actually emit (site_sync_success / site_sync_failure).
API: 12 new endpoints — {GET secret, POST regenerate, POST disable,
POST test} for each of the 4 tiers. SendSyncForTest returns
status_code/latency_ms/signature_sent/delivery_id/response_snippet so
the UI surfaces receiver feedback inline.
UI: shared OutgoingWebhookPanel.svelte fits the existing card aesthetic.
Signing-state pill, secret reveal-on-demand, regenerate/disable behind
ConfirmDialog modals (not inline strips — too easy to misclick), send-
test result card with colour-coded status. Wired into Settings →
Integrations, project edit form, per-stage edit, and per-site detail.
EN + RU i18n.
Tests: round-trip (sender signs, receiver verifies), tampered-body and
wrong-secret rejection, unsigned-send omits header, send-test surfaces
4xx, concurrent fan-out via Drain. Resolver precedence locked for both
deploy and site paths.
Docs: docs/webhooks.md with header reference, verifier snippets in
Node/Python/Go, and a recipe for the service-to-notification-bridge
generic webhook provider.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user