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
+186
View File
@@ -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
}
+69
View File
@@ -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"`
}
+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)
}
+189
View File
@@ -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
}
+196
View File
@@ -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
}
+57
View File
@@ -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"`
}
+222
View File
@@ -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
}