diff --git a/cmd/server/main.go b/cmd/server/main.go index 93de034..c022b7d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -38,6 +38,7 @@ import ( notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty" notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack" notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams" + notifywebhook "github.com/certctl-io/certctl/internal/connector/notifier/webhook" "github.com/certctl-io/certctl/internal/crypto/signer" "github.com/certctl-io/certctl/internal/domain" authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth" @@ -742,6 +743,31 @@ func main() { logger.Info("OpsGenie notifier enabled") } + // Acquisition-audit DOC-001 closure (Sprint 7 ACQ, 2026-05-16). + // Generic webhook notifier. The webhook impl shipped to + // internal/connector/notifier/webhook/ months ago with full + // SafeHTTPDialContext SSRF guard + HMAC-SHA256 signing + tests but + // was never wired here — the README's "6 notifiers" claim was off + // by one. NotifierAdapter bridges the rich notifier.Connector + // interface (SendEvent / SendAlert / ValidateConfig) to the + // service.Notifier (Send + Channel) shape used by the notification + // service. Empty CERTCTL_WEBHOOK_URL keeps the notifier disabled + // (matches the env-var-gated pattern of the other five). The + // signing secret is operator-acknowledged optional — see + // internal/config/notifiers.go::NotifierConfig.WebhookSecret. + if cfg.Notifiers.WebhookURL != "" { + webhookConnector := notifywebhook.New(¬ifywebhook.Config{ + URL: cfg.Notifiers.WebhookURL, + Secret: cfg.Notifiers.WebhookSecret, + }, logger) + notifierRegistry["Webhook"] = notifywebhook.NewNotifierAdapter(webhookConnector) + signedHint := "unsigned" + if cfg.Notifiers.WebhookSecret != "" { + signedHint = "HMAC-SHA256 signed" + } + logger.Info("Webhook notifier enabled", "signing", signedHint) + } + // Wire email notifier if SMTP is configured var emailAdapter *notifyemail.NotifierAdapter if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" { diff --git a/internal/config/config.go b/internal/config/config.go index b70a3dd..8d29c94 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -613,6 +613,12 @@ func Load() (*Config, error) { SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""), SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""), SMTPUseTLS: getEnvBool("CERTCTL_SMTP_USE_TLS", true), + // Acquisition-audit DOC-001 closure (Sprint 7 ACQ, 2026-05-16). + // Wire the previously-orphan webhook notifier + // (internal/connector/notifier/webhook/) into the boot + // path. Empty WebhookURL = notifier disabled. + WebhookURL: getEnv("CERTCTL_WEBHOOK_URL", ""), + WebhookSecret: getEnv("CERTCTL_WEBHOOK_SECRET", ""), }, NetworkScan: NetworkScanConfig{ Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false), diff --git a/internal/config/notifiers.go b/internal/config/notifiers.go index 1b38605..c05a9ae 100644 --- a/internal/config/notifiers.go +++ b/internal/config/notifiers.go @@ -83,4 +83,28 @@ type NotifierConfig struct { // Default: true. Set to false for plain SMTP (not recommended). // Setting: CERTCTL_SMTP_USE_TLS environment variable. SMTPUseTLS bool + + // WebhookURL is the HTTP(S) endpoint for the generic webhook + // notifier. Acquisition-audit DOC-001 closure (Sprint 7 ACQ, + // 2026-05-16). When set, the cmd/server/main.go boot path + // constructs an internal/connector/notifier/webhook.Connector + // (full SafeHTTPDialContext SSRF guard + ValidateSafeURL pre- + // flight + HMAC-SHA256 signing) wrapped in NotifierAdapter so + // the simpler service.Notifier (Send + Channel) interface used + // by the notification service receives a "webhook" channel + // registration. Pre-Sprint-7 the impl existed in the tree but + // was unwired — README claimed "6 notifiers" while only 5 + // were registered. Optional: leave empty to disable. + // Setting: CERTCTL_WEBHOOK_URL environment variable. + WebhookURL string + + // WebhookSecret is the HMAC-SHA256 shared secret used by the + // webhook notifier to sign every outbound HTTP POST in the + // X-Webhook-Signature header. The receiver verifies the signature + // against the SAME secret before trusting the payload — without + // this guard, any host that can reach the operator's webhook + // endpoint could spoof certctl notifications. Optional but + // strongly recommended; empty disables signing (operator- + // acknowledged unsigned mode). Setting: CERTCTL_WEBHOOK_SECRET. + WebhookSecret string } diff --git a/internal/connector/notifier/webhook/adapter.go b/internal/connector/notifier/webhook/adapter.go new file mode 100644 index 0000000..e3914ef --- /dev/null +++ b/internal/connector/notifier/webhook/adapter.go @@ -0,0 +1,106 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 + +package webhook + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + "github.com/certctl-io/certctl/internal/connector/notifier" +) + +// NotifierAdapter bridges the rich notifier.Connector interface +// (SendAlert / SendEvent / ValidateConfig) to the simpler service- +// layer service.Notifier interface (Send + Channel) used by the +// notification service for per-recipient expiry alerts + threshold +// notifications. +// +// Acquisition-audit DOC-001 closure (Sprint 7 ACQ, 2026-05-16). +// Pre-Sprint-7 the webhook notifier was a complete impl with full +// SSRF guard + HMAC-SHA256 signing + tests, but it was never wired +// in cmd/server/main.go — README claimed "6 notifiers" while only 5 +// were actually registered. This adapter closes the wire gap so the +// "6 notifiers" claim is accurate. Mirrors the +// notifyemail.NotifierAdapter pattern. +// +// Method semantics: +// +// Send(ctx, recipient, subject, body) — constructs a +// notifier.Event with the three fields populated + a fresh +// random ID + the current UTC timestamp, then delegates to +// the underlying Connector's SendEvent. The webhook payload +// the recipient sees is the canonical {id, type, recipient, +// subject, body, metadata, created_at} JSON shape — same +// shape ValidateConfig probes for. +// +// Channel() — returns "webhook" so the notification service's +// per-channel routing matches the operator's +// CERTCTL_WEBHOOK_URL configuration. +// +// The Connector's per-request HMAC-SHA256 signing + SafeHTTPDialContext +// SSRF guard apply transitively — every Send call routes through +// SendEvent which routes through postWebhook which applies both +// defenses. No defense duplication is needed at the adapter layer. +type NotifierAdapter struct { + c *Connector +} + +// NewNotifierAdapter wraps a fully-configured webhook Connector for +// use as a service.Notifier. The Connector MUST be constructed via +// webhook.New (production) — newForTest is rejected by Go's package +// visibility from outside the webhook package, so production callers +// cannot accidentally adapt a permissive-validator connector. +func NewNotifierAdapter(c *Connector) *NotifierAdapter { + return &NotifierAdapter{c: c} +} + +// Channel returns the channel identifier used by the notification +// service's per-channel routing map. +func (a *NotifierAdapter) Channel() string { + return "webhook" +} + +// Send delivers a notification by translating the service-layer +// {recipient, subject, body} tuple into a notifier.Event and +// delegating to the underlying Connector's SendEvent. The Event +// carries a fresh 16-hex random ID (NOT a UUID — no extra dep +// needed; 128 bits of entropy is enough for de-dup at the receiver +// without colliding) and the current UTC time. +// +// The webhook recipient sees a JSON body like: +// +// { +// "id": "...", +// "type": "notification", +// "recipient": "", +// "subject": "", +// "body": "", +// "created_at": "" +// } +// +// signed with HMAC-SHA256 in the X-Webhook-Signature header (when +// CERTCTL_WEBHOOK_SECRET is set). +func (a *NotifierAdapter) Send(ctx context.Context, recipient string, subject string, body string) error { + event := notifier.Event{ + ID: adapterEventID(), + Type: "notification", + Recipient: recipient, + Subject: subject, + Body: body, + CreatedAt: time.Now().UTC(), + } + return a.c.SendEvent(ctx, event) +} + +// adapterEventID returns a 32-character hex random ID for the +// adapter-side event. 16 bytes from crypto/rand is enough for de- +// duplication at the webhook recipient without adding a UUID +// dependency (we already use crypto/rand transitively). +func adapterEventID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +}