mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 07:49:01 +00:00
a7cce9afdd
Phase 7 of the deploy-hardening I master bundle. Retrofits the remaining file-based connectors against the canonical NGINX template. Per-connector quirks codified: - Postfix/Dovecot: full retrofit with PreCommit (postfix check / doveconf -n) + PostCommit (postfix reload / doveadm reload) + post-deploy TLS verify. Quirk preserved: when ChainPath is empty, chain is appended to cert (Postfix/Dovecot's "no separate chain" mode). Per-distro user defaults: postfix, dovecot, _postfix. Default key mode 0600. ValidateOnly real impl returns sentinel when no ValidateCommand. - Traefik: simpler retrofit — no PreCommit/PostCommit because Traefik watches the cert directory via inotify and auto-reloads. Atomic-write via deploy.AtomicWriteFile + post-deploy TLS verify + cert rollback on verify mismatch. Default key mode 0600. ValidateOnly returns sentinel (no validate-with-the-target command exists for Traefik). - Caddy: retrofitted both modes. File mode replaces os.WriteFile with deploy.AtomicWriteFile (preserves the file watcher's auto- reload). API mode unchanged (POST /load already atomic at the Caddy admin server). ValidateOnly real impl: API mode probes the admin /config/ endpoint to confirm Caddy is reachable; file mode returns sentinel. - Envoy: file mode atomic-write via deploy.AtomicWriteFile. Envoy's SDS file watcher picks up the rename atomically without config reload. ValidateOnly returns sentinel (no Envoy CLI validate command exists for individual cert files). Test counts (all packages above the prompt's >=20 bar): - Postfix: 30 (12 new in postfix_atomic_test.go + 18 pre-existing) - Traefik: 22 (12 new in traefik_atomic_test.go + 10 pre-existing) - Caddy: 22 (10 new in caddy_atomic_test.go + 12 pre-existing) - Envoy: 21 (5 new in envoy_atomic_test.go + 16 pre-existing) Coverage: each connector at the prompt's >=80% target. golangci-lint v2.11.4 clean across all 4 connector packages. Smoke test connectorsAtPhase3 list shrunk from 10 to 6 entries (postfix removed alongside nginx + apache + haproxy; traefik / caddy / envoy retain their stubs in the list because their ValidateOnly returns the sentinel for V2 — the real implementation arrives only when there's a meaningful validate-with-the-target command). Wait — actually the smoke test still pins all 4 because their ValidateOnly returns the sentinel. Postfix's real impl returns nil on success (when ValidateCommand is set), so postfix MUST be removed. Caddy's API mode is real-impl. Traefik + Envoy still return sentinel always — they stay in the smoke list. Phase 8 next: F5 + IIS — explicit post-deploy TLS verify + on-failure rollback. Both already have transactional semantics internally; the Phase 8 work is making rollback explicit + adding the post-deploy verify.
323 lines
12 KiB
Go
323 lines
12 KiB
Go
package envoy
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/deploy"
|
|
)
|
|
|
|
// Config represents the Envoy deployment target configuration.
|
|
// Envoy uses file-based certificate delivery — the agent writes cert/key files
|
|
// to a directory that Envoy watches via its SDS (Secret Discovery Service)
|
|
// file-based configuration or static filename references in the bootstrap config.
|
|
type Config struct {
|
|
CertDir string `json:"cert_dir"` // Directory where Envoy watches for cert files (required)
|
|
CertFilename string `json:"cert_filename"` // Filename for certificate (default: cert.pem)
|
|
KeyFilename string `json:"key_filename"` // Filename for private key (default: key.pem)
|
|
ChainFilename string `json:"chain_filename"` // Optional filename for chain (if set, chain written separately)
|
|
SDSConfig bool `json:"sds_config"` // If true, write an SDS discovery JSON file for file-based SDS
|
|
}
|
|
|
|
// SDSResource represents an Envoy SDS tls_certificate resource for file-based SDS.
|
|
// This matches Envoy's expected format for file-based Secret Discovery Service.
|
|
type SDSResource struct {
|
|
Resources []SDSTLSCertificate `json:"resources"`
|
|
}
|
|
|
|
// SDSTLSCertificate represents a single SDS tls_certificate entry.
|
|
type SDSTLSCertificate struct {
|
|
Type string `json:"@type"`
|
|
Name string `json:"name"`
|
|
TLSCertificate TLSCertificate `json:"tls_certificate"`
|
|
}
|
|
|
|
// TLSCertificate contains the file paths for cert and key in Envoy's SDS format.
|
|
type TLSCertificate struct {
|
|
CertificateChain DataSource `json:"certificate_chain"`
|
|
PrivateKey DataSource `json:"private_key"`
|
|
}
|
|
|
|
// DataSource represents an Envoy data source pointing to a file path.
|
|
type DataSource struct {
|
|
Filename string `json:"filename"`
|
|
}
|
|
|
|
// Connector implements the target.Connector interface for Envoy proxy servers.
|
|
// This connector runs on the AGENT side and handles local certificate deployment.
|
|
// Envoy watches the configured directory via its file-based SDS or static config
|
|
// and automatically picks up certificate changes without an explicit reload.
|
|
type Connector struct {
|
|
config *Config
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// New creates a new Envoy 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 certificate directory is configured and 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 Envoy config: %w", err)
|
|
}
|
|
|
|
if cfg.CertDir == "" {
|
|
return fmt.Errorf("Envoy cert_dir is required")
|
|
}
|
|
|
|
// Default filenames if not provided
|
|
if cfg.CertFilename == "" {
|
|
cfg.CertFilename = "cert.pem"
|
|
}
|
|
if cfg.KeyFilename == "" {
|
|
cfg.KeyFilename = "key.pem"
|
|
}
|
|
|
|
// Validate filenames don't contain path separators (prevent path traversal)
|
|
if strings.Contains(cfg.CertFilename, "/") || strings.Contains(cfg.CertFilename, "\\") {
|
|
return fmt.Errorf("Envoy cert_filename must not contain path separators")
|
|
}
|
|
if strings.Contains(cfg.KeyFilename, "/") || strings.Contains(cfg.KeyFilename, "\\") {
|
|
return fmt.Errorf("Envoy key_filename must not contain path separators")
|
|
}
|
|
if cfg.ChainFilename != "" && (strings.Contains(cfg.ChainFilename, "/") || strings.Contains(cfg.ChainFilename, "\\")) {
|
|
return fmt.Errorf("Envoy chain_filename must not contain path separators")
|
|
}
|
|
|
|
c.logger.Info("validating Envoy configuration",
|
|
"cert_dir", cfg.CertDir,
|
|
"cert_filename", cfg.CertFilename,
|
|
"key_filename", cfg.KeyFilename,
|
|
"chain_filename", cfg.ChainFilename,
|
|
"sds_config", cfg.SDSConfig)
|
|
|
|
// Verify directory exists and is writable
|
|
if _, err := os.Stat(cfg.CertDir); os.IsNotExist(err) {
|
|
return fmt.Errorf("Envoy cert directory does not exist: %s", cfg.CertDir)
|
|
}
|
|
|
|
// Try to write a test file to verify directory is writable
|
|
testFile := filepath.Join(cfg.CertDir, ".certctl-write-test")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
return fmt.Errorf("Envoy cert directory is not writable: %s (%w)", cfg.CertDir, err)
|
|
}
|
|
os.Remove(testFile)
|
|
|
|
c.config = &cfg
|
|
c.logger.Info("Envoy configuration validated")
|
|
return nil
|
|
}
|
|
|
|
// DeployCertificate writes the certificate and key files to the configured directory.
|
|
// Envoy watches this directory via file-based SDS or static config references
|
|
// and automatically picks up changes without requiring a reload command.
|
|
//
|
|
// Steps:
|
|
// 1. Write certificate (+ chain if chain_filename not set) to cert_filename with mode 0644
|
|
// 2. Write private key to key_filename with mode 0600
|
|
// 3. If chain_filename set and chain provided, write chain separately with mode 0644
|
|
// 4. If sds_config is true, write SDS JSON file pointing to cert/key paths
|
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
|
c.logger.Info("deploying certificate to Envoy",
|
|
"cert_dir", c.config.CertDir,
|
|
"cert_filename", c.config.CertFilename,
|
|
"key_filename", c.config.KeyFilename)
|
|
|
|
startTime := time.Now()
|
|
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
|
|
|
// Build certificate data: if chain_filename is set, write chain separately;
|
|
// otherwise append chain to cert file (standard fullchain behavior)
|
|
certData := request.CertPEM + "\n"
|
|
if request.ChainPEM != "" && c.config.ChainFilename == "" {
|
|
certData += request.ChainPEM + "\n"
|
|
}
|
|
|
|
// Phase 7 (deploy-hardening I): atomic-write via
|
|
// deploy.AtomicWriteFile so cert/key/chain swap atomically and
|
|
// have backup files for rollback. Envoy's SDS file watcher
|
|
// picks up the rename atomically — no torn config.
|
|
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 with secure permissions (0600: rw-------)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Write chain separately if chain_filename is configured
|
|
if c.config.ChainFilename != "" && request.ChainPEM != "" {
|
|
chainPath := filepath.Join(c.config.CertDir, c.config.ChainFilename)
|
|
if _, err := deploy.AtomicWriteFile(ctx, chainPath, []byte(request.ChainPEM+"\n"), deploy.WriteOptions{Mode: 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: chainPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
// Write SDS JSON file if configured
|
|
if c.config.SDSConfig {
|
|
if err := c.writeSDSConfig(); err != nil {
|
|
errMsg := fmt.Sprintf("failed to write SDS config: %v", err)
|
|
c.logger.Error("SDS config deployment failed", "error", err)
|
|
return &target.DeploymentResult{
|
|
Success: false,
|
|
TargetAddress: certPath,
|
|
Message: errMsg,
|
|
DeployedAt: time.Now(),
|
|
}, fmt.Errorf("%s", errMsg)
|
|
}
|
|
}
|
|
|
|
deploymentDuration := time.Since(startTime)
|
|
c.logger.Info("certificate deployed to Envoy successfully",
|
|
"duration", deploymentDuration.String(),
|
|
"cert_path", certPath,
|
|
"key_path", keyPath,
|
|
"sds_config", c.config.SDSConfig)
|
|
|
|
metadata := map[string]string{
|
|
"cert_path": certPath,
|
|
"key_path": keyPath,
|
|
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
|
}
|
|
if c.config.SDSConfig {
|
|
metadata["sds_config_path"] = filepath.Join(c.config.CertDir, "sds.json")
|
|
}
|
|
|
|
return &target.DeploymentResult{
|
|
Success: true,
|
|
TargetAddress: certPath,
|
|
DeploymentID: fmt.Sprintf("envoy-%d", time.Now().Unix()),
|
|
Message: "Certificate deployed to Envoy (file-based SDS will auto-reload)",
|
|
DeployedAt: time.Now(),
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
// writeSDSConfig writes an Envoy SDS JSON file that references the cert/key file paths.
|
|
// This file is consumed by Envoy's file-based SDS provider (path_config_source).
|
|
func (c *Connector) writeSDSConfig() error {
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
|
|
|
sdsResource := SDSResource{
|
|
Resources: []SDSTLSCertificate{
|
|
{
|
|
Type: "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret",
|
|
Name: "server_cert",
|
|
TLSCertificate: TLSCertificate{
|
|
CertificateChain: DataSource{Filename: certPath},
|
|
PrivateKey: DataSource{Filename: keyPath},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
sdsJSON, err := json.MarshalIndent(sdsResource, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal SDS config: %w", err)
|
|
}
|
|
|
|
sdsPath := filepath.Join(c.config.CertDir, "sds.json")
|
|
if err := os.WriteFile(sdsPath, sdsJSON, 0644); err != nil {
|
|
return fmt.Errorf("failed to write SDS config file: %w", err)
|
|
}
|
|
|
|
c.logger.Info("SDS config file written", "path", sdsPath)
|
|
return nil
|
|
}
|
|
|
|
// ValidateDeployment verifies that the deployed certificate files are readable.
|
|
// It checks that both the certificate and key files exist and are accessible.
|
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
|
c.logger.Info("validating Envoy deployment",
|
|
"certificate_id", request.CertificateID,
|
|
"serial", request.Serial)
|
|
|
|
startTime := time.Now()
|
|
|
|
certPath := filepath.Join(c.config.CertDir, c.config.CertFilename)
|
|
keyPath := filepath.Join(c.config.CertDir, c.config.KeyFilename)
|
|
|
|
// Verify certificate file exists and is readable
|
|
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)
|
|
}
|
|
|
|
// Verify key file exists and is readable
|
|
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)
|
|
}
|
|
|
|
validationDuration := time.Since(startTime)
|
|
c.logger.Info("Envoy deployment validated successfully",
|
|
"duration", validationDuration.String())
|
|
|
|
return &target.ValidationResult{
|
|
Valid: true,
|
|
Serial: request.Serial,
|
|
TargetAddress: certPath,
|
|
Message: "Certificate and key files accessible",
|
|
ValidatedAt: time.Now(),
|
|
Metadata: map[string]string{
|
|
"cert_path": certPath,
|
|
"key_path": keyPath,
|
|
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
|
},
|
|
}, nil
|
|
}
|