mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:41:36 +00:00
feat(notifier): DOC-001 — wire the orphan webhook notifier; README "6 notifiers" now accurate
Acquisition-audit DOC-001 closure (Sprint 7 ACQ, 2026-05-16). The
webhook notifier shipped to internal/connector/notifier/webhook/
months ago with full SafeHTTPDialContext SSRF guard + HMAC-SHA256
signing + comprehensive tests, but it was never wired in
cmd/server/main.go — README:39 claimed "6 notifiers" while only 5
were actually registered. Audit prompt offered two paths: (a) wire
it if the impl is feature-complete, (b) fix the README count. The
impl IS feature-complete (verified by reading webhook.go +
webhook_test.go), so path (a) is the rigorous closure.
What this commit adds
=====================
internal/connector/notifier/webhook/adapter.go (NEW):
NotifierAdapter bridges the rich notifier.Connector interface
(SendAlert / SendEvent / ValidateConfig) to the simpler service-
layer service.Notifier (Send + Channel) used by the notification
service's per-channel routing. Send(ctx, recipient, subject,
body) constructs a notifier.Event with the three fields populated
+ a fresh 16-byte hex random ID + UTC timestamp, delegates to
the Connector's SendEvent. Channel() returns "webhook". The
Connector's per-request HMAC-SHA256 signing + SafeHTTPDialContext
SSRF guard apply transitively through SendEvent → postWebhook
— no defense duplication at the adapter layer.
internal/config/notifiers.go:
NotifierConfig gains WebhookURL + WebhookSecret fields with the
same docstring shape as the other 5 notifier env-var pairs.
internal/config/config.go::Load():
Reads CERTCTL_WEBHOOK_URL + CERTCTL_WEBHOOK_SECRET (both empty
by default → notifier disabled, matching the pattern of the
other 5 env-var-gated notifiers).
cmd/server/main.go:
- notifywebhook import added next to the other 5.
- New wire-up block after the OpsGenie one: when WebhookURL is
set, constructs the Connector via webhook.New (production
constructor — strict ValidateSafeURL + SafeHTTPDialContext),
wraps in NotifierAdapter, registers as notifierRegistry["Webhook"].
Boot log includes the signing posture ("HMAC-SHA256 signed"
vs "unsigned") so operators can spot a missing secret.
Target-connector count reconciliation
=====================================
The audit prompt also asked to reconcile the target-connector
count (README says "fourteen + Kubernetes Secrets preview" = 15;
ls internal/connector/target/ shows 17 dirs). Ground-truth: the
extra two dirs (certutil, configcheck) are shared HELPER packages
(PEM/PFX conversion + server-side shell-injection validation
respectively), NOT target connectors. Real target-connector count
is 17 - 2 = 15, exactly matching README:12 + README:39. No README
change needed.
Verified locally: gofmt clean, go vet clean, staticcheck clean
across internal/config + internal/connector/notifier/webhook +
cmd/server; `go test -count=1
./internal/connector/notifier/webhook/...` green (existing tests
unchanged); `go test -short -count=1 ./internal/config/...
./cmd/server/...` green; `go build ./cmd/server` produces a
30.9MB binary that boots.
This commit is contained in:
@@ -38,6 +38,7 @@ import (
|
|||||||
notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty"
|
notifypagerduty "github.com/certctl-io/certctl/internal/connector/notifier/pagerduty"
|
||||||
notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack"
|
notifyslack "github.com/certctl-io/certctl/internal/connector/notifier/slack"
|
||||||
notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
|
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/crypto/signer"
|
||||||
"github.com/certctl-io/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||||
@@ -742,6 +743,31 @@ func main() {
|
|||||||
logger.Info("OpsGenie notifier enabled")
|
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
|
// Wire email notifier if SMTP is configured
|
||||||
var emailAdapter *notifyemail.NotifierAdapter
|
var emailAdapter *notifyemail.NotifierAdapter
|
||||||
if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" {
|
if cfg.Notifiers.SMTPHost != "" && cfg.Notifiers.SMTPFromAddress != "" {
|
||||||
|
|||||||
@@ -613,6 +613,12 @@ func Load() (*Config, error) {
|
|||||||
SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""),
|
SMTPPassword: getEnv("CERTCTL_SMTP_PASSWORD", ""),
|
||||||
SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""),
|
SMTPFromAddress: getEnv("CERTCTL_SMTP_FROM_ADDRESS", ""),
|
||||||
SMTPUseTLS: getEnvBool("CERTCTL_SMTP_USE_TLS", true),
|
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{
|
NetworkScan: NetworkScanConfig{
|
||||||
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
Enabled: getEnvBool("CERTCTL_NETWORK_SCAN_ENABLED", false),
|
||||||
|
|||||||
@@ -83,4 +83,28 @@ type NotifierConfig struct {
|
|||||||
// Default: true. Set to false for plain SMTP (not recommended).
|
// Default: true. Set to false for plain SMTP (not recommended).
|
||||||
// Setting: CERTCTL_SMTP_USE_TLS environment variable.
|
// Setting: CERTCTL_SMTP_USE_TLS environment variable.
|
||||||
SMTPUseTLS bool
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "<recipient>",
|
||||||
|
// "subject": "<subject>",
|
||||||
|
// "body": "<body>",
|
||||||
|
// "created_at": "<RFC3339>"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// 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[:])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user