mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 14:48:52 +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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user