Initial scaffold: certificate control plane v0.1.0

This commit is contained in:
shankar0123
2026-03-14 08:22:17 -04:00
commit d395776a95
57 changed files with 9548 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
package email
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/smtp"
"strings"
"time"
"github.com/shankar0123/certctl/internal/connector/notifier"
)
// Config represents the email notifier configuration.
type Config struct {
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
Username string `json:"username"`
Password string `json:"password"`
FromAddress string `json:"from_address"`
UseTLS bool `json:"tls"`
}
// Connector implements the notifier.Connector interface for email notifications.
// It sends alert and event notifications via SMTP.
type Connector struct {
config *Config
logger *slog.Logger
}
// New creates a new email notifier with the given configuration and logger.
func New(config *Config, logger *slog.Logger) *Connector {
return &Connector{
config: config,
logger: logger,
}
}
// ValidateConfig checks that the SMTP server is reachable and credentials are valid.
// It attempts to connect to the SMTP server to verify connectivity.
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 email config: %w", err)
}
if cfg.SMTPHost == "" || cfg.SMTPPort == 0 || cfg.FromAddress == "" {
return fmt.Errorf("email smtp_host, smtp_port, and from_address are required")
}
c.logger.Info("validating email configuration",
"smtp_host", cfg.SMTPHost,
"smtp_port", cfg.SMTPPort)
// Test SMTP connectivity with timeout
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to reach SMTP server %s: %w", addr, err)
}
defer conn.Close()
c.config = &cfg
c.logger.Info("email configuration validated")
return nil
}
// SendAlert sends an alert notification via SMTP.
// It formats the alert as an email message and sends it to the recipient.
func (c *Connector) SendAlert(ctx context.Context, alert notifier.Alert) error {
c.logger.Info("sending email alert",
"alert_id", alert.ID,
"severity", alert.Severity,
"recipient", alert.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[%s] %s", strings.ToUpper(alert.Severity), alert.Subject)
body := c.formatAlertBody(alert)
// Send email
if err := c.sendEmail(ctx, alert.Recipient, subject, body); err != nil {
c.logger.Error("failed to send alert email",
"alert_id", alert.ID,
"error", err)
return fmt.Errorf("failed to send alert email: %w", err)
}
c.logger.Info("alert email sent successfully",
"alert_id", alert.ID,
"recipient", alert.Recipient)
return nil
}
// SendEvent sends an event notification via SMTP.
// It formats the event as an email message and sends it to the recipient.
func (c *Connector) SendEvent(ctx context.Context, event notifier.Event) error {
c.logger.Info("sending email event",
"event_id", event.ID,
"event_type", event.Type,
"recipient", event.Recipient)
// Format email subject and body
subject := fmt.Sprintf("[Event] %s", event.Subject)
body := c.formatEventBody(event)
// Send email
if err := c.sendEmail(ctx, event.Recipient, subject, body); err != nil {
c.logger.Error("failed to send event email",
"event_id", event.ID,
"error", err)
return fmt.Errorf("failed to send event email: %w", err)
}
c.logger.Info("event email sent successfully",
"event_id", event.ID,
"recipient", event.Recipient)
return nil
}
// sendEmail sends an email message using the configured SMTP server.
// It handles both TLS and plain authentication modes.
func (c *Connector) sendEmail(ctx context.Context, to, subject, body string) error {
addr := fmt.Sprintf("%s:%d", c.config.SMTPHost, c.config.SMTPPort)
// Connect to SMTP server
var auth smtp.Auth
if c.config.Username != "" && c.config.Password != "" {
auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.SMTPHost)
}
var conn net.Conn
var err error
if c.config.UseTLS {
// Connect with TLS
tlsConfig := &tls.Config{
ServerName: c.config.SMTPHost,
}
conn, err = tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
} else {
// Connect without TLS
conn, err = net.Dial("tcp", addr)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, c.config.SMTPHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Authenticate if credentials provided
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
}
// Send email
if err := client.Mail(c.config.FromAddress); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
defer wc.Close()
// Format and write email headers and body
message := c.formatEmailMessage(c.config.FromAddress, to, subject, body)
if _, err := wc.Write(message); err != nil {
return fmt.Errorf("failed to write message: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit SMTP: %w", err)
}
return nil
}
// formatEmailMessage formats an email message with standard headers.
func (c *Connector) formatEmailMessage(from, to, subject, body string) []byte {
message := fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s",
from,
to,
subject,
time.Now().Format(time.RFC1123Z),
body,
)
return []byte(message)
}
// formatAlertBody formats an alert notification as email body text.
func (c *Connector) formatAlertBody(alert notifier.Alert) string {
body := fmt.Sprintf(`
Certificate Alert Notification
================================
Alert ID: %s
Type: %s
Severity: %s
Created: %s
Subject: %s
Message:
%s
%s
`, alert.ID, alert.Type, alert.Severity, alert.CreatedAt.Format(time.RFC3339), alert.Subject, alert.Message, c.formatMetadata(alert.Metadata))
return body
}
// formatEventBody formats an event notification as email body text.
func (c *Connector) formatEventBody(event notifier.Event) string {
certInfo := ""
if event.CertificateID != nil {
certInfo = fmt.Sprintf("Certificate ID: %s\n", *event.CertificateID)
}
body := fmt.Sprintf(`
Certificate Event Notification
================================
Event ID: %s
Type: %s
Created: %s
%sSubject: %s
Body:
%s
%s
`, event.ID, event.Type, event.CreatedAt.Format(time.RFC3339), certInfo, event.Subject, event.Body, c.formatMetadata(event.Metadata))
return body
}
// formatMetadata formats metadata as a readable string.
func (c *Connector) formatMetadata(metadata map[string]string) string {
if len(metadata) == 0 {
return ""
}
metadataStr := "\nMetadata:\n"
for key, value := range metadata {
metadataStr += fmt.Sprintf(" %s: %s\n", key, value)
}
return metadataStr
}
+43
View File
@@ -0,0 +1,43 @@
package notifier
import (
"context"
"encoding/json"
"time"
)
// Connector defines the interface for sending notifications about certificate events.
type Connector interface {
// ValidateConfig validates the notifier configuration.
ValidateConfig(ctx context.Context, config json.RawMessage) error
// SendAlert sends an alert notification.
SendAlert(ctx context.Context, alert Alert) error
// SendEvent sends an event notification.
SendEvent(ctx context.Context, event Event) error
}
// Alert represents a notification alert with urgency.
type Alert struct {
ID string `json:"id"`
Type string `json:"type"`
Severity string `json:"severity"`
Subject string `json:"subject"`
Message string `json:"message"`
Recipient string `json:"recipient"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Event represents a notification event with contextual information.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
CertificateID *string `json:"certificate_id,omitempty"`
Recipient string `json:"recipient"`
Subject string `json:"subject"`
Body string `json:"body"`
Metadata map[string]string `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -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)
}