From fd05bacb768627ea9eec38bda106176d0ed40104 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Fri, 3 Apr 2026 01:23:35 -0400 Subject: [PATCH] feat(M41): Envoy target connector with SDS support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 7 +- api/openapi.yaml | 2 +- cmd/agent/main.go | 10 + docs/connectors.md | 32 +- internal/connector/target/envoy/envoy.go | 318 ++++++++++++++ internal/connector/target/envoy/envoy_test.go | 394 ++++++++++++++++++ internal/domain/connector.go | 1 + web/src/pages/TargetsPage.tsx | 9 + 8 files changed, 768 insertions(+), 5 deletions(-) create mode 100644 internal/connector/target/envoy/envoy.go create mode 100644 internal/connector/target/envoy/envoy_test.go diff --git a/README.md b/README.md index a490509..61abb5a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ gantt 47 days :crit, 2020-01-01, 47d ``` -> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,521+ tests with race detection, static analysis, and vulnerability scanning on every commit. +> **Actively maintained — shipping weekly.** Found something? [Open a GitHub issue](https://github.com/shankar0123/certctl/issues) — issues get triaged same-day. CI runs 1,536+ tests with race detection, static analysis, and vulnerability scanning on every commit. ## Why certctl Exists @@ -44,7 +44,7 @@ Certificate lifecycle tooling today falls into two camps: expensive enterprise p certctl fills that gap. It's **CA-agnostic** — plug in any certificate authority: Let's Encrypt via ACME, Smallstep step-ca, HashiCorp Vault PKI, DigiCert CertCentral, your enterprise ADCS via sub-CA mode, or any custom CA through a shell script adapter. Run multiple issuers simultaneously for different certificate types. -It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments. +It's **target-agnostic**. Agents deploy certificates to NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, and IIS (local PowerShell or remote WinRM) — all using the same pluggable connector model. The control plane never initiates outbound connections — agents poll for work, which means certctl works behind firewalls, across network zones, and in air-gapped environments. For a detailed comparison with CertKit, KeyTalk, and enterprise platforms, see [Why certctl?](docs/why-certctl.md) @@ -97,6 +97,7 @@ For the full capability breakdown — revocation infrastructure (CRL + OCSP), po | HAProxy | Implemented | `HAProxy` | | Traefik | Implemented | `Traefik` | | Caddy | Implemented | `Caddy` | +| Envoy | Implemented | `Envoy` | | Microsoft IIS | Implemented (local + WinRM) | `IIS` | | F5 BIG-IP | Interface only | `F5` | @@ -292,7 +293,7 @@ CI runs on every push: `go vet`, `go test -race`, `golangci-lint`, `govulncheck` Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector, agent-side key generation, API auth + rate limiting, React dashboard, CI pipeline with coverage gates, Docker images on GHCR. ### V2: Operational Maturity — Shipped -30+ milestones, 1,521+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details. +30+ milestones, 1,536+ tests. Sub-CA mode, ACME DNS-01/DNS-PERSIST-01, step-ca, Vault PKI, DigiCert CertCentral, OpenSSL/Custom CA issuers. NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS targets. RFC 5280 revocation with CRL + OCSP. Certificate profiles, ownership tracking, approval workflows. Filesystem and network certificate discovery. Prometheus metrics, dashboard charts, agent fleet overview. EST server (RFC 7030), ACME ARI (RFC 9702), certificate export, S/MIME support, Helm chart, MCP server, CLI, scheduled digest emails. Slack, Teams, PagerDuty, OpsGenie, SMTP notifications. Compliance mapping (SOC 2, PCI-DSS 4.0, NIST SP 800-57). See the [Feature Inventory](docs/features.md) for details. **Coming in v2.1.0:** Dynamic issuer and target configuration via GUI (no env var restarts), first-run onboarding wizard. diff --git a/api/openapi.yaml b/api/openapi.yaml index 6a2406d..5ab4aee 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2669,7 +2669,7 @@ components: # ─── Targets ───────────────────────────────────────────────────── TargetType: type: string - enum: [NGINX, Apache, HAProxy, Traefik, Caddy, IIS, F5] + enum: [NGINX, Apache, HAProxy, Traefik, Caddy, Envoy, IIS, F5] DeploymentTarget: type: object diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 4f2cbb6..e94cd54 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -29,6 +29,7 @@ import ( "github.com/shankar0123/certctl/internal/connector/target" "github.com/shankar0123/certctl/internal/connector/target/apache" "github.com/shankar0123/certctl/internal/connector/target/caddy" + "github.com/shankar0123/certctl/internal/connector/target/envoy" "github.com/shankar0123/certctl/internal/connector/target/f5" "github.com/shankar0123/certctl/internal/connector/target/haproxy" "github.com/shankar0123/certctl/internal/connector/target/iis" @@ -612,6 +613,15 @@ func (a *Agent) createTargetConnector(targetType string, configJSON json.RawMess } return caddy.New(&cfg, a.logger), nil + case "Envoy": + var cfg envoy.Config + if len(configJSON) > 0 { + if err := json.Unmarshal(configJSON, &cfg); err != nil { + return nil, fmt.Errorf("invalid Envoy config: %w", err) + } + } + return envoy.New(&cfg, a.logger), nil + default: return nil, fmt.Errorf("unsupported target type: %s", targetType) } diff --git a/docs/connectors.md b/docs/connectors.md index d55d91a..00b79a6 100644 --- a/docs/connectors.md +++ b/docs/connectors.md @@ -21,6 +21,7 @@ Connectors extend certctl to integrate with external systems for certificate iss - [Built-in: Apache httpd](#built-in-apache-httpd) - [Built-in: HAProxy](#built-in-haproxy) - [Built-in: Traefik](#built-in-traefik) + - [Built-in: Envoy](#built-in-envoy) - [Built-in: Caddy](#built-in-caddy) - [F5 BIG-IP (Interface Only)](#f5-big-ip-interface-only) - [IIS (Implemented, Dual-Mode)](#iis-implemented-dual-mode) @@ -52,7 +53,7 @@ Connectors extend certctl to integrate with external systems for certificate iss Three types of connectors: 1. **Issuer Connector** — Obtains certificates from CAs (Local CA with sub-CA support, ACME with HTTP-01 + DNS-01 + DNS-PERSIST-01, step-ca, OpenSSL/Custom CA implemented; additional CA integrations planned) -2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, IIS implemented; F5 via proxy agent planned; additional cloud and network targets planned) +2. **Target Connector** — Deploys certificates to infrastructure (NGINX, Apache httpd, HAProxy, Traefik, Caddy, Envoy, IIS implemented; F5 via proxy agent planned; additional cloud and network targets planned) 3. **Notifier Connector** — Sends alerts about certificate events (Email, Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie implemented) All connectors accept JSON configuration at initialization, support config validation, and are registered in the service layer. Issuer connectors run on the control plane; target connectors run on agents. For network appliances where agents can't be installed, a **proxy agent** in the same network zone handles deployment — the server never initiates outbound connections. @@ -590,6 +591,35 @@ When `mode` is `"api"`, the connector posts the certificate to the admin API end Location: `internal/connector/target/caddy/caddy.go` +### Built-in: Envoy + +The Envoy connector uses file-based certificate delivery — it writes certificate and 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. When files change, Envoy automatically picks up the new certificates without requiring a reload command. + +Configuration: +```json +{ + "cert_dir": "/etc/envoy/certs", + "cert_filename": "cert.pem", + "key_filename": "key.pem", + "chain_filename": "chain.pem", + "sds_config": true +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `cert_dir` | string | (required) | Directory where Envoy watches for certificate files | +| `cert_filename` | string | `cert.pem` | Filename for the certificate (leaf + chain unless `chain_filename` is set) | +| `key_filename` | string | `key.pem` | Filename for the private key | +| `chain_filename` | string | (empty) | If set, chain is written to a separate file instead of appended to the cert | +| `sds_config` | bool | `false` | If true, writes an `sds.json` file for Envoy's file-based SDS provider | + +When `sds_config` is `true`, the connector writes an SDS JSON file (`{cert_dir}/sds.json`) containing a `tls_certificate` resource that points to the cert and key file paths. Envoy's file-based SDS (`path_config_source`) watches this file for changes, providing automatic hot-reload of certificates. This is the recommended approach for production Envoy deployments using dynamic TLS configuration. + +When `sds_config` is `false` (the default), the connector simply writes cert and key files. Use this mode when Envoy's bootstrap config references the cert/key files directly via static `filename` fields in the TLS context. + +Location: `internal/connector/target/envoy/envoy.go` + ### F5 BIG-IP (Interface Only) The F5 BIG-IP target connector interface is defined with the iControl REST flow mapped out, but the actual API calls are not yet implemented. F5 appliances can't run agents directly, so this connector uses the **proxy agent pattern**: a designated agent in the same network zone picks up F5 deployment jobs and calls the iControl REST API. The server assigns the work; the proxy agent executes it. diff --git a/internal/connector/target/envoy/envoy.go b/internal/connector/target/envoy/envoy.go new file mode 100644 index 0000000..f07f08d --- /dev/null +++ b/internal/connector/target/envoy/envoy.go @@ -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 +} diff --git a/internal/connector/target/envoy/envoy_test.go b/internal/connector/target/envoy/envoy_test.go new file mode 100644 index 0000000..0f05a1d --- /dev/null +++ b/internal/connector/target/envoy/envoy_test.go @@ -0,0 +1,394 @@ +package envoy_test + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/envoy" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +func TestEnvoyConnector_ValidateConfig_Success(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{ + CertDir: tmpDir, + CertFilename: "cert.pem", + KeyFilename: "key.pem", + } + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } +} + +func TestEnvoyConnector_ValidateConfig_InvalidJSON(t *testing.T) { + ctx := context.Background() + connector := envoy.New(&envoy.Config{}, testLogger()) + if err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`)); err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestEnvoyConnector_ValidateConfig_MissingCertDir(t *testing.T) { + ctx := context.Background() + cfg := envoy.Config{CertFilename: "cert.pem", KeyFilename: "key.pem"} + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err == nil { + t.Fatal("expected error for missing cert_dir") + } +} + +func TestEnvoyConnector_ValidateConfig_DirectoryNotExists(t *testing.T) { + ctx := context.Background() + cfg := envoy.Config{CertDir: "/nonexistent/directory"} + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err == nil { + t.Fatal("expected error for non-existent directory") + } +} + +func TestEnvoyConnector_ValidateConfig_PathTraversal_CertFilename(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "../../../etc/passwd"} + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err == nil { + t.Fatal("expected error for path traversal in cert_filename") + } +} + +func TestEnvoyConnector_ValidateConfig_PathTraversal_KeyFilename(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + cfg := envoy.Config{CertDir: tmpDir, KeyFilename: "sub/key.pem"} + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err == nil { + t.Fatal("expected error for path traversal in key_filename") + } +} + +func TestEnvoyConnector_ValidateConfig_PathTraversal_ChainFilename(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + cfg := envoy.Config{CertDir: tmpDir, ChainFilename: "../chain.pem"} + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err == nil { + t.Fatal("expected error for path traversal in chain_filename") + } +} + +func TestEnvoyConnector_ValidateConfig_DefaultFilenames(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + cfg := envoy.Config{CertDir: tmpDir} // No filenames — should use defaults + + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + if err := connector.ValidateConfig(ctx, rawConfig); err != nil { + t.Fatalf("ValidateConfig with defaults failed: %v", err) + } +} + +func TestEnvoyConnector_DeployCertificate_Success(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + request := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\nCAcert...\n-----END CERTIFICATE-----", + } + + result, err := connector.DeployCertificate(ctx, request) + if err != nil { + t.Fatalf("DeployCertificate failed: %v", err) + } + if !result.Success { + t.Fatalf("deployment should succeed, got: %s", result.Message) + } + + // Verify cert file was created with chain appended (no chain_filename set) + certData, err := os.ReadFile(filepath.Join(tmpDir, "cert.pem")) + if err != nil { + t.Fatalf("failed to read cert file: %v", err) + } + if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nCAcert...\n-----END CERTIFICATE-----\n" { + t.Fatalf("cert content mismatch: got %q", got) + } + + // Verify key file created with correct permissions + keyPath := filepath.Join(tmpDir, "key.pem") + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("key file not found: %v", err) + } + if perms := keyInfo.Mode().Perm(); perms != 0600 { + t.Fatalf("key permissions are %o, expected 0600", perms) + } +} + +func TestEnvoyConnector_DeployCertificate_WithoutChain(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + request := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + } + + result, err := connector.DeployCertificate(ctx, request) + if err != nil { + t.Fatalf("DeployCertificate failed: %v", err) + } + if !result.Success { + t.Fatalf("deployment should succeed, got: %s", result.Message) + } + + // Cert file should only contain the leaf cert (no chain) + certData, _ := os.ReadFile(filepath.Join(tmpDir, "cert.pem")) + if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----\n" { + t.Fatalf("cert content mismatch: got %q", got) + } +} + +func TestEnvoyConnector_DeployCertificate_SeparateChainFile(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{ + CertDir: tmpDir, + CertFilename: "cert.pem", + KeyFilename: "key.pem", + ChainFilename: "chain.pem", + } + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + request := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nleaf...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\nCA...\n-----END CERTIFICATE-----", + } + + result, err := connector.DeployCertificate(ctx, request) + if err != nil { + t.Fatalf("DeployCertificate failed: %v", err) + } + if !result.Success { + t.Fatalf("deployment should succeed, got: %s", result.Message) + } + + // Cert file should only contain leaf (chain is separate) + certData, _ := os.ReadFile(filepath.Join(tmpDir, "cert.pem")) + if got := string(certData); got != "-----BEGIN CERTIFICATE-----\nleaf...\n-----END CERTIFICATE-----\n" { + t.Fatalf("cert should not contain chain when chain_filename is set: got %q", got) + } + + // Chain file should exist with chain data + chainData, err := os.ReadFile(filepath.Join(tmpDir, "chain.pem")) + if err != nil { + t.Fatalf("chain file not found: %v", err) + } + if got := string(chainData); got != "-----BEGIN CERTIFICATE-----\nCA...\n-----END CERTIFICATE-----\n" { + t.Fatalf("chain content mismatch: got %q", got) + } +} + +func TestEnvoyConnector_DeployCertificate_WithSDSConfig(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{ + CertDir: tmpDir, + CertFilename: "cert.pem", + KeyFilename: "key.pem", + SDSConfig: true, + } + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + request := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + } + + result, err := connector.DeployCertificate(ctx, request) + if err != nil { + t.Fatalf("DeployCertificate failed: %v", err) + } + if !result.Success { + t.Fatalf("deployment should succeed, got: %s", result.Message) + } + + // Verify SDS JSON file was created + sdsPath := filepath.Join(tmpDir, "sds.json") + sdsData, err := os.ReadFile(sdsPath) + if err != nil { + t.Fatalf("SDS config file not found: %v", err) + } + + // Parse and verify SDS JSON structure + var sdsResource envoy.SDSResource + if err := json.Unmarshal(sdsData, &sdsResource); err != nil { + t.Fatalf("invalid SDS JSON: %v", err) + } + + if len(sdsResource.Resources) != 1 { + t.Fatalf("expected 1 SDS resource, got %d", len(sdsResource.Resources)) + } + + res := sdsResource.Resources[0] + if res.Type != "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.Secret" { + t.Fatalf("wrong @type: %s", res.Type) + } + if res.Name != "server_cert" { + t.Fatalf("wrong name: %s", res.Name) + } + + expectedCertPath := filepath.Join(tmpDir, "cert.pem") + expectedKeyPath := filepath.Join(tmpDir, "key.pem") + if res.TLSCertificate.CertificateChain.Filename != expectedCertPath { + t.Fatalf("cert chain path mismatch: got %s, want %s", res.TLSCertificate.CertificateChain.Filename, expectedCertPath) + } + if res.TLSCertificate.PrivateKey.Filename != expectedKeyPath { + t.Fatalf("private key path mismatch: got %s, want %s", res.TLSCertificate.PrivateKey.Filename, expectedKeyPath) + } + + // Verify SDS path is in metadata + if result.Metadata["sds_config_path"] != sdsPath { + t.Fatalf("SDS config path not in metadata") + } +} + +func TestEnvoyConnector_DeployCertificate_WriteError(t *testing.T) { + ctx := context.Background() + + cfg := envoy.Config{ + CertDir: "/root/envoy/certs", + CertFilename: "cert.pem", + KeyFilename: "key.pem", + } + connector := envoy.New(&cfg, testLogger()) + + request := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + } + + result, err := connector.DeployCertificate(ctx, request) + if err == nil { + t.Fatal("expected error for write failure") + } + if result.Success { + t.Fatal("deployment should fail") + } +} + +func TestEnvoyConnector_ValidateDeployment_Success(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + // First deploy + deployReq := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----", + KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----", + } + connector.DeployCertificate(ctx, deployReq) + + // Then validate + validateReq := target.ValidationRequest{ + CertificateID: "mc-test", + Serial: "123456", + } + + result, err := connector.ValidateDeployment(ctx, validateReq) + if err != nil { + t.Fatalf("ValidateDeployment failed: %v", err) + } + if !result.Valid { + t.Fatalf("validation should succeed, got: %s", result.Message) + } + if result.Serial != "123456" { + t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial) + } +} + +func TestEnvoyConnector_ValidateDeployment_CertFileNotFound(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + validateReq := target.ValidationRequest{CertificateID: "mc-test", Serial: "123456"} + result, err := connector.ValidateDeployment(ctx, validateReq) + if err == nil { + t.Fatal("expected error for missing certificate file") + } + if result.Valid { + t.Fatal("validation should fail") + } +} + +func TestEnvoyConnector_ValidateDeployment_KeyFileNotFound(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + cfg := envoy.Config{CertDir: tmpDir, CertFilename: "cert.pem", KeyFilename: "key.pem"} + connector := envoy.New(&cfg, testLogger()) + rawConfig, _ := json.Marshal(cfg) + _ = connector.ValidateConfig(ctx, rawConfig) + + // Write cert but not key + os.WriteFile(filepath.Join(tmpDir, "cert.pem"), []byte("cert"), 0644) + + validateReq := target.ValidationRequest{CertificateID: "mc-test", Serial: "123456"} + result, err := connector.ValidateDeployment(ctx, validateReq) + if err == nil { + t.Fatal("expected error for missing key file") + } + if result.Valid { + t.Fatal("validation should fail") + } +} diff --git a/internal/domain/connector.go b/internal/domain/connector.go index 84a2659..262daee 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -84,4 +84,5 @@ const ( TargetTypeIIS TargetType = "IIS" TargetTypeTraefik TargetType = "Traefik" TargetTypeCaddy TargetType = "Caddy" + TargetTypeEnvoy TargetType = "Envoy" ) diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx index aa1596b..7329338 100644 --- a/web/src/pages/TargetsPage.tsx +++ b/web/src/pages/TargetsPage.tsx @@ -16,6 +16,7 @@ const typeLabels: Record = { haproxy: 'HAProxy', traefik: 'Traefik', caddy: 'Caddy', + envoy: 'Envoy', f5_bigip: 'F5 BIG-IP', iis: 'IIS', }; @@ -26,6 +27,7 @@ const TARGET_TYPES = [ { value: 'haproxy', label: 'HAProxy', description: 'Combined PEM file (cert+chain+key), optional validate, reload' }, { value: 'traefik', label: 'Traefik', description: 'File provider deployment — writes cert/key to watched directory, auto-reload' }, { value: 'caddy', label: 'Caddy', description: 'Admin API hot-reload or file-based deployment with configurable mode' }, + { value: 'envoy', label: 'Envoy', description: 'File-based deployment — writes cert/key to watched directory. Optional SDS file generation.' }, { value: 'f5_bigip', label: 'F5 BIG-IP', description: 'iControl REST via proxy agent (V3 implementation)' }, { value: 'iis', label: 'IIS', description: 'Windows IIS via agent-local PowerShell or remote WinRM proxy agent' }, ]; @@ -60,6 +62,13 @@ const CONFIG_FIELDS: Record