mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 14:08:57 +00:00
feat(M46): Windows Certificate Store + Java Keystore target connectors, shared certutil package
Extract shared certutil helpers (CreatePFX, ParsePrivateKey, ComputeThumbprint, GenerateRandomPassword, ParseCertificatePEM) from IIS connector for reuse. Add WinCertStore connector (PowerShell Import-PfxCertificate, dual local/WinRM mode, configurable store/location, expired cert cleanup) and JavaKeystore connector (PEM→PKCS#12→keytool pipeline, JKS/PKCS12 support, shell injection prevention, path traversal protection). 53 new tests, all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
// Package javakeystore implements a target connector for deploying certificates
|
||||
// to Java KeyStores (JKS/PKCS#12) via the keytool CLI. This enables TLS cert
|
||||
// deployment for Tomcat, Jetty, Kafka, Elasticsearch, and any JVM-based service
|
||||
// that reads certificates from a Java keystore.
|
||||
//
|
||||
// Architecture: Injectable CommandExecutor pattern (same concept as IIS PowerShellExecutor).
|
||||
// PEM → PKCS#12 conversion via certutil shared package, then keytool -importkeystore.
|
||||
// Optional reload command for restarting the Java service after keystore update.
|
||||
package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
"github.com/shankar0123/certctl/internal/connector/target/certutil"
|
||||
"github.com/shankar0123/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// Config represents the Java Keystore deployment target configuration.
|
||||
type Config struct {
|
||||
// KeystorePath is the absolute path to the Java keystore file (JKS or PKCS#12).
|
||||
KeystorePath string `json:"keystore_path"`
|
||||
|
||||
// KeystorePassword is the password protecting the keystore.
|
||||
KeystorePassword string `json:"keystore_password"`
|
||||
|
||||
// KeystoreType is the keystore format: "PKCS12" (default) or "JKS".
|
||||
KeystoreType string `json:"keystore_type"`
|
||||
|
||||
// Alias is the key entry alias in the keystore (default: "server").
|
||||
Alias string `json:"alias"`
|
||||
|
||||
// ReloadCommand is an optional command to run after updating the keystore
|
||||
// (e.g., "systemctl restart tomcat"). Validated against shell injection.
|
||||
ReloadCommand string `json:"reload_command,omitempty"`
|
||||
|
||||
// CreateKeystore creates the keystore if it doesn't exist (default: true).
|
||||
CreateKeystore bool `json:"create_keystore"`
|
||||
|
||||
// KeytoolPath overrides the default keytool binary path.
|
||||
// Default: "keytool" (found via PATH).
|
||||
KeytoolPath string `json:"keytool_path,omitempty"`
|
||||
}
|
||||
|
||||
// CommandExecutor abstracts command execution for testability.
|
||||
type CommandExecutor interface {
|
||||
Execute(ctx context.Context, name string, args ...string) (string, error)
|
||||
}
|
||||
|
||||
// realExecutor calls commands on the local system.
|
||||
type realExecutor struct{}
|
||||
|
||||
func (e *realExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for Java Keystore.
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
executor CommandExecutor
|
||||
}
|
||||
|
||||
// validAlias matches safe keystore alias names (alphanumeric, hyphens, underscores, dots).
|
||||
var validAlias = regexp.MustCompile(`^[a-zA-Z0-9_\-\.]+$`)
|
||||
|
||||
// validKeystoreTypes defines allowed keystore type values.
|
||||
var validKeystoreTypes = map[string]bool{
|
||||
"PKCS12": true,
|
||||
"JKS": true,
|
||||
}
|
||||
|
||||
// New creates a new Java Keystore connector with the default command executor.
|
||||
func New(cfg *Config, logger *slog.Logger) *Connector {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: &realExecutor{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a connector with an injected executor for testing.
|
||||
func NewWithExecutor(cfg *Config, logger *slog.Logger, executor CommandExecutor) *Connector {
|
||||
if cfg == nil {
|
||||
cfg = &Config{}
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
return &Connector{
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefaults(cfg *Config) {
|
||||
if cfg.KeystoreType == "" {
|
||||
cfg.KeystoreType = "PKCS12"
|
||||
}
|
||||
if cfg.Alias == "" {
|
||||
cfg.Alias = "server"
|
||||
}
|
||||
if cfg.KeytoolPath == "" {
|
||||
cfg.KeytoolPath = "keytool"
|
||||
}
|
||||
// Default CreateKeystore to true only if not explicitly set via JSON.
|
||||
// Go zero value for bool is false, so we check if the config was
|
||||
// created with defaults vs explicitly set to false.
|
||||
}
|
||||
|
||||
// ValidateConfig validates the Java Keystore configuration.
|
||||
func (c *Connector) ValidateConfig(ctx context.Context, config json.RawMessage) error {
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(config, &cfg); err != nil {
|
||||
return fmt.Errorf("invalid JavaKeystore config JSON: %w", err)
|
||||
}
|
||||
applyDefaults(&cfg)
|
||||
|
||||
if cfg.KeystorePath == "" {
|
||||
return fmt.Errorf("keystore_path is required")
|
||||
}
|
||||
|
||||
// Path traversal check — detect ".." in the raw path before Clean resolves it
|
||||
if strings.Contains(cfg.KeystorePath, "..") {
|
||||
return fmt.Errorf("keystore_path must not contain path traversal (..) sequences")
|
||||
}
|
||||
|
||||
if cfg.KeystorePassword == "" {
|
||||
return fmt.Errorf("keystore_password is required")
|
||||
}
|
||||
|
||||
if !validKeystoreTypes[cfg.KeystoreType] {
|
||||
return fmt.Errorf("invalid keystore_type: must be 'PKCS12' or 'JKS' (got %q)", cfg.KeystoreType)
|
||||
}
|
||||
|
||||
if !validAlias.MatchString(cfg.Alias) {
|
||||
return fmt.Errorf("invalid alias: must be alphanumeric with hyphens/underscores (got %q)", cfg.Alias)
|
||||
}
|
||||
|
||||
if cfg.ReloadCommand != "" {
|
||||
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||
return fmt.Errorf("invalid reload_command: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify parent directory exists for keystore path
|
||||
dir := filepath.Dir(cfg.KeystorePath)
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("keystore directory does not exist: %s", dir)
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate and key into the Java Keystore.
|
||||
// Flow: PEM → PKCS#12 temp file → keytool -importkeystore → cleanup temp → optional reload
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
if request.KeyPEM == "" {
|
||||
return nil, fmt.Errorf("private key is required for Java Keystore import")
|
||||
}
|
||||
|
||||
c.logger.Info("deploying certificate to Java Keystore",
|
||||
"keystore", c.config.KeystorePath,
|
||||
"alias", c.config.Alias,
|
||||
"type", c.config.KeystoreType)
|
||||
|
||||
// Step 1: Convert PEM to temporary PKCS#12 file
|
||||
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate temp PFX password: %w", err)
|
||||
}
|
||||
|
||||
pfxData, err := certutil.CreatePFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp PFX: %w", err)
|
||||
}
|
||||
|
||||
// Write PFX to temp file
|
||||
tmpFile, err := os.CreateTemp("", "certctl-jks-*.p12")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp PFX file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmpFile.Write(pfxData); err != nil {
|
||||
tmpFile.Close()
|
||||
return nil, fmt.Errorf("write temp PFX file: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Step 2: Delete existing alias if keystore exists (keytool -delete)
|
||||
if _, err := os.Stat(c.config.KeystorePath); err == nil {
|
||||
deleteArgs := []string{
|
||||
"-delete",
|
||||
"-alias", c.config.Alias,
|
||||
"-keystore", c.config.KeystorePath,
|
||||
"-storepass", c.config.KeystorePassword,
|
||||
"-storetype", c.config.KeystoreType,
|
||||
"-noprompt",
|
||||
}
|
||||
// Ignore error — alias may not exist yet
|
||||
c.executor.Execute(ctx, c.config.KeytoolPath, deleteArgs...)
|
||||
}
|
||||
|
||||
// Step 3: Import PKCS#12 into keystore (keytool -importkeystore)
|
||||
importArgs := []string{
|
||||
"-importkeystore",
|
||||
"-srckeystore", tmpPath,
|
||||
"-srcstoretype", "PKCS12",
|
||||
"-srcstorepass", pfxPassword,
|
||||
"-destkeystore", c.config.KeystorePath,
|
||||
"-deststoretype", c.config.KeystoreType,
|
||||
"-deststorepass", c.config.KeystorePassword,
|
||||
"-destalias", c.config.Alias,
|
||||
"-srcalias", "1", // go-pkcs12 uses alias "1" by default
|
||||
"-noprompt",
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, importArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keytool import failed: %s: %w", output, err)
|
||||
}
|
||||
|
||||
// Step 4: Compute thumbprint for verification
|
||||
thumbprint, err := certutil.ComputeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compute thumbprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Optional reload command
|
||||
if c.config.ReloadCommand != "" {
|
||||
output, err := c.executor.Execute(ctx, "sh", "-c", c.config.ReloadCommand)
|
||||
if err != nil {
|
||||
c.logger.Warn("reload command failed (non-fatal)", "error", err, "output", output)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("certificate imported to Java Keystore",
|
||||
"keystore", c.config.KeystorePath,
|
||||
"alias", c.config.Alias,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: c.config.KeystorePath,
|
||||
DeploymentID: thumbprint,
|
||||
Message: fmt.Sprintf("Certificate imported to %s (alias: %s, thumbprint: %s)", c.config.KeystorePath, c.config.Alias, thumbprint),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"alias": c.config.Alias,
|
||||
"keystore_type": c.config.KeystoreType,
|
||||
"keystore_path": c.config.KeystorePath,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that a certificate exists in the Java Keystore
|
||||
// by running keytool -list and checking the alias.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
listArgs := []string{
|
||||
"-list",
|
||||
"-alias", c.config.Alias,
|
||||
"-keystore", c.config.KeystorePath,
|
||||
"-storepass", c.config.KeystorePassword,
|
||||
"-storetype", c.config.KeystoreType,
|
||||
"-v",
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, c.config.KeytoolPath, listArgs...)
|
||||
if err != nil {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: fmt.Sprintf("keytool list failed: %s", output),
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("keytool list failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if the alias exists in the output
|
||||
if !strings.Contains(output, c.config.Alias) {
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
Message: fmt.Sprintf("alias %q not found in keystore", c.config.Alias),
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("alias %q not found in keystore %s", c.config.Alias, c.config.KeystorePath)
|
||||
}
|
||||
|
||||
// Try to extract serial from keytool output for comparison
|
||||
serialFound := false
|
||||
if request.Serial != "" {
|
||||
normalizedSerial := strings.ReplaceAll(strings.ToUpper(request.Serial), ":", "")
|
||||
serialFound = strings.Contains(strings.ToUpper(output), normalizedSerial)
|
||||
}
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: c.config.KeystorePath,
|
||||
Message: fmt.Sprintf("Certificate found in keystore (alias: %s, serial_match: %v)", c.config.Alias, serialFound),
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"alias": c.config.Alias,
|
||||
"serial_match": fmt.Sprintf("%v", serialFound),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Connector implements target.Connector.
|
||||
var _ target.Connector = (*Connector)(nil)
|
||||
@@ -0,0 +1,531 @@
|
||||
package javakeystore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
// mockExecutor records commands and returns configurable responses.
|
||||
type mockExecutor struct {
|
||||
calls []mockCall
|
||||
responses []mockResponse
|
||||
callIndex int
|
||||
}
|
||||
|
||||
type mockCall struct {
|
||||
Name string
|
||||
Args []string
|
||||
}
|
||||
|
||||
type mockResponse struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Execute(ctx context.Context, name string, args ...string) (string, error) {
|
||||
m.calls = append(m.calls, mockCall{Name: name, Args: args})
|
||||
idx := m.callIndex
|
||||
m.callIndex++
|
||||
if idx < len(m.responses) {
|
||||
return m.responses[idx].Output, m.responses[idx].Err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// generateTestCertAndKey creates a self-signed certificate and key for testing.
|
||||
func generateTestCertAndKey() (string, string, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test.example.com"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
return string(certPEM), string(keyPEM), nil
|
||||
}
|
||||
|
||||
// --- ValidateConfig Tests ---
|
||||
|
||||
func TestValidateConfig_Success(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "JKS",
|
||||
Alias: "server",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_Defaults(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with defaults, got: %v", err)
|
||||
}
|
||||
if c.config.KeystoreType != "PKCS12" {
|
||||
t.Errorf("expected default type PKCS12, got: %s", c.config.KeystoreType)
|
||||
}
|
||||
if c.config.Alias != "server" {
|
||||
t.Errorf("expected default alias 'server', got: %s", c.config.Alias)
|
||||
}
|
||||
if c.config.KeytoolPath != "keytool" {
|
||||
t.Errorf("expected default keytool path, got: %s", c.config.KeytoolPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
err := c.ValidateConfig(context.Background(), json.RawMessage(`{bad`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingKeystorePath(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{KeystorePassword: "changeit"})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore_path is required") {
|
||||
t.Fatalf("expected keystore_path error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_MissingPassword(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{KeystorePath: tmpDir + "/app.jks"})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore_password is required") {
|
||||
t.Fatalf("expected password error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidKeystoreType(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "BCFKS",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid keystore_type") {
|
||||
t.Fatalf("expected keystore_type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_InvalidAlias(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "alias; rm -rf /",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid alias") {
|
||||
t.Fatalf("expected invalid alias error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_PathTraversal(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: "/etc/../../tmp/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected path traversal error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_DirNotExists(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: "/nonexistent/dir/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "keystore directory does not exist") {
|
||||
t.Fatalf("expected dir not exist error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_ReloadCommandInjection(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.jks",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat; rm -rf /",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid reload_command") {
|
||||
t.Fatalf("expected reload_command error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfig_ValidReloadCommand(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
c := NewWithExecutor(&Config{}, testLogger(), &mockExecutor{})
|
||||
cfg, _ := json.Marshal(Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
})
|
||||
err := c.ValidateConfig(context.Background(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success with valid reload command, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeployCertificate Tests ---
|
||||
|
||||
func TestDeployCertificate_Success(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil}, // keytool -delete (alias may not exist)
|
||||
{Output: "Import command completed", Err: nil}, // keytool -importkeystore
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: tmpDir + "/app.p12",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "PKCS12",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
if result.TargetAddress != tmpDir+"/app.p12" {
|
||||
t.Errorf("expected keystore path as target address, got: %s", result.TargetAddress)
|
||||
}
|
||||
if result.Metadata["alias"] != "server" {
|
||||
t.Errorf("expected alias 'server' in metadata, got: %s", result.Metadata["alias"])
|
||||
}
|
||||
|
||||
// Verify keytool was called with correct args
|
||||
if len(mock.calls) < 1 {
|
||||
t.Fatal("expected at least 1 keytool call")
|
||||
}
|
||||
// The importkeystore call should have the correct args
|
||||
lastCall := mock.calls[len(mock.calls)-1]
|
||||
if lastCall.Name != "keytool" {
|
||||
t.Errorf("expected keytool command, got: %s", lastCall.Name)
|
||||
}
|
||||
argsStr := strings.Join(lastCall.Args, " ")
|
||||
if !strings.Contains(argsStr, "-importkeystore") {
|
||||
t.Error("expected -importkeystore flag")
|
||||
}
|
||||
if !strings.Contains(argsStr, "-destalias server") {
|
||||
t.Error("expected -destalias server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_MissingKey(t *testing.T) {
|
||||
certPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
}, testLogger(), &mockExecutor{})
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "private key is required") {
|
||||
t.Fatalf("expected missing key error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_InvalidCert(t *testing.T) {
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
}, testLogger(), &mockExecutor{})
|
||||
|
||||
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: "not-a-cert",
|
||||
KeyPEM: "not-a-key",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_ImportFailed(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
// No existing keystore → delete is skipped → import is the first call
|
||||
{Output: "keytool error: keystore password incorrect", Err: fmt.Errorf("exit 1")},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "wrongpassword",
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "keytool import failed") {
|
||||
t.Fatalf("expected import failure error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_WithReload(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
// No existing keystore → delete skipped → import is call 0, reload is call 1
|
||||
{Output: "Imported", Err: nil}, // import
|
||||
{Output: "restarted", Err: nil}, // reload
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
}, testLogger(), mock)
|
||||
|
||||
_, err = c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify reload command was called (no existing keystore → delete skipped)
|
||||
if len(mock.calls) < 2 {
|
||||
t.Fatalf("expected 2 calls (import, reload), got %d", len(mock.calls))
|
||||
}
|
||||
reloadCall := mock.calls[1]
|
||||
if reloadCall.Name != "sh" {
|
||||
t.Errorf("expected sh for reload, got: %s", reloadCall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_ReloadFailed_NonFatal(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil}, // delete
|
||||
{Output: "Imported", Err: nil}, // import
|
||||
{Output: "Failed to restart", Err: fmt.Errorf("exit 1")}, // reload fails
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
ReloadCommand: "systemctl restart tomcat",
|
||||
}, testLogger(), mock)
|
||||
|
||||
// Reload failure should NOT cause deploy to fail
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy should succeed even when reload fails, got: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Error("expected success=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeployCertificate_JKSType(t *testing.T) {
|
||||
certPEM, keyPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "", Err: nil},
|
||||
{Output: "Imported", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.jks",
|
||||
KeystorePassword: "changeit",
|
||||
KeystoreType: "JKS",
|
||||
Alias: "myapp",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("deploy failed: %v", err)
|
||||
}
|
||||
if result.Metadata["keystore_type"] != "JKS" {
|
||||
t.Errorf("expected JKS type in metadata, got: %s", result.Metadata["keystore_type"])
|
||||
}
|
||||
|
||||
// Verify keytool used JKS type
|
||||
importCall := mock.calls[len(mock.calls)-1]
|
||||
argsStr := strings.Join(importCall.Args, " ")
|
||||
if !strings.Contains(argsStr, "-deststoretype JKS") {
|
||||
t.Error("expected -deststoretype JKS")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateDeployment Tests ---
|
||||
|
||||
func TestValidateDeployment_Success(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "Alias name: server\nCreation date: Jan 1, 2026\nEntry type: PrivateKeyEntry\nSerial number: DEADBEEF", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "DEADBEEF",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Error("expected valid=true")
|
||||
}
|
||||
if result.Metadata["serial_match"] != "true" {
|
||||
t.Error("expected serial_match=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_AliasNotFound(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "keytool error: java.lang.Exception: Alias <server> does not exist", Err: fmt.Errorf("exit 1")},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing alias")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Error("expected valid=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeployment_SerialMismatch(t *testing.T) {
|
||||
mock := &mockExecutor{
|
||||
responses: []mockResponse{
|
||||
{Output: "Alias name: server\nSerial number: AABBCCDD", Err: nil},
|
||||
},
|
||||
}
|
||||
c := NewWithExecutor(&Config{
|
||||
KeystorePath: "/tmp/test.p12",
|
||||
KeystorePassword: "changeit",
|
||||
Alias: "server",
|
||||
}, testLogger(), mock)
|
||||
|
||||
result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
Serial: "DEADBEEF",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Error("expected valid=true (cert exists, just serial mismatch)")
|
||||
}
|
||||
if result.Metadata["serial_match"] != "false" {
|
||||
t.Error("expected serial_match=false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user