Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

443 lines
15 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package caddy
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"github.com/certctl-io/certctl/internal/connector/target"
"github.com/certctl-io/certctl/internal/connector/target/certutil"
"github.com/certctl-io/certctl/internal/deploy"
)
// Config represents the Caddy deployment target configuration.
// Caddy supports both API-based and file-based certificate deployment.
// In API mode, certificates are posted to the Caddy admin API.
// In file mode, certificates are written to a directory and Caddy reloads.
type Config struct {
AdminAPI string `json:"admin_api"` // Caddy admin API URL (e.g., http://localhost:2019, default: http://localhost:2019)
CertDir string `json:"cert_dir"` // Directory for file-based deployment (used if API fails or mode=file)
CertFile string `json:"cert_file"` // Filename for certificate in file mode (default: cert.pem)
KeyFile string `json:"key_file"` // Filename for private key in file mode (default: key.pem)
Mode string `json:"mode"` // Deployment mode: "api" (default) or "file"
}
// Connector implements the target.Connector interface for Caddy servers.
// This connector runs on the AGENT side and handles local certificate deployment.
// It supports both API-based hot reload and file-based deployment.
type Connector struct {
config *Config
logger *slog.Logger
client *http.Client
}
// New creates a new Caddy 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: 10 * time.Second},
}
}
// ValidateConfig checks that the Caddy configuration is valid.
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 Caddy config: %w", err)
}
// Set defaults
if cfg.AdminAPI == "" {
cfg.AdminAPI = "http://localhost:2019"
}
if cfg.Mode == "" {
cfg.Mode = "api"
}
if cfg.CertFile == "" {
cfg.CertFile = "cert.pem"
}
if cfg.KeyFile == "" {
cfg.KeyFile = "key.pem"
}
// Validate mode
if cfg.Mode != "api" && cfg.Mode != "file" {
return fmt.Errorf("Caddy mode must be 'api' or 'file', got: %s", cfg.Mode)
}
c.logger.Info("validating Caddy configuration",
"admin_api", cfg.AdminAPI,
"mode", cfg.Mode)
// For file mode, verify directory exists
if cfg.Mode == "file" {
if cfg.CertDir == "" {
return fmt.Errorf("Caddy cert_dir is required in file mode")
}
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
return fmt.Errorf("Caddy cert directory does not exist: %s", cfg.CertDir)
}
// Test write access
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
return fmt.Errorf("Caddy cert directory is not writable: %s (%w)", cfg.CertDir, err)
}
os.Remove(testFile)
}
c.config = &cfg
c.logger.Info("Caddy configuration validated")
return nil
}
// DeployCertificate deploys a certificate to Caddy using the configured mode.
// In API mode, it posts the certificate to Caddy's admin API.
// In file mode, it writes the certificate files and relies on Caddy's file watcher.
//
// Steps:
// 1. If mode="api": POST to Caddy admin API endpoint with certificate data
// 2. If mode="file" or API fails: Write certificate and key files to cert_dir
// 3. Log deployment status
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
c.logger.Info("deploying certificate to Caddy",
"mode", c.config.Mode,
"admin_api", c.config.AdminAPI)
startTime := time.Now()
// Try API mode if configured
if c.config.Mode == "api" {
result, err := c.deployViaAPI(ctx, request, startTime)
if err == nil {
c.logger.Info("certificate deployed to Caddy via API",
"duration", time.Since(startTime).String())
return result, nil
}
c.logger.Warn("API deployment failed, falling back to file mode", "error", err)
}
// Fall back to file mode
return c.deployViaFile(ctx, request, startTime)
}
// deployViaAPI deploys a certificate using Caddy's admin API.
//
// Bundle 9 of the 2026-05-02 deployment-target audit:
// - Fix 1: duration_ms metric uses the caller-passed startTime (was
// time.Since(time.Now()) which always rounded to ~0ms).
// - Fix 3: SHA-256 idempotency short-circuit — GET the current loaded
// certs and compare to the deploy payload's cert bytes; skip the POST
// when they match. Conservative: any GET failure (network, non-200,
// parse error, hash mismatch) falls through to today's POST behavior.
func (c *Connector) deployViaAPI(ctx context.Context, request target.DeploymentRequest, startTime time.Time) (*target.DeploymentResult, error) {
c.logger.Debug("attempting API deployment", "url", c.config.AdminAPI)
// Build the certificate payload with combined cert and chain
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
apiURL := c.config.AdminAPI + "/config/apps/tls/certificates/load"
// Bundle 9 Fix 3: idempotency short-circuit. GET the current loaded
// certs and SHA-256 compare to the deploy payload — if they match,
// skip the POST and return success with metadata.idempotent=true.
// Caddy's response shape varies between operators / config versions;
// handle BOTH the array-of-objects and single-object shapes. Any
// failure during the GET (network, non-200, JSON parse error, hash
// mismatch) silently falls through to the POST below — false
// negatives are safe; false positives are dangerous.
deployHash := sha256.Sum256([]byte(certData))
if c.idempotencySkipPOST(ctx, apiURL, deployHash) {
c.logger.Debug("Caddy already has this certificate loaded; skipping POST",
"admin_url", c.config.AdminAPI)
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.AdminAPI,
DeploymentID: fmt.Sprintf("caddy-api-idem-%d", time.Now().Unix()),
Message: "Caddy already has this certificate loaded (idempotent skip)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"method": "api",
"admin_url": c.config.AdminAPI,
"idempotent": "true",
"duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
},
}, nil
}
payload := map[string]string{
"cert": certData,
"key": request.KeyPEM,
}
bodyBytes, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("failed to create API request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to reach Caddy API: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return nil, fmt.Errorf("Caddy API returned status %d: %s", resp.StatusCode, string(body))
}
return &target.DeploymentResult{
Success: true,
TargetAddress: c.config.AdminAPI,
DeploymentID: fmt.Sprintf("caddy-api-%d", time.Now().Unix()),
Message: "Certificate deployed via Caddy admin API",
DeployedAt: time.Now(),
Metadata: map[string]string{
"method": "api",
"admin_url": c.config.AdminAPI,
"duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
},
}, nil
}
// idempotencySkipPOST returns true iff GET-ing the Caddy admin API at the
// same path returns a cert whose SHA-256 matches the deploy payload's
// SHA-256. The function is best-effort: it returns false (= "fall through
// to the POST") on any error along the way (network, non-200, JSON parse,
// no matching entry, hash mismatch).
//
// Caddy's response shape can be either:
// - a single object: {"cert": "...", "key": "..."}
// - an array of objects: [{"cert": "...", "key": "..."}, ...]
//
// We try both shapes; the first that decodes AND has a matching hash wins.
//
// Bundle 9 Fix 3 of the 2026-05-02 deployment-target audit.
func (c *Connector) idempotencySkipPOST(ctx context.Context, apiURL string, deployHash [32]byte) bool {
getReq, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return false
}
getReq.Header.Set("Accept", "application/json")
resp, err := c.client.Do(getReq)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return false
}
// Try array-of-objects shape first.
var asArray []map[string]string
if err := json.Unmarshal(body, &asArray); err == nil {
for _, entry := range asArray {
if entry["cert"] == "" {
continue
}
if sha256.Sum256([]byte(entry["cert"])) == deployHash {
return true
}
}
return false
}
// Fall back to single-object shape.
var asObject map[string]string
if err := json.Unmarshal(body, &asObject); err == nil {
if asObject["cert"] != "" && sha256.Sum256([]byte(asObject["cert"])) == deployHash {
return true
}
}
return false
}
// deployViaFile deploys a certificate by writing files to the cert directory.
func (c *Connector) deployViaFile(ctx context.Context, request target.DeploymentRequest, startTime time.Time) (*target.DeploymentResult, error) {
c.logger.Debug("deploying via file mode", "cert_dir", c.config.CertDir)
if c.config.CertDir == "" {
return &target.DeploymentResult{
Success: false,
Message: "cert_dir required for file mode deployment",
DeployedAt: time.Now(),
}, fmt.Errorf("cert_dir not configured for file mode")
}
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
// Write certificate with chain — Phase 7 (deploy-hardening I):
// atomic-write via deploy.AtomicWriteFile so cert/key swap
// atomically and have backup files for rollback (Caddy's file
// watcher picks up the rename atomically, no torn config).
certData := request.CertPEM + "\n"
if request.ChainPEM != "" {
certData += request.ChainPEM + "\n"
}
if _, err := deploy.AtomicWriteFile(ctx, certPath, []byte(certData), deploy.WriteOptions{
Mode: 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: certPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Write private key — atomic + 0600 default.
if request.KeyPEM != "" {
if _, err := deploy.AtomicWriteFile(ctx, keyPath, []byte(request.KeyPEM), deploy.WriteOptions{
Mode: 0600,
}); err != nil {
errMsg := fmt.Sprintf("failed to write private key: %v", err)
c.logger.Error("key deployment failed", "error", err)
return &target.DeploymentResult{
Success: false,
TargetAddress: keyPath,
Message: errMsg,
DeployedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
deploymentDuration := time.Since(startTime)
c.logger.Info("certificate deployed to Caddy via file mode",
"duration", deploymentDuration.String(),
"cert_path", certPath,
"key_path", keyPath)
return &target.DeploymentResult{
Success: true,
TargetAddress: certPath,
DeploymentID: fmt.Sprintf("caddy-file-%d", time.Now().Unix()),
Message: "Certificate deployed to Caddy (file-based)",
DeployedAt: time.Now(),
Metadata: map[string]string{
"method": "file",
"cert_path": certPath,
"key_path": keyPath,
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
},
}, nil
}
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
// For API mode, it doesn't perform additional validation.
// For file mode, it checks that the certificate and key files exist and are readable.
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
c.logger.Info("validating Caddy deployment",
"certificate_id", request.CertificateID,
"serial", request.Serial,
"mode", c.config.Mode)
startTime := time.Now()
// For file mode, verify files exist AND the cert is valid PEM/x509.
//
// Bundle 9 Fix 2 of the 2026-05-02 deployment-target audit: pre-fix,
// this branch checked file existence only (os.Stat). A cert file that
// existed but contained malformed bytes passed validation; Caddy's
// file-watcher silently failed to load it; operators saw "validation
// green" + "TLS handshake fails" with no obvious connection. Post-fix,
// the cert file is read and parsed via certutil.ParseCertificatePEM so
// only files that Caddy can actually load pass validation.
if c.config.Mode == "file" || c.config.CertDir != "" {
certPath := filepath.Join(c.config.CertDir, c.config.CertFile)
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFile)
if _, err := os.Stat(certPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("certificate file not found: %s", certPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: certPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
errMsg := fmt.Sprintf("private key file not found: %s", keyPath)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: keyPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
// Bundle 9 Fix 2: PEM-parse the cert file. A malformed cert that
// Caddy's watcher would silently reject must fail validation here
// instead of bubbling up as a confusing "TLS handshake fails"
// later.
certBytes, err := os.ReadFile(certPath)
if err != nil {
errMsg := fmt.Sprintf("failed to read certificate file %s: %v", certPath, err)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: certPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
if _, err := certutil.ParseCertificatePEM(string(certBytes)); err != nil {
errMsg := fmt.Sprintf("certificate file %s is not valid PEM/x509: %v", certPath, err)
c.logger.Error("validation failed", "error", err)
return &target.ValidationResult{
Valid: false,
Serial: request.Serial,
TargetAddress: certPath,
Message: errMsg,
ValidatedAt: time.Now(),
}, fmt.Errorf("%s", errMsg)
}
}
validationDuration := time.Since(startTime)
c.logger.Info("Caddy deployment validated successfully",
"duration", validationDuration.String())
return &target.ValidationResult{
Valid: true,
Serial: request.Serial,
TargetAddress: c.config.AdminAPI,
Message: "Caddy certificate deployment validated",
ValidatedAt: time.Now(),
Metadata: map[string]string{
"mode": c.config.Mode,
"admin_api": c.config.AdminAPI,
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
},
}, nil
}