mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 08:29:06 +00:00
Initial scaffold: certificate control plane v0.1.0
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/issuer"
|
||||
)
|
||||
|
||||
// Config represents the ACME issuer connector configuration.
|
||||
type Config struct {
|
||||
DirectoryURL string `json:"directory_url"`
|
||||
Email string `json:"email"`
|
||||
EABKid string `json:"eab_kid,omitempty"`
|
||||
EABHmac string `json:"eab_hmac,omitempty"`
|
||||
}
|
||||
|
||||
// Connector implements the issuer.Connector interface for ACME-compatible CAs.
|
||||
// This is a stub implementation that demonstrates the structure; actual ACME protocol
|
||||
// implementation will use a proper ACME library (e.g., golang.org/x/crypto/acme).
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New creates a new ACME connector 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 ACME directory URL is reachable and valid.
|
||||
// It performs a HEAD request to the directory URL 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 ACME config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.DirectoryURL == "" {
|
||||
return fmt.Errorf("ACME directory_url is required")
|
||||
}
|
||||
|
||||
if cfg.Email == "" {
|
||||
return fmt.Errorf("ACME email is required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating ACME configuration", "directory_url", cfg.DirectoryURL)
|
||||
|
||||
// Verify that the directory URL is reachable
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, cfg.DirectoryURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reach ACME directory: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("ACME directory returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("ACME configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// IssueCertificate submits a certificate issuance request to the ACME CA.
|
||||
//
|
||||
// The flow for ACME is:
|
||||
// 1. Create a new order with the CA, specifying the identifiers (SANs + CN)
|
||||
// 2. The CA returns authorization challenges (DNS, HTTP, etc.)
|
||||
// 3. Solve the challenges (stub: in production, the agent or external solver handles this)
|
||||
// 4. Finalize the order by submitting the CSR
|
||||
// 5. Download the issued certificate and chain
|
||||
//
|
||||
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
|
||||
// This stub documents the expected flow but doesn't execute it.
|
||||
func (c *Connector) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing ACME issuance request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// TODO: Implement ACME order creation.
|
||||
// For now, return a stub response to demonstrate the interface.
|
||||
// In production:
|
||||
// 1. Connect to the ACME directory
|
||||
// 2. Create a new order with identifiers from CommonName and SANs
|
||||
// 3. Get authorization challenges
|
||||
// 4. Wait for challenge completion (agent/solver will handle)
|
||||
// 5. Submit CSR to finalize order
|
||||
// 6. Retrieve issued certificate and chain
|
||||
|
||||
c.logger.Warn("ACME issuance not yet implemented", "common_name", request.CommonName)
|
||||
|
||||
// Stub: Return a placeholder result
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub)\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
|
||||
Serial: "stub-serial-123456",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(0, 0, 90),
|
||||
OrderID: "stub-order-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewCertificate renews an existing certificate by submitting a new ACME order.
|
||||
// The process is identical to IssueCertificate but uses the existing CSR from the previous certificate.
|
||||
//
|
||||
// TODO: Implement actual ACME protocol using golang.org/x/crypto/acme.
|
||||
func (c *Connector) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) {
|
||||
c.logger.Info("processing ACME renewal request",
|
||||
"common_name", request.CommonName,
|
||||
"san_count", len(request.SANs))
|
||||
|
||||
// TODO: Implement ACME renewal.
|
||||
// In production:
|
||||
// 1. Create a new order with the same identifiers
|
||||
// 2. Obtain and solve authorization challenges
|
||||
// 3. Submit the CSR (from request.CSRPEM)
|
||||
// 4. Retrieve the issued certificate and chain
|
||||
|
||||
c.logger.Warn("ACME renewal not yet implemented", "common_name", request.CommonName)
|
||||
|
||||
// Stub: Return a placeholder result
|
||||
return &issuer.IssuanceResult{
|
||||
CertPEM: "-----BEGIN CERTIFICATE-----\n(stub renewed)\n-----END CERTIFICATE-----\n",
|
||||
ChainPEM: "-----BEGIN CERTIFICATE-----\n(stub chain)\n-----END CERTIFICATE-----\n",
|
||||
Serial: "stub-serial-renewal-123456",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().AddDate(0, 0, 90),
|
||||
OrderID: "stub-order-renewal-id",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeCertificate revokes a certificate at the ACME CA.
|
||||
// The CA will no longer consider the certificate valid.
|
||||
//
|
||||
// TODO: Implement revocation via ACME protocol.
|
||||
func (c *Connector) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error {
|
||||
c.logger.Info("processing ACME revocation request", "serial", request.Serial)
|
||||
|
||||
// TODO: Implement ACME revocation.
|
||||
// In production:
|
||||
// 1. Retrieve the certificate PEM
|
||||
// 2. Post revocation request to CA's revocation endpoint
|
||||
// 3. Provide reason if given
|
||||
|
||||
c.logger.Warn("ACME revocation not yet implemented", "serial", request.Serial)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus retrieves the current status of an ACME order.
|
||||
// This is useful for polling the status of pending issuance or renewal orders.
|
||||
//
|
||||
// TODO: Implement order status polling.
|
||||
func (c *Connector) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) {
|
||||
c.logger.Info("fetching ACME order status", "order_id", orderID)
|
||||
|
||||
// TODO: Implement ACME order status polling.
|
||||
// In production:
|
||||
// 1. Connect to the ACME directory
|
||||
// 2. Fetch order status by orderID
|
||||
// 3. Return current status, message, and any issued certificate material
|
||||
|
||||
c.logger.Warn("ACME order status polling not yet implemented", "order_id", orderID)
|
||||
|
||||
// Stub: Return a placeholder status
|
||||
return &issuer.OrderStatus{
|
||||
OrderID: orderID,
|
||||
Status: "processing",
|
||||
Message: nil,
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Connector defines the interface for certificate issuance operations.
|
||||
type Connector interface {
|
||||
// ValidateConfig validates the issuer configuration.
|
||||
ValidateConfig(ctx context.Context, config json.RawMessage) error
|
||||
|
||||
// IssueCertificate issues a new certificate.
|
||||
IssueCertificate(ctx context.Context, request IssuanceRequest) (*IssuanceResult, error)
|
||||
|
||||
// RenewCertificate renews an existing certificate.
|
||||
RenewCertificate(ctx context.Context, request RenewalRequest) (*IssuanceResult, error)
|
||||
|
||||
// RevokeCertificate revokes a certificate.
|
||||
RevokeCertificate(ctx context.Context, request RevocationRequest) error
|
||||
|
||||
// GetOrderStatus retrieves the status of an issuance or renewal order.
|
||||
GetOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
|
||||
}
|
||||
|
||||
// IssuanceRequest contains the parameters for issuing a new certificate.
|
||||
type IssuanceRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
}
|
||||
|
||||
// IssuanceResult contains the result of a successful certificate issuance.
|
||||
type IssuanceResult struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
ChainPEM string `json:"chain_pem"`
|
||||
Serial string `json:"serial"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
OrderID string `json:"order_id"`
|
||||
}
|
||||
|
||||
// RenewalRequest contains the parameters for renewing a certificate.
|
||||
type RenewalRequest struct {
|
||||
CommonName string `json:"common_name"`
|
||||
SANs []string `json:"sans"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
OrderID *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
// RevocationRequest contains the parameters for revoking a certificate.
|
||||
type RevocationRequest struct {
|
||||
Serial string `json:"serial"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// OrderStatus contains the status of a pending issuance or renewal order.
|
||||
type OrderStatus struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
CertPEM *string `json:"cert_pem,omitempty"`
|
||||
ChainPEM *string `json:"chain_pem,omitempty"`
|
||||
Serial *string `json:"serial,omitempty"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package f5
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the F5 BIG-IP deployment target configuration.
|
||||
type Config struct {
|
||||
Host string `json:"host"` // F5 BIG-IP hostname or IP
|
||||
Port int `json:"port"` // F5 iControl REST API port (default 443)
|
||||
Username string `json:"username"` // Administrative username
|
||||
Password string `json:"password"` // Administrative password
|
||||
Partition string `json:"partition"` // F5 partition name (e.g., "Common")
|
||||
SSLProfile string `json:"ssl_profile"` // SSL profile name to update
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for F5 BIG-IP load balancers.
|
||||
// This connector communicates with F5's iControl REST API to upload certificates and manage SSL profiles.
|
||||
//
|
||||
// TODO: Implement actual F5 iControl REST API communication.
|
||||
// The documented API endpoints and flow are:
|
||||
// - Authentication: POST /mgmt/shared/authn/login
|
||||
// - Upload certificate: POST /mgmt/tm/ltm/certificate
|
||||
// - Update SSL profile: PATCH /mgmt/tm/ltm/profile/client-ssl/{profile_name}
|
||||
// - Check SSL profile: GET /mgmt/tm/ltm/profile/client-ssl/{profile_name}
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// New creates a new F5 target connector 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,
|
||||
// TODO: Configure proper TLS verification or skip for self-signed F5 certs
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the F5 BIG-IP is reachable and credentials are valid.
|
||||
// It attempts to authenticate to the F5 iControl REST API.
|
||||
//
|
||||
// TODO: Implement actual F5 authentication validation.
|
||||
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 F5 config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Host == "" || cfg.Username == "" || cfg.Password == "" {
|
||||
return fmt.Errorf("F5 host, username, and password are required")
|
||||
}
|
||||
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 443 // Default HTTPS port
|
||||
}
|
||||
|
||||
if cfg.Partition == "" {
|
||||
cfg.Partition = "Common"
|
||||
}
|
||||
|
||||
c.logger.Info("validating F5 configuration",
|
||||
"host", cfg.Host,
|
||||
"port", cfg.Port,
|
||||
"partition", cfg.Partition)
|
||||
|
||||
// TODO: Implement F5 authentication check
|
||||
// In production:
|
||||
// 1. POST to https://{host}:{port}/mgmt/shared/authn/login
|
||||
// 2. Send credentials in request body
|
||||
// 3. Verify response contains valid authentication token
|
||||
// 4. Optionally test connectivity to SSL profile endpoint
|
||||
|
||||
c.logger.Warn("F5 validation not yet fully implemented",
|
||||
"host", cfg.Host)
|
||||
|
||||
c.config = &cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate uploads a certificate to the F5 BIG-IP and updates the specified SSL profile.
|
||||
//
|
||||
// The F5 deployment process:
|
||||
// 1. Authenticate to iControl REST API using credentials
|
||||
// 2. Upload certificate PEM to /mgmt/tm/ltm/certificate
|
||||
// 3. Upload chain PEM as separate certificate if needed
|
||||
// 4. Update the target SSL profile to reference the new certificate
|
||||
// 5. Verify the profile was updated successfully
|
||||
//
|
||||
// TODO: Implement actual F5 iControl REST API calls.
|
||||
// API endpoints used:
|
||||
// - POST /mgmt/shared/authn/login (authentication)
|
||||
// - POST /mgmt/tm/ltm/certificate (upload cert)
|
||||
// - PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile} (update profile)
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to F5 BIG-IP",
|
||||
"host", c.config.Host,
|
||||
"partition", c.config.Partition,
|
||||
"ssl_profile", c.config.SSLProfile)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement F5 certificate deployment
|
||||
// In production:
|
||||
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
|
||||
// 2. Create certificate object:
|
||||
// POST /mgmt/tm/ltm/certificate
|
||||
// Body: {"name": "certctl-cert-{timestamp}", "certificateText": "{CertPEM}"}
|
||||
// 3. If chain is provided, upload as separate certificate:
|
||||
// POST /mgmt/tm/ltm/certificate
|
||||
// Body: {"name": "certctl-chain-{timestamp}", "certificateText": "{ChainPEM}"}
|
||||
// 4. Update SSL profile:
|
||||
// PATCH /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
||||
// Body: {"certificate": "/Common/certctl-cert-{timestamp}"}
|
||||
// 5. Verify deployment by checking profile status
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("F5 deployment not yet implemented",
|
||||
"host", c.config.Host,
|
||||
"ssl_profile", c.config.SSLProfile)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||
DeploymentID: fmt.Sprintf("f5-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployment to F5 initiated (stub)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"host": c.config.Host,
|
||||
"partition": c.config.Partition,
|
||||
"ssl_profile": c.config.SSLProfile,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the certificate is properly deployed on the F5 BIG-IP.
|
||||
// It checks the SSL profile configuration to ensure it references the correct certificate.
|
||||
//
|
||||
// TODO: Implement actual F5 validation via iControl REST API.
|
||||
// API endpoint used:
|
||||
// - GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating F5 deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial,
|
||||
"ssl_profile", c.config.SSLProfile)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement F5 deployment validation
|
||||
// In production:
|
||||
// 1. Authenticate to F5: POST /mgmt/shared/authn/login
|
||||
// 2. Query SSL profile:
|
||||
// GET /mgmt/tm/ltm/profile/client-ssl/{SSLProfile}
|
||||
// 3. Verify the response includes the expected certificate name
|
||||
// 4. Optionally check certificate validity dates
|
||||
// 5. Verify the profile is in active use (no errors/warnings)
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("F5 validation not yet implemented",
|
||||
"ssl_profile", c.config.SSLProfile)
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s:%d", c.config.Host, c.config.Port),
|
||||
Message: "Certificate deployment validation initiated (stub)",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"host": c.config.Host,
|
||||
"ssl_profile": c.config.SSLProfile,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the IIS deployment target configuration.
|
||||
// This configuration is for Windows agents that manage IIS servers.
|
||||
type Config struct {
|
||||
Hostname string `json:"hostname"` // Target hostname or IP
|
||||
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
|
||||
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
|
||||
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for IIS (Internet Information Services).
|
||||
// This connector runs on Windows agents and manages certificate deployment via IIS.
|
||||
//
|
||||
// IIS certificate management requires:
|
||||
// - Windows Server with IIS installed
|
||||
// - PowerShell execution available
|
||||
// - Administrative privileges
|
||||
//
|
||||
// TODO: Implement actual PowerShell command execution for:
|
||||
// - Certificate import: Import-PfxCertificate
|
||||
// - IIS binding update: New-WebBinding, Set-WebBinding
|
||||
// - Validation: Get-WebBinding
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new IIS target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that the IIS configuration is valid and accessible.
|
||||
// It verifies that we're on Windows and that the IIS site exists.
|
||||
//
|
||||
// TODO: Implement actual PowerShell checks.
|
||||
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 IIS config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.SiteName == "" || cfg.CertStore == "" {
|
||||
return fmt.Errorf("IIS site_name and cert_store are required")
|
||||
}
|
||||
|
||||
// Verify we're on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
c.logger.Info("validating IIS configuration",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore,
|
||||
"hostname", cfg.Hostname)
|
||||
|
||||
// TODO: Implement PowerShell check
|
||||
// In production:
|
||||
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
|
||||
// 2. Verify site exists and is running
|
||||
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
|
||||
|
||||
c.logger.Warn("IIS validation not yet fully implemented",
|
||||
"site_name", cfg.SiteName)
|
||||
|
||||
c.config = &cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate to the Windows certificate store and updates
|
||||
// the IIS binding to use the new certificate.
|
||||
//
|
||||
// The IIS deployment process (via PowerShell):
|
||||
// 1. Create a temporary PFX file from the certificate and existing private key
|
||||
// (Note: The private key is managed by the agent, not provided by the control plane)
|
||||
// 2. Import the PFX to the Windows certificate store (My store by default)
|
||||
// 3. Get the certificate thumbprint
|
||||
// 4. Update the IIS binding to use the new certificate by thumbprint
|
||||
// 5. Verify the binding is active
|
||||
//
|
||||
// TODO: Implement actual PowerShell commands:
|
||||
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
|
||||
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
|
||||
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to IIS",
|
||||
"site_name", c.config.SiteName,
|
||||
"cert_store", c.config.CertStore)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS certificate deployment
|
||||
// In production:
|
||||
// 1. Create temporary PFX from CertPEM and ChainPEM
|
||||
// (Private key should already exist on the agent)
|
||||
// 2. Import certificate:
|
||||
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
|
||||
// 3. Get certificate thumbprint:
|
||||
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
|
||||
// 4. Update IIS binding:
|
||||
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
|
||||
// 5. Remove temporary PFX file
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS deployment not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployment to IIS initiated (stub)",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"cert_store": c.config.CertStore,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
|
||||
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
|
||||
//
|
||||
// TODO: Implement actual PowerShell validation.
|
||||
// PowerShell command:
|
||||
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating IIS deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial,
|
||||
"site_name", c.config.SiteName)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS deployment validation
|
||||
// In production:
|
||||
// 1. Query IIS binding status:
|
||||
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
|
||||
// 2. Verify binding exists and is active
|
||||
// 3. Extract certificate thumbprint from binding
|
||||
// 4. Query certificate store to verify thumbprint matches expected certificate
|
||||
// 5. Check certificate validity dates and key match
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS validation not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: "Certificate deployment validation initiated (stub)",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// executePowerShellCommand is a helper to run PowerShell commands on Windows.
|
||||
// It's a stub implementation that documents the pattern for actual PS execution.
|
||||
func (c *Connector) executePowerShellCommand(ctx context.Context, psCommand string) (string, error) {
|
||||
if runtime.GOOS != "windows" {
|
||||
return "", fmt.Errorf("PowerShell commands only work on Windows")
|
||||
}
|
||||
|
||||
// TODO: Implement actual PowerShell execution
|
||||
// In production:
|
||||
// cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
|
||||
// output, err := cmd.CombinedOutput()
|
||||
// return string(output), err
|
||||
|
||||
c.logger.Debug("executing PowerShell command", "command", psCommand)
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package target
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Connector defines the interface for certificate deployment operations.
|
||||
type Connector interface {
|
||||
// ValidateConfig validates the deployment target configuration.
|
||||
ValidateConfig(ctx context.Context, config json.RawMessage) error
|
||||
|
||||
// DeployCertificate deploys a certificate to the target.
|
||||
// The request contains the certificate and chain in PEM format, but never a private key.
|
||||
DeployCertificate(ctx context.Context, request DeploymentRequest) (*DeploymentResult, error)
|
||||
|
||||
// ValidateDeployment verifies that a deployed certificate is valid and accessible.
|
||||
ValidateDeployment(ctx context.Context, request ValidationRequest) (*ValidationResult, error)
|
||||
}
|
||||
|
||||
// DeploymentRequest contains the parameters for deploying a certificate to a target.
|
||||
// Note: This request NEVER contains a private key. The agent generates keys locally.
|
||||
type DeploymentRequest struct {
|
||||
CertPEM string `json:"cert_pem"`
|
||||
ChainPEM string `json:"chain_pem"`
|
||||
TargetConfig json.RawMessage `json:"target_config"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DeploymentResult contains the result of a successful certificate deployment.
|
||||
type DeploymentResult struct {
|
||||
Success bool `json:"success"`
|
||||
TargetAddress string `json:"target_address"`
|
||||
DeploymentID string `json:"deployment_id"`
|
||||
Message string `json:"message"`
|
||||
DeployedAt time.Time `json:"deployed_at"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationRequest contains the parameters for validating a deployed certificate.
|
||||
type ValidationRequest struct {
|
||||
CertificateID string `json:"certificate_id"`
|
||||
Serial string `json:"serial"`
|
||||
TargetConfig json.RawMessage `json:"target_config"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationResult contains the result of a certificate validation check.
|
||||
type ValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Serial string `json:"serial"`
|
||||
TargetAddress string `json:"target_address"`
|
||||
Message string `json:"message"`
|
||||
ValidatedAt time.Time `json:"validated_at"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package nginx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// Config represents the NGINX deployment target configuration.
|
||||
// This configuration is used on the agent side to deploy certificates to NGINX.
|
||||
type Config struct {
|
||||
CertPath string `json:"cert_path"` // Path where cert will be written (typically /etc/nginx/certs/cert.pem)
|
||||
KeyPath string `json:"key_path"` // Path where private key will be written (NOT provided by control plane)
|
||||
ChainPath string `json:"chain_path"` // Path where chain will be written (typically /etc/nginx/certs/chain.pem)
|
||||
ReloadCommand string `json:"reload_command"` // Command to reload NGINX (e.g., "nginx -s reload" or "systemctl reload nginx")
|
||||
ValidateCommand string `json:"validate_command"` // Command to validate NGINX config (e.g., "nginx -t")
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for NGINX servers.
|
||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New creates a new NGINX target connector with the given configuration and logger.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig checks that all required configuration paths and commands are valid.
|
||||
// It verifies that the certificate and key paths are writable and commands are executable.
|
||||
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 NGINX config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
||||
return fmt.Errorf("NGINX cert_path and chain_path are required")
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
||||
return fmt.Errorf("NGINX reload_command and validate_command are required")
|
||||
}
|
||||
|
||||
c.logger.Info("validating NGINX configuration",
|
||||
"cert_path", cfg.CertPath,
|
||||
"chain_path", cfg.ChainPath)
|
||||
|
||||
// Verify directory exists and is writable
|
||||
certDir := cfg.CertPath[:len(cfg.CertPath)-len("/cert.pem")] // Simple path extraction
|
||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("NGINX cert directory does not exist: %s", certDir)
|
||||
}
|
||||
|
||||
// Verify validate command works
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cfg.ValidateCommand)
|
||||
if err := cmd.Run(); err != nil {
|
||||
c.logger.Warn("NGINX config validation failed during config check",
|
||||
"error", err,
|
||||
"validate_command", cfg.ValidateCommand)
|
||||
// Don't fail validation; NGINX might not be installed yet
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("NGINX configuration validated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate writes the certificate and chain to the configured paths
|
||||
// and reloads NGINX to pick up the new certificates.
|
||||
// The agent (not the control plane) manages the private key.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Write certificate to cert_path with mode 0644 (readable by all)
|
||||
// 2. Write chain to chain_path with mode 0644
|
||||
// 3. Validate NGINX configuration
|
||||
// 4. Execute reload command
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to NGINX",
|
||||
"cert_path", c.config.CertPath,
|
||||
"chain_path", c.config.ChainPath)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Write certificate with secure permissions (0644: rw-r--r--)
|
||||
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
||||
c.logger.Error("certificate deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Write chain with same permissions
|
||||
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
||||
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
||||
c.logger.Error("chain deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.ChainPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Validate NGINX configuration before reload
|
||||
c.logger.Debug("validating NGINX configuration", "validate_command", c.config.ValidateCommand)
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if err := validateCmd.Run(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
|
||||
c.logger.Error("NGINX validation failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Reload NGINX
|
||||
c.logger.Debug("reloading NGINX", "reload_command", c.config.ReloadCommand)
|
||||
reloadCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if err := reloadCmd.Run(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX reload failed: %v", err)
|
||||
c.logger.Error("NGINX reload failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
c.logger.Info("certificate deployed to NGINX successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"cert_path", c.config.CertPath)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.CertPath,
|
||||
DeploymentID: fmt.Sprintf("nginx-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployed and NGINX reloaded successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"cert_path": c.config.CertPath,
|
||||
"chain_path": c.config.ChainPath,
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
||||
// It validates the NGINX configuration to ensure the certificate can be read.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Run validate command to check config syntax
|
||||
// 2. Verify certificate file is readable
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating NGINX deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
"serial", request.Serial)
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Validate NGINX configuration
|
||||
validateCmd := exec.CommandContext(ctx, "sh", "-c", c.config.ValidateCommand)
|
||||
if err := validateCmd.Run(); err != nil {
|
||||
errMsg := fmt.Sprintf("NGINX config validation failed: %v", err)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
// Verify certificate file exists and is readable
|
||||
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
||||
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
validationDuration := time.Since(startTime)
|
||||
c.logger.Info("NGINX deployment validated successfully",
|
||||
"duration", validationDuration.String())
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.CertPath,
|
||||
Message: "NGINX configuration valid and certificate accessible",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"validate_command": c.config.ValidateCommand,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user