mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 16:39:03 +00:00
feat(M41): Envoy target connector with SDS support
File-based deployment for Envoy service mesh — writes cert/key/chain to watched directory with optional SDS JSON config for xDS bootstrap. Path traversal prevention, configurable filenames, 15 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
package envoy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// Write certificate with mode 0644 (readable by Envoy process)
|
||||
if err := os.WriteFile(certPath, []byte(certData), 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 := os.WriteFile(keyPath, []byte(request.KeyPEM), 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 := os.WriteFile(chainPath, []byte(request.ChainPEM+"\n"), 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
|
||||
}
|
||||
Reference in New Issue
Block a user