mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 18:38:53 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/notifier"
|
||||
)
|
||||
|
||||
// Config represents the webhook notifier configuration.
|
||||
type Config struct {
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret,omitempty"` // Secret for HMAC-SHA256 signature
|
||||
Headers map[string]string `json:"headers,omitempty"` // Custom headers to include
|
||||
}
|
||||
|
||||
// Connector implements the notifier.Connector interface for webhook notifications.
|
||||
// It sends alert and event notifications via HTTP POST with optional HMAC signing.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New creates a new webhook notifier with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the webhook URL is valid and reachable.
|
||||
// It performs a test request to verify the endpoint is accessible.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid webhook config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("webhook url is required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating webhook configuration", "url", cfg.URL)
|
||||
|
||||
// Test webhook connectivity with a HEAD request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid webhook URL: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reach webhook endpoint: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Accept any 2xx or 3xx status code as valid
|
||||
if resp.StatusCode >= 400 {
|
||||
c.logger.Warn("webhook validation: endpoint returned error status",
|
||||
"status_code", resp.StatusCode)
|
||||
// Still allow configuration; the endpoint might be designed to accept POST
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("webhook configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendAlert sends an alert notification via webhook.
|
||||
// It POSTs the alert as JSON to the configured webhook URL with optional HMAC signature.
|
||||
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
|
||||
c.logger.Info("sending webhook alert",
|
||||
"alert_id", alert.ID,
|
||||
"severity", alert.Severity)
|
||||
|
||||
// Format payload
|
||||
payload := map[string]interface{}{
|
||||
"type": "alert",
|
||||
"alert_id": alert.ID,
|
||||
"severity": alert.Severity,
|
||||
"subject": alert.Subject,
|
||||
"message": alert.Message,
|
||||
"recipient": alert.Recipient,
|
||||
"created_at": alert.CreatedAt,
|
||||
"metadata": alert.Metadata,
|
||||
}
|
||||
|
||||
if err := c.postWebhook(ctx, payload); err != nil {
|
||||
c.logger.Error("failed to send alert via webhook",
|
||||
"alert_id", alert.ID,
|
||||
"error", err)
|
||||
return fmt.Errorf("failed to send alert via webhook: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("alert sent via webhook", "alert_id", alert.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendEvent sends an event notification via webhook.
|
||||
// It POSTs the event as JSON to the configured webhook URL with optional HMAC signature.
|
||||
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
|
||||
c.logger.Info("sending webhook event",
|
||||
"event_id", event.ID,
|
||||
"event_type", event.Type)
|
||||
|
||||
// Format payload
|
||||
payload := map[string]interface{}{
|
||||
"type": "event",
|
||||
"event_id": event.ID,
|
||||
"event_type": event.Type,
|
||||
"subject": event.Subject,
|
||||
"body": event.Body,
|
||||
"recipient": event.Recipient,
|
||||
"created_at": event.CreatedAt,
|
||||
}
|
||||
|
||||
if event.CertificateID != nil {
|
||||
payload["certificate_id"] = *event.CertificateID
|
||||
}
|
||||
|
||||
if event.Metadata != nil {
|
||||
payload["metadata"] = event.Metadata
|
||||
}
|
||||
|
||||
if err := c.postWebhook(ctx, payload); err != nil {
|
||||
c.logger.Error("failed to send event via webhook",
|
||||
"event_id", event.ID,
|
||||
"error", err)
|
||||
return fmt.Errorf("failed to send event via webhook: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("event sent via webhook", "event_id", event.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// postWebhook sends a payload to the webhook URL with proper headers and signing.
|
||||
// If a secret is configured, it signs the payload using HMAC-SHA256 and includes
|
||||
// the signature in the X-Signature header.
|
||||
func (c *Connector) postWebhook(ctx context.Context, payload interface{}) error {
|
||||
// Marshal payload to JSON
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.URL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set standard headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "certctl-notifier/1.0")
|
||||
|
||||
// Add custom headers from configuration
|
||||
for key, value := range c.config.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// Sign payload if secret is configured
|
||||
if c.config.Secret != "" {
|
||||
signature := c.signPayload(jsonData)
|
||||
req.Header.Set("X-Signature", signature)
|
||||
req.Header.Set("X-Signature-Algorithm", "sha256")
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body for error logging
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Accept 2xx status codes as success
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
c.logger.Debug("webhook request successful",
|
||||
"status_code", resp.StatusCode,
|
||||
"url", c.config.URL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signPayload computes an HMAC-SHA256 signature of the payload using the configured secret.
|
||||
// The signature is returned as a hex-encoded string in the format "sha256=<hex>".
|
||||
func (c *Connector) signPayload(data []byte) string {
|
||||
h := hmac.New(sha256.New, []byte(c.config.Secret))
|
||||
h.Write(data)
|
||||
signature := hex.EncodeToString(h.Sum(nil))
|
||||
return fmt.Sprintf("sha256=%s", signature)
|
||||
}
|
||||
Reference in New Issue
Block a user