mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 18:18:52 +00:00
feat(M39): IIS target connector + README overhaul
Implement full IIS target connector with PEM-to-PFX conversion via go-pkcs12, PowerShell-based deployment (Import-PfxCertificate, IIS binding management), SHA-1 thumbprint computation, and SNI support. Injectable PowerShellExecutor interface enables cross-platform testing. Regex-validated config fields prevent PowerShell injection. 28 tests. Restructure README from 563 to 313 lines: outcome-focused feature descriptions, "Who Is This For" persona section, examples promoted above the fold, configuration/API/security reference moved to docs. All numbers verified against repo (25 GUI pages, 97 OpenAPI ops, CI thresholds service 55%/handler 60%/domain 40%/middleware 30%). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,22 @@ package iis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/connector/target"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// Config represents the IIS deployment target configuration.
|
||||
@@ -18,85 +27,178 @@ type Config struct {
|
||||
SiteName string `json:"site_name"` // IIS site name (e.g., "Default Web Site")
|
||||
CertStore string `json:"cert_store"` // Windows cert store (e.g., "My", "WebHosting")
|
||||
BindingInfo string `json:"binding_info"` // Binding info (e.g., "*.example.com")
|
||||
Port int `json:"port"` // HTTPS port (default 443)
|
||||
SNI bool `json:"sni"` // Enable Server Name Indication
|
||||
IPAddress string `json:"ip_address"` // Bind to specific IP (default "*")
|
||||
}
|
||||
|
||||
// PowerShellExecutor abstracts PowerShell command execution for testability.
|
||||
// On real Windows deployments, the realExecutor calls powershell.exe directly.
|
||||
// Tests inject a mock executor to verify command construction without Windows.
|
||||
type PowerShellExecutor interface {
|
||||
Execute(ctx context.Context, script string) (string, error)
|
||||
}
|
||||
|
||||
// realExecutor calls powershell.exe on the local system.
|
||||
type realExecutor struct{}
|
||||
|
||||
func (e *realExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
// Connector implements the target.Connector interface for IIS (Internet Information Services).
|
||||
// This connector runs on Windows agents and manages certificate deployment via IIS.
|
||||
// This connector runs on Windows agents and manages certificate deployment via PowerShell.
|
||||
//
|
||||
// IIS certificate management requires:
|
||||
// - Windows Server with IIS installed
|
||||
// - PowerShell execution available
|
||||
// - Administrative privileges
|
||||
// - Windows Server with IIS installed
|
||||
// - PowerShell execution available
|
||||
// - Administrative privileges
|
||||
//
|
||||
// TODO: Implement actual PowerShell command execution for:
|
||||
// - Certificate import: Import-PfxCertificate
|
||||
// - IIS binding update: New-WebBinding, Set-WebBinding
|
||||
// - Validation: Get-WebBinding
|
||||
// Deployment flow:
|
||||
// 1. Convert PEM cert+key to PFX (PKCS#12) format via go-pkcs12
|
||||
// 2. Import PFX to Windows certificate store via Import-PfxCertificate
|
||||
// 3. Compute SHA-1 thumbprint (IIS certificate identifier)
|
||||
// 4. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
|
||||
// 5. Verify binding is active via Get-WebBinding
|
||||
type Connector struct {
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
config *Config
|
||||
logger *slog.Logger
|
||||
executor PowerShellExecutor
|
||||
}
|
||||
|
||||
// New creates a new IIS target connector with the given configuration and logger.
|
||||
// Uses the real PowerShell executor for production deployments.
|
||||
func New(config *Config, logger *slog.Logger) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
config: config,
|
||||
logger: logger,
|
||||
executor: &realExecutor{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithExecutor creates a new IIS target connector with an injected executor.
|
||||
// Used in tests to mock PowerShell execution on non-Windows platforms.
|
||||
func NewWithExecutor(config *Config, logger *slog.Logger, executor PowerShellExecutor) *Connector {
|
||||
return &Connector{
|
||||
config: config,
|
||||
logger: logger,
|
||||
executor: executor,
|
||||
}
|
||||
}
|
||||
|
||||
// validIISName matches safe IIS site names and cert store names.
|
||||
// Allows alphanumeric, spaces, underscores, hyphens, and dots.
|
||||
var validIISName = regexp.MustCompile(`^[a-zA-Z0-9 _\-\.]+$`)
|
||||
|
||||
// validateIISName checks that an IIS name field contains only safe characters.
|
||||
// This prevents PowerShell injection via malicious site or store names.
|
||||
func validateIISName(name, field string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%s is required", field)
|
||||
}
|
||||
if len(name) > 256 {
|
||||
return fmt.Errorf("%s exceeds maximum length (256 characters)", field)
|
||||
}
|
||||
if !validIISName.MatchString(name) {
|
||||
return fmt.Errorf("%s contains invalid characters (allowed: alphanumeric, space, underscore, hyphen, dot)", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validIPOrWildcard matches valid IP addresses or the wildcard "*".
|
||||
var validIPOrWildcard = regexp.MustCompile(`^(\*|(\d{1,3}\.){3}\d{1,3})$`)
|
||||
|
||||
// ValidateConfig checks that the IIS configuration is valid and accessible.
|
||||
// It verifies that we're on Windows and that the IIS site exists.
|
||||
//
|
||||
// TODO: Implement actual PowerShell checks.
|
||||
// It verifies field values, PowerShell availability, and optionally checks that
|
||||
// the IIS site exists and the cert store is accessible.
|
||||
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 IIS config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.SiteName == "" || cfg.CertStore == "" {
|
||||
return fmt.Errorf("IIS site_name and cert_store are required")
|
||||
// Validate required fields
|
||||
if err := validateIISName(cfg.SiteName, "site_name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateIISName(cfg.CertStore, "cert_store"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify we're on Windows
|
||||
if runtime.GOOS != "windows" {
|
||||
return fmt.Errorf("IIS connector only runs on Windows, got %s", runtime.GOOS)
|
||||
// Apply defaults
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 443
|
||||
}
|
||||
if cfg.IPAddress == "" {
|
||||
cfg.IPAddress = "*"
|
||||
}
|
||||
|
||||
// Validate port range
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
return fmt.Errorf("port must be between 1 and 65535, got %d", cfg.Port)
|
||||
}
|
||||
|
||||
// Validate IP address format
|
||||
if !validIPOrWildcard.MatchString(cfg.IPAddress) {
|
||||
return fmt.Errorf("ip_address must be a valid IPv4 address or '*', got %q", cfg.IPAddress)
|
||||
}
|
||||
|
||||
// Validate binding_info if provided (safe characters only)
|
||||
if cfg.BindingInfo != "" {
|
||||
if len(cfg.BindingInfo) > 512 {
|
||||
return fmt.Errorf("binding_info exceeds maximum length (512 characters)")
|
||||
}
|
||||
// Allow typical binding chars: alphanumeric, *, :, ., -
|
||||
validBinding := regexp.MustCompile(`^[a-zA-Z0-9\*\:\.\-]+$`)
|
||||
if !validBinding.MatchString(cfg.BindingInfo) {
|
||||
return fmt.Errorf("binding_info contains invalid characters")
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("validating IIS configuration",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore,
|
||||
"hostname", cfg.Hostname)
|
||||
"hostname", cfg.Hostname,
|
||||
"port", cfg.Port)
|
||||
|
||||
// TODO: Implement PowerShell check
|
||||
// In production:
|
||||
// 1. Run PowerShell command: Get-IISSite -Name {SiteName}
|
||||
// 2. Verify site exists and is running
|
||||
// 3. Check cert store: Get-Item -Path "Cert:\LocalMachine\{CertStore}"
|
||||
// Verify PowerShell is available
|
||||
if _, err := exec.LookPath("powershell.exe"); err != nil {
|
||||
return fmt.Errorf("powershell.exe not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Warn("IIS validation not yet fully implemented",
|
||||
"site_name", cfg.SiteName)
|
||||
// Verify IIS site exists
|
||||
siteCheckScript := fmt.Sprintf(`Get-Website -Name '%s' | Select-Object -ExpandProperty Name`, cfg.SiteName)
|
||||
output, err := c.executor.Execute(ctx, siteCheckScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("IIS site %q not found or inaccessible: %s (error: %w)", cfg.SiteName, strings.TrimSpace(output), err)
|
||||
}
|
||||
|
||||
// Verify cert store is accessible
|
||||
storeCheckScript := fmt.Sprintf(`Test-Path 'Cert:\LocalMachine\%s'`, cfg.CertStore)
|
||||
output, err = c.executor.Execute(ctx, storeCheckScript)
|
||||
if err != nil || !strings.Contains(strings.TrimSpace(output), "True") {
|
||||
return fmt.Errorf("certificate store %q is not accessible: %s", cfg.CertStore, strings.TrimSpace(output))
|
||||
}
|
||||
|
||||
c.config = &cfg
|
||||
c.logger.Info("IIS configuration validated",
|
||||
"site_name", cfg.SiteName,
|
||||
"cert_store", cfg.CertStore)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeployCertificate imports a certificate to the Windows certificate store and updates
|
||||
// the IIS binding to use the new certificate.
|
||||
//
|
||||
// The IIS deployment process (via PowerShell):
|
||||
// 1. Create a temporary PFX file from the certificate and existing private key
|
||||
// (Note: The private key is managed by the agent, not provided by the control plane)
|
||||
// 2. Import the PFX to the Windows certificate store (My store by default)
|
||||
// 3. Get the certificate thumbprint
|
||||
// 4. Update the IIS binding to use the new certificate by thumbprint
|
||||
// 5. Verify the binding is active
|
||||
//
|
||||
// TODO: Implement actual PowerShell commands:
|
||||
// - Import-PfxCertificate -FilePath {pfxPath} -CertStoreLocation "Cert:\LocalMachine\My"
|
||||
// - Get-ChildItem -Path "Cert:\LocalMachine\My" | Where {$_.Subject -eq "CN=..."}
|
||||
// - Set-WebBinding -Name {SiteName} -BindingInformation "{BindingInfo}" -Protocol https -SslFlags 1 -CertificateThumbprint {thumbprint}
|
||||
// Deployment flow:
|
||||
// 1. Convert PEM cert+key+chain to PFX format (go-pkcs12 with random password)
|
||||
// 2. Write PFX to temp file (cleaned up on exit, even on error)
|
||||
// 3. Compute SHA-1 thumbprint from DER cert (matches Windows certutil output)
|
||||
// 4. Import PFX to Windows cert store via Import-PfxCertificate
|
||||
// 5. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
|
||||
// 6. Return result with thumbprint in metadata
|
||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||
c.logger.Info("deploying certificate to IIS",
|
||||
"site_name", c.config.SiteName,
|
||||
@@ -104,44 +206,182 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS certificate deployment
|
||||
// In production:
|
||||
// 1. Create temporary PFX from CertPEM and ChainPEM
|
||||
// (Private key should already exist on the agent)
|
||||
// 2. Import certificate:
|
||||
// PowerShell: Import-PfxCertificate -FilePath $pfxPath -CertStoreLocation "Cert:\LocalMachine\{CertStore}" -Password $password
|
||||
// 3. Get certificate thumbprint:
|
||||
// PowerShell: (Get-ChildItem -Path "Cert:\LocalMachine\{CertStore}" | Where {$_.Subject -like "*CN=*"}).Thumbprint
|
||||
// 4. Update IIS binding:
|
||||
// PowerShell: Set-WebBinding -Name "{SiteName}" -BindingInformation "{BindingInfo}:443:*.example.com" -Protocol https -CertificateThumbprint $thumbprint
|
||||
// 5. Remove temporary PFX file
|
||||
// Validate we have a private key (required for PFX creation)
|
||||
if request.KeyPEM == "" {
|
||||
errMsg := "private key (KeyPEM) is required for IIS deployment"
|
||||
c.logger.Error("deployment failed", "error", errMsg)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Step 1: Create PFX from PEM inputs
|
||||
pfxPassword, err := generateRandomPassword(32)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to generate PFX password: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(request.CertPEM, request.KeyPEM, request.ChainPEM, pfxPassword)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create PFX: %v", err)
|
||||
c.logger.Error("PFX creation failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Step 2: Write PFX to temp file
|
||||
tmpFile, err := os.CreateTemp("", "certctl-*.pfx")
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to create temp PFX file: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
pfxPath := tmpFile.Name()
|
||||
defer os.Remove(pfxPath) // Always clean up temp PFX
|
||||
|
||||
if _, err := tmpFile.Write(pfxData); err != nil {
|
||||
tmpFile.Close()
|
||||
errMsg := fmt.Sprintf("failed to write temp PFX file: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Step 3: Compute thumbprint (SHA-1 of DER-encoded cert — matches Windows certutil)
|
||||
thumbprint, err := computeThumbprint(request.CertPEM)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to compute certificate thumbprint: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
c.logger.Debug("certificate thumbprint computed", "thumbprint", thumbprint)
|
||||
|
||||
// Step 4: Import PFX to Windows certificate store
|
||||
importScript := fmt.Sprintf(
|
||||
`$password = ConvertTo-SecureString -String '%s' -AsPlainText -Force; `+
|
||||
`Import-PfxCertificate -FilePath '%s' -CertStoreLocation 'Cert:\LocalMachine\%s' -Password $password`,
|
||||
pfxPassword, pfxPath, c.config.CertStore,
|
||||
)
|
||||
|
||||
output, err := c.executor.Execute(ctx, importScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("PFX import failed: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("PFX import failed",
|
||||
"error", err,
|
||||
"output", strings.TrimSpace(output),
|
||||
"cert_store", c.config.CertStore)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
c.logger.Info("PFX imported to certificate store",
|
||||
"cert_store", c.config.CertStore,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
// Step 5: Update IIS HTTPS binding
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
ipAddress := c.config.IPAddress
|
||||
if ipAddress == "" {
|
||||
ipAddress = "*"
|
||||
}
|
||||
hostHeader := c.config.BindingInfo
|
||||
sniFlag := 0
|
||||
if c.config.SNI {
|
||||
sniFlag = 1
|
||||
}
|
||||
|
||||
bindingScript := fmt.Sprintf(
|
||||
// Remove existing HTTPS binding on this port (if any), then create new one
|
||||
`$existing = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
|
||||
`if ($existing) { $existing | Remove-WebBinding }; `+
|
||||
`New-WebBinding -Name '%s' -Protocol 'https' -Port %d -IPAddress '%s' -HostHeader '%s' -SslFlags %d; `+
|
||||
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d; `+
|
||||
`$binding.AddSslCertificate('%s', '%s')`,
|
||||
c.config.SiteName, port,
|
||||
c.config.SiteName, port, ipAddress, hostHeader, sniFlag,
|
||||
c.config.SiteName, port,
|
||||
thumbprint, c.config.CertStore,
|
||||
)
|
||||
|
||||
output, err = c.executor.Execute(ctx, bindingScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("IIS binding update failed: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("IIS binding update failed",
|
||||
"error", err,
|
||||
"output", strings.TrimSpace(output),
|
||||
"site_name", c.config.SiteName)
|
||||
// Cert is imported but binding failed — partial success
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"cert_store": c.config.CertStore,
|
||||
"import_success": "true",
|
||||
"binding_error": strings.TrimSpace(output),
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
deploymentDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS deployment not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
c.logger.Info("certificate deployed to IIS successfully",
|
||||
"duration", deploymentDuration.String(),
|
||||
"site_name", c.config.SiteName,
|
||||
"thumbprint", thumbprint)
|
||||
|
||||
return &target.DeploymentResult{
|
||||
Success: true,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
DeploymentID: fmt.Sprintf("iis-%d", time.Now().Unix()),
|
||||
Message: "Certificate deployment to IIS initiated (stub)",
|
||||
DeploymentID: fmt.Sprintf("iis-%s-%d", thumbprint[:8], time.Now().Unix()),
|
||||
Message: "Certificate imported and IIS binding updated successfully",
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"cert_store": c.config.CertStore,
|
||||
"thumbprint": thumbprint,
|
||||
"port": fmt.Sprintf("%d", port),
|
||||
"sni": fmt.Sprintf("%t", c.config.SNI),
|
||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateDeployment verifies that the certificate is properly deployed in IIS.
|
||||
// It checks the IIS binding configuration to ensure it's active with the correct certificate.
|
||||
//
|
||||
// TODO: Implement actual PowerShell validation.
|
||||
// PowerShell command:
|
||||
// - Get-IISSiteBinding -Name {SiteName} | Where {$_.protocol -eq "https"}
|
||||
// It checks the IIS binding to ensure it's active with the correct certificate thumbprint.
|
||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||
c.logger.Info("validating IIS deployment",
|
||||
"certificate_id", request.CertificateID,
|
||||
@@ -150,33 +390,211 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// TODO: Implement IIS deployment validation
|
||||
// In production:
|
||||
// 1. Query IIS binding status:
|
||||
// PowerShell: Get-WebBinding -Name "{SiteName}" -Protocol "https"
|
||||
// 2. Verify binding exists and is active
|
||||
// 3. Extract certificate thumbprint from binding
|
||||
// 4. Query certificate store to verify thumbprint matches expected certificate
|
||||
// 5. Check certificate validity dates and key match
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
|
||||
// Query IIS binding for HTTPS on the configured port
|
||||
bindingScript := fmt.Sprintf(
|
||||
`$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; `+
|
||||
`if ($binding) { $binding.certificateHash } else { 'NO_BINDING' }`,
|
||||
c.config.SiteName, port,
|
||||
)
|
||||
|
||||
output, err := c.executor.Execute(ctx, bindingScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to query IIS binding: %v (output: %s)", err, strings.TrimSpace(output))
|
||||
c.logger.Error("validation failed", "error", err, "output", strings.TrimSpace(output))
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
bindingHash := strings.TrimSpace(output)
|
||||
if bindingHash == "NO_BINDING" || bindingHash == "" {
|
||||
errMsg := fmt.Sprintf("no HTTPS binding found on IIS site %q port %d", c.config.SiteName, port)
|
||||
c.logger.Error("validation failed", "error", errMsg)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Verify the certificate exists in the store
|
||||
certCheckScript := fmt.Sprintf(
|
||||
`$cert = Get-ChildItem -Path 'Cert:\LocalMachine\%s\%s' -ErrorAction SilentlyContinue; `+
|
||||
`if ($cert -and $cert.NotAfter -gt (Get-Date)) { 'VALID' } `+
|
||||
`elseif ($cert) { 'EXPIRED' } `+
|
||||
`else { 'NOT_FOUND' }`,
|
||||
c.config.CertStore, bindingHash,
|
||||
)
|
||||
|
||||
output, err = c.executor.Execute(ctx, certCheckScript)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("failed to verify certificate in store: %v", err)
|
||||
c.logger.Error("validation failed", "error", err)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
certStatus := strings.TrimSpace(output)
|
||||
validationDuration := time.Since(startTime)
|
||||
|
||||
c.logger.Warn("IIS validation not yet implemented",
|
||||
"site_name", c.config.SiteName)
|
||||
switch certStatus {
|
||||
case "VALID":
|
||||
c.logger.Info("IIS deployment validated successfully",
|
||||
"duration", validationDuration.String(),
|
||||
"thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: "Certificate is bound to IIS site and valid",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"site_name": c.config.SiteName,
|
||||
"cert_store": c.config.CertStore,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
|
||||
return &target.ValidationResult{
|
||||
Valid: true,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: "Certificate deployment validation initiated (stub)",
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"hostname": c.config.Hostname,
|
||||
"site_name": c.config.SiteName,
|
||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
||||
},
|
||||
}, nil
|
||||
case "EXPIRED":
|
||||
errMsg := fmt.Sprintf("certificate %s is expired in store %q", bindingHash, c.config.CertStore)
|
||||
c.logger.Error("validation failed: certificate expired", "thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"status": "expired",
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
|
||||
default: // NOT_FOUND or unexpected
|
||||
errMsg := fmt.Sprintf("certificate %s not found in store %q", bindingHash, c.config.CertStore)
|
||||
c.logger.Error("validation failed: certificate not in store", "thumbprint", bindingHash)
|
||||
return &target.ValidationResult{
|
||||
Valid: false,
|
||||
Serial: request.Serial,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: errMsg,
|
||||
ValidatedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": bindingHash,
|
||||
"status": "not_found",
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// executePowerShellCommand will be implemented in V3 when IIS target connector ships.
|
||||
// Pattern: exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command", psCommand)
|
||||
// createPFX converts PEM-encoded cert, key, and chain into PKCS#12 (PFX) format.
|
||||
// IIS requires PFX for certificate import. Uses go-pkcs12 Modern encoder
|
||||
// with strong encryption (same library used by M27 export service).
|
||||
func createPFX(certPEM, keyPEM, chainPEM string, password string) ([]byte, error) {
|
||||
// Parse leaf certificate
|
||||
certBlock, _ := pem.Decode([]byte(certPEM))
|
||||
if certBlock == nil || certBlock.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("failed to decode certificate PEM")
|
||||
}
|
||||
leafCert, err := x509.ParseCertificate(certBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse leaf certificate: %w", err)
|
||||
}
|
||||
|
||||
// Parse private key (supports PKCS#8, PKCS#1 RSA, and EC)
|
||||
keyBlock, _ := pem.Decode([]byte(keyPEM))
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("failed to decode private key PEM")
|
||||
}
|
||||
privateKey, err := parsePrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse CA chain certificates (optional)
|
||||
var caCerts []*x509.Certificate
|
||||
if chainPEM != "" {
|
||||
rest := []byte(chainPEM)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
caCert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
caCerts = append(caCerts, caCert)
|
||||
}
|
||||
}
|
||||
|
||||
// Encode as PKCS#12 with Modern encryption
|
||||
pfxData, err := pkcs12.Modern.Encode(privateKey, leafCert, caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PKCS#12: %w", err)
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// parsePrivateKey attempts to parse a DER-encoded private key.
|
||||
// Tries PKCS#8, PKCS#1 RSA, and EC formats in order.
|
||||
func parsePrivateKey(der []byte) (interface{}, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
if key, err := x509.ParseECPrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported private key format")
|
||||
}
|
||||
|
||||
// computeThumbprint calculates the SHA-1 thumbprint of a PEM-encoded certificate.
|
||||
// IIS uses SHA-1 thumbprints as the primary certificate identifier.
|
||||
// Returns uppercase hex string matching Windows certutil output.
|
||||
func computeThumbprint(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("failed to decode certificate PEM for thumbprint")
|
||||
}
|
||||
hash := sha1.Sum(block.Bytes)
|
||||
return strings.ToUpper(hex.EncodeToString(hash[:])), nil
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a random alphanumeric password for transient PFX encryption.
|
||||
// The password is only used between PFX creation and import — it never persists.
|
||||
func generateRandomPassword(length int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,845 @@
|
||||
package iis
|
||||
|
||||
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"
|
||||
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// mockExecutor records PowerShell commands and returns configurable responses.
|
||||
type mockExecutor struct {
|
||||
// commands records all scripts passed to Execute in order
|
||||
commands []string
|
||||
// responses maps script substrings to (output, error) pairs.
|
||||
// First matching substring wins.
|
||||
responses map[string]mockResponse
|
||||
// defaultOutput is returned when no response matches
|
||||
defaultOutput string
|
||||
// defaultErr is returned when no response matches
|
||||
defaultErr error
|
||||
}
|
||||
|
||||
type mockResponse struct {
|
||||
output string
|
||||
err error
|
||||
}
|
||||
|
||||
func newMockExecutor() *mockExecutor {
|
||||
return &mockExecutor{
|
||||
responses: make(map[string]mockResponse),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockExecutor) Execute(ctx context.Context, script string) (string, error) {
|
||||
m.commands = append(m.commands, script)
|
||||
for substr, resp := range m.responses {
|
||||
if strings.Contains(script, substr) {
|
||||
return resp.output, resp.err
|
||||
}
|
||||
}
|
||||
return m.defaultOutput, m.defaultErr
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
||||
// --- ValidateConfig tests ---
|
||||
|
||||
func TestIISConnector_ValidateConfig_Success(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-Website"] = mockResponse{output: "Default Web Site\n", err: nil}
|
||||
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
||||
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}
|
||||
|
||||
// We need powershell.exe in PATH for LookPath — skip on non-Windows
|
||||
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
// On non-Windows, LookPath("powershell.exe") will fail.
|
||||
// We test the validation logic up to that point by checking the error message.
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err != nil {
|
||||
// If it's just a "powershell not found" error, that's expected on Linux
|
||||
if strings.Contains(err.Error(), "powershell.exe not found") {
|
||||
t.Skip("Skipping: powershell.exe not available (non-Windows)")
|
||||
}
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidJSON(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
err := connector.ValidateConfig(context.Background(), json.RawMessage(`{invalid}`))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid IIS config") {
|
||||
t.Errorf("expected 'invalid IIS config' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_MissingSiteName(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{CertStore: "My"}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing site_name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "site_name") {
|
||||
t.Errorf("expected error about site_name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_MissingCertStore(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{SiteName: "Default Web Site"}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing cert_store")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cert_store") {
|
||||
t.Errorf("expected error about cert_store, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidSiteName_Injection(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{
|
||||
SiteName: "Default'; Drop-Database",
|
||||
CertStore: "My",
|
||||
}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for injection characters in site_name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid characters") {
|
||||
t.Errorf("expected 'invalid characters' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidCertStore_Injection(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My$(whoami)",
|
||||
}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for injection characters in cert_store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidPort(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 99999,
|
||||
}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid port")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "port") {
|
||||
t.Errorf("expected error about port, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_InvalidIPAddress(t *testing.T) {
|
||||
connector := NewWithExecutor(&Config{}, testLogger(), newMockExecutor())
|
||||
cfg := Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
IPAddress: "not_an_ip",
|
||||
}
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid IP address")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ip_address") {
|
||||
t.Errorf("expected error about ip_address, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateConfig_DefaultValues(t *testing.T) {
|
||||
// Test that defaults are applied (port 443, IP *)
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-Website"] = mockResponse{output: "TestSite\n", err: nil}
|
||||
executor.responses["Test-Path"] = mockResponse{output: "True\n", err: nil}
|
||||
|
||||
cfg := Config{
|
||||
SiteName: "TestSite",
|
||||
CertStore: "WebHosting",
|
||||
// Port and IPAddress intentionally left empty
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&cfg, testLogger(), executor)
|
||||
rawConfig, _ := json.Marshal(cfg)
|
||||
|
||||
err := connector.ValidateConfig(context.Background(), rawConfig)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "powershell.exe not found") {
|
||||
t.Skip("Skipping: powershell.exe not available (non-Windows)")
|
||||
}
|
||||
t.Fatalf("ValidateConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify defaults were applied
|
||||
if connector.config.Port != 443 {
|
||||
t.Errorf("expected default port 443, got %d", connector.config.Port)
|
||||
}
|
||||
if connector.config.IPAddress != "*" {
|
||||
t.Errorf("expected default IP '*', got %s", connector.config.IPAddress)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeployCertificate tests ---
|
||||
|
||||
// generateTestCertAndKey creates a self-signed ECDSA P-256 cert+key for testing.
|
||||
func generateTestCertAndKey() (certPEM, keyPEM, chainPEM string, err 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(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"test.example.com"},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
certPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
keyPEMStr := string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
// Use the self-signed cert as its own "chain" for testing
|
||||
chainPEMStr := certPEMStr
|
||||
|
||||
return certPEMStr, keyPEMStr, chainPEMStr, nil
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_Success(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
executor.defaultOutput = "OK"
|
||||
|
||||
cfg := &Config{
|
||||
Hostname: "web01.example.com",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
IPAddress: "*",
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(cfg, testLogger(), executor)
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify thumbprint is in metadata
|
||||
if result.Metadata["thumbprint"] == "" {
|
||||
t.Error("expected thumbprint in metadata")
|
||||
}
|
||||
// SHA-1 thumbprint = 40 hex chars uppercase
|
||||
if len(result.Metadata["thumbprint"]) != 40 {
|
||||
t.Errorf("expected 40-char thumbprint, got %d", len(result.Metadata["thumbprint"]))
|
||||
}
|
||||
|
||||
// Verify both import and binding scripts were executed
|
||||
if len(executor.commands) != 2 {
|
||||
t.Errorf("expected 2 PowerShell commands, got %d", len(executor.commands))
|
||||
}
|
||||
|
||||
// First command should be PFX import
|
||||
if len(executor.commands) > 0 && !strings.Contains(executor.commands[0], "Import-PfxCertificate") {
|
||||
t.Errorf("expected Import-PfxCertificate in first command, got: %s", executor.commands[0])
|
||||
}
|
||||
|
||||
// Second command should be binding update
|
||||
if len(executor.commands) > 1 && !strings.Contains(executor.commands[1], "New-WebBinding") {
|
||||
t.Errorf("expected New-WebBinding in second command, got: %s", executor.commands[1])
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if result.Metadata["site_name"] != "Default Web Site" {
|
||||
t.Errorf("expected site_name in metadata")
|
||||
}
|
||||
if result.Metadata["cert_store"] != "My" {
|
||||
t.Errorf("expected cert_store in metadata")
|
||||
}
|
||||
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||
t.Error("expected duration_ms in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_MissingKeyPEM(t *testing.T) {
|
||||
certPEM, _, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
}, testLogger(), newMockExecutor())
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: "", // Missing key
|
||||
ChainPEM: chainPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing KeyPEM")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "private key") {
|
||||
t.Errorf("expected error about private key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_InvalidCertPEM(t *testing.T) {
|
||||
_, keyPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test key: %v", err)
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
}, testLogger(), newMockExecutor())
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: "not a valid cert",
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert PEM")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_InvalidKeyPEM(t *testing.T) {
|
||||
certPEM, _, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
}, testLogger(), newMockExecutor())
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: "not a valid key",
|
||||
ChainPEM: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key PEM")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_ImportFails(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Import-PfxCertificate"] = mockResponse{
|
||||
output: "Access denied",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PFX import fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PFX import failed") {
|
||||
t.Errorf("expected 'PFX import failed' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_BindingFails(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
// Import succeeds
|
||||
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
||||
// Binding fails
|
||||
executor.responses["New-WebBinding"] = mockResponse{
|
||||
output: "The website 'Default Web Site' already has a binding",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when binding update fails")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
// Partial success: cert was imported but binding failed
|
||||
if result.Metadata["import_success"] != "true" {
|
||||
t.Error("expected import_success=true in metadata (cert imported but binding failed)")
|
||||
}
|
||||
if result.Metadata["thumbprint"] == "" {
|
||||
t.Error("expected thumbprint in metadata even on binding failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_SNIEnabled(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
executor.defaultOutput = "OK"
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
SNI: true,
|
||||
BindingInfo: "test.example.com",
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.DeployCertificate(context.Background(), target.DeploymentRequest{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: chainPEM,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DeployCertificate failed: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("expected success, got: %s", result.Message)
|
||||
}
|
||||
|
||||
// Verify SNI flag was passed in the binding script
|
||||
if len(executor.commands) < 2 {
|
||||
t.Fatal("expected at least 2 commands")
|
||||
}
|
||||
bindingCmd := executor.commands[1]
|
||||
if !strings.Contains(bindingCmd, "-SslFlags 1") {
|
||||
t.Errorf("expected -SslFlags 1 for SNI, got: %s", bindingCmd)
|
||||
}
|
||||
if result.Metadata["sni"] != "true" {
|
||||
t.Error("expected sni=true in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
// --- ValidateDeployment tests ---
|
||||
|
||||
func TestIISConnector_ValidateDeployment_Success(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-WebBinding"] = mockResponse{output: "ABC123DEF456\n", err: nil}
|
||||
executor.responses["Get-ChildItem"] = mockResponse{output: "VALID\n", err: nil}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDeployment failed: %v", err)
|
||||
}
|
||||
if !result.Valid {
|
||||
t.Fatalf("expected valid deployment, got: %s", result.Message)
|
||||
}
|
||||
if result.Metadata["thumbprint"] != "ABC123DEF456" {
|
||||
t.Errorf("expected thumbprint in metadata, got: %s", result.Metadata["thumbprint"])
|
||||
}
|
||||
if _, ok := result.Metadata["duration_ms"]; !ok {
|
||||
t.Error("expected duration_ms in metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateDeployment_NoBinding(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-WebBinding"] = mockResponse{output: "NO_BINDING\n", err: nil}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "TestSite",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no binding found")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no HTTPS binding found") {
|
||||
t.Errorf("expected 'no HTTPS binding found' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateDeployment_CertNotInStore(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
|
||||
executor.responses["Get-ChildItem"] = mockResponse{output: "NOT_FOUND\n", err: nil}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when cert not in store")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
if result.Metadata["status"] != "not_found" {
|
||||
t.Errorf("expected status=not_found in metadata, got: %s", result.Metadata["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateDeployment_CertExpired(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-WebBinding"] = mockResponse{output: "DEADBEEF1234\n", err: nil}
|
||||
executor.responses["Get-ChildItem"] = mockResponse{output: "EXPIRED\n", err: nil}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when cert is expired")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
if result.Metadata["status"] != "expired" {
|
||||
t.Errorf("expected status=expired in metadata, got: %s", result.Metadata["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_ValidateDeployment_QueryFails(t *testing.T) {
|
||||
executor := newMockExecutor()
|
||||
executor.responses["Get-WebBinding"] = mockResponse{
|
||||
output: "Permission denied",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
SiteName: "Default Web Site",
|
||||
CertStore: "My",
|
||||
Port: 443,
|
||||
}, testLogger(), executor)
|
||||
|
||||
result, err := connector.ValidateDeployment(context.Background(), target.ValidationRequest{
|
||||
CertificateID: "mc-test",
|
||||
Serial: "123456",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when query fails")
|
||||
}
|
||||
if result.Valid {
|
||||
t.Fatal("expected invalid result")
|
||||
}
|
||||
}
|
||||
|
||||
// --- PFX conversion tests (pure Go crypto, runs on any OS) ---
|
||||
|
||||
func TestCreatePFX_Success(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(certPEM, keyPEM, chainPEM, "testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("createPFX failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pfxData) == 0 {
|
||||
t.Fatal("expected non-empty PFX data")
|
||||
}
|
||||
|
||||
// Verify PFX is parseable
|
||||
_, _, _, err = pkcs12.DecodeChain(pfxData, "testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("PFX data is not valid PKCS#12: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_NoChain(t *testing.T) {
|
||||
certPEM, keyPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
pfxData, err := createPFX(certPEM, keyPEM, "", "testpassword")
|
||||
if err != nil {
|
||||
t.Fatalf("createPFX with no chain failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pfxData) == 0 {
|
||||
t.Fatal("expected non-empty PFX data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_InvalidCert(t *testing.T) {
|
||||
_, keyPEM, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test key: %v", err)
|
||||
}
|
||||
|
||||
_, err = createPFX("not a valid cert", keyPEM, "", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid cert PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePFX_InvalidKey(t *testing.T) {
|
||||
certPEM, _, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
_, err = createPFX(certPEM, "not a valid key", "", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid key PEM")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Thumbprint tests ---
|
||||
|
||||
func TestComputeThumbprint_Success(t *testing.T) {
|
||||
certPEM, _, _, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
thumbprint, err := computeThumbprint(certPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("computeThumbprint failed: %v", err)
|
||||
}
|
||||
|
||||
// SHA-1 = 20 bytes = 40 hex chars
|
||||
if len(thumbprint) != 40 {
|
||||
t.Errorf("expected 40-char thumbprint, got %d chars: %s", len(thumbprint), thumbprint)
|
||||
}
|
||||
|
||||
// Should be uppercase hex
|
||||
if thumbprint != strings.ToUpper(thumbprint) {
|
||||
t.Errorf("thumbprint should be uppercase, got: %s", thumbprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_InvalidPEM(t *testing.T) {
|
||||
_, err := computeThumbprint("not a valid pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid PEM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeThumbprint_EmptyString(t *testing.T) {
|
||||
_, err := computeThumbprint("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validation helper tests ---
|
||||
|
||||
func TestValidateIISName_Valid(t *testing.T) {
|
||||
tests := []string{
|
||||
"Default Web Site",
|
||||
"My",
|
||||
"WebHosting",
|
||||
"site-01",
|
||||
"my_site.prod",
|
||||
"Test 123",
|
||||
}
|
||||
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateIISName(name, "test_field"); err != nil {
|
||||
t.Errorf("expected valid name %q, got error: %v", name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIISName_Invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"semicolon", "My;Store"},
|
||||
{"dollar", "My$Store"},
|
||||
{"backtick", "My`Store"},
|
||||
{"pipe", "My|Store"},
|
||||
{"ampersand", "My&Store"},
|
||||
{"parentheses", "My(Store)"},
|
||||
{"quotes", `My"Store"`},
|
||||
{"angle_brackets", "My<Store>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateIISName(tt.input, "test_field"); err == nil {
|
||||
t.Errorf("expected error for name %q", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIISName_TooLong(t *testing.T) {
|
||||
longName := strings.Repeat("a", 257)
|
||||
if err := validateIISName(longName, "test_field"); err == nil {
|
||||
t.Fatal("expected error for name exceeding 256 chars")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Random password generation ---
|
||||
|
||||
func TestGenerateRandomPassword(t *testing.T) {
|
||||
pw, err := generateRandomPassword(32)
|
||||
if err != nil {
|
||||
t.Fatalf("generateRandomPassword failed: %v", err)
|
||||
}
|
||||
if len(pw) != 32 {
|
||||
t.Errorf("expected 32-char password, got %d", len(pw))
|
||||
}
|
||||
|
||||
// Verify it only contains allowed characters
|
||||
for _, c := range pw {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
|
||||
t.Errorf("unexpected character in password: %c", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify two passwords are different (probabilistic but reliable)
|
||||
pw2, _ := generateRandomPassword(32)
|
||||
if pw == pw2 {
|
||||
t.Error("two generated passwords should be different")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user