mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:41:41 +00:00
iis: pre-deploy binding snapshot + on-failure rollback
Closes Bundle 5 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
DeployCertificate at iis.go:235-436 imported the cert via
Import-PfxCertificate (atomic at cert-store level) then ran a
separate PowerShell script for the SNI binding update. If the
binding script failed, the new cert was orphaned in the store AND
the old binding stayed pointed at the old thumbprint.
docs/deployment-atomicity.md L91 promised "explicit pre-deploy
backup + post-rollback re-import"; the code didn't deliver.
This commit:
1. Pre-deploy snapshot. snapshotOldBinding runs Get-WebBinding
before the import; parses the bound SSL thumbprint into a local
`oldThumbprint` variable. Empty = first-time binding (no
rollback target).
2. On-failure rollback script. When the binding-update Execute
returns error, rollbackBinding runs a single PowerShell script
that:
- Remove-Item Cert:\LocalMachine\<store>\<newThumbprint> (delete
the cert we just imported but couldn't bind).
- If oldThumbprint != "", AddSslCertificate('<oldThumbprint>',
...) to re-bind the old cert. Falls through to New-WebBinding
+ AddSslCertificate when the old binding entry is also gone.
3. Post-rollback verification. verifyRollback re-reads
Get-WebBinding; asserts the bound thumbprint matches
oldThumbprint. On mismatch, warn in the DeploymentResult
message — the rollback ran but final state is suspect, operator
inspection required. Skipped when oldThumbprint == "" (no
binding to verify against).
4. Helper extraction. snapshotOldBinding / rollbackBinding /
verifyRollback are private methods on Connector for clean test
seams. Each emits a unique `# CERTCTL_*` PowerShell comment tag
so test mocks can match scripts deterministically — multiple
scripts call Get-WebBinding so substring matching otherwise
collides under Go's randomized map iteration order.
DeploymentResult shape on failure:
- rollback OK → Success=false, Message="binding update failed;
rolled back", clean error.
- rollback FAIL → Success=false, wrapped error containing both
binding error and rollback error; metadata
flags manual_action_required=true and surfaces
rollback_error / binding_error verbatim.
Tests added to iis_test.go:
- TestIIS_BindingUpdateFails_RemovesNewCert_RebindsOld — happy
rollback path. Mock executor queued with snapshot →
OLD_THUMBPRINT:abc123, import OK, binding fails, rollback →
REBOUND_EXISTING. Asserts rollback script contains both
Remove-Item for the new thumbprint AND
AddSslCertificate('abc123', ...).
- TestIIS_BindingUpdateFails_NoOldBinding_RemovesNewCertOnly —
first-time deploy variant. Snapshot returns NO_OLD_BINDING;
rollback removes the new cert but does NOT call
AddSslCertificate; verify script never runs.
- TestIIS_BindingUpdateFails_RollbackAlsoFails_OperatorActionable
— wrapped-error escalation. Asserts the returned error mentions
both `binding update failed` and `rollback also failed`, and
metadata flags manual_action_required=true.
Two existing tests (TestIISConnector_DeployCertificate_Success and
…_SNIEnabled) updated to expect 3 commands (snapshot, import,
binding) and to look for the binding script at commands[2].
docs/deployment-atomicity.md L91 unchanged from today's text — the
"Already explicit pre-deploy backup + post-rollback re-import"
claim is now honest. (Bundle 1 doc-realignment hasn't shipped yet,
so there's no softened-pending claim to restore.)
Verified locally (sandbox lacks staticcheck install due to disk
pressure, ran via go vet + go test -race; CI runs the full lint
gate):
- gofmt -l ./internal/connector/target/iis/ clean
- go vet ./internal/connector/target/iis/... clean
- go build ./internal/connector/target/iis/... clean
- go test -race -count=1 ./internal/connector/target/iis/ green
Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 5.
This commit is contained in:
@@ -226,12 +226,16 @@ func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessag
|
||||
// the IIS binding to use the new certificate.
|
||||
//
|
||||
// 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
|
||||
// 1. Snapshot the existing binding's SSL cert thumbprint via Get-WebBinding
|
||||
// (Bundle 5: enables rollback on binding-update failure)
|
||||
// 2. Convert PEM cert+key+chain to PFX format (go-pkcs12 with random password)
|
||||
// 3. Write PFX to temp file (cleaned up on exit, even on error)
|
||||
// 4. Compute SHA-1 thumbprint from DER cert (matches Windows certutil output)
|
||||
// 5. Import PFX to Windows cert store via Import-PfxCertificate
|
||||
// 6. Update IIS HTTPS binding via New-WebBinding + AddSslCertificate
|
||||
// 7. On binding failure (Bundle 5): rollback — remove new cert from store
|
||||
// and re-bind old thumbprint (if any); verify rollback via Get-WebBinding
|
||||
// 8. 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,
|
||||
@@ -250,6 +254,27 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Bundle 5 (2026-05-02 deployment-target audit): pre-deploy snapshot
|
||||
// of the existing binding's SSL thumbprint so a binding-update failure
|
||||
// can roll back to the pre-deploy state. Empty oldThumbprint means
|
||||
// there is no existing binding (first-time deploy) — rollback removes
|
||||
// the new cert but does not re-bind anything.
|
||||
oldThumbprint, err := c.snapshotOldBinding(ctx)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("pre-deploy binding snapshot failed: %v", err)
|
||||
c.logger.Error("deployment failed", "error", err)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
Message: errMsg,
|
||||
DeployedAt: time.Now(),
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if oldThumbprint != "" {
|
||||
c.logger.Debug("pre-deploy binding snapshot captured", "old_thumbprint", oldThumbprint)
|
||||
} else {
|
||||
c.logger.Debug("pre-deploy snapshot: no existing binding (first-time deploy)")
|
||||
}
|
||||
|
||||
// Step 1: Create PFX from PEM inputs
|
||||
pfxPassword, err := certutil.GenerateRandomPassword(32)
|
||||
if err != nil {
|
||||
@@ -391,12 +416,61 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
|
||||
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
|
||||
bindingErr := err
|
||||
bindingOutput := strings.TrimSpace(output)
|
||||
c.logger.Error("IIS binding update failed; attempting rollback",
|
||||
"error", bindingErr,
|
||||
"output", bindingOutput,
|
||||
"site_name", c.config.SiteName,
|
||||
"new_thumbprint", thumbprint,
|
||||
"old_thumbprint", oldThumbprint)
|
||||
|
||||
// Bundle 5: roll back. Remove the freshly-imported cert from the
|
||||
// store; if there was an old binding, re-bind the old thumbprint.
|
||||
// Then verify the rollback by re-reading Get-WebBinding.
|
||||
rbErr := c.rollbackBinding(ctx, oldThumbprint, thumbprint)
|
||||
if rbErr != nil {
|
||||
// Operator-actionable: binding update AND rollback both failed.
|
||||
// The cert store may contain the orphaned new cert AND the
|
||||
// binding may be in an indeterminate state. Surface both
|
||||
// errors and flag for manual inspection.
|
||||
c.logger.Error("IIS rollback also failed",
|
||||
"binding_error", bindingErr,
|
||||
"rollback_error", rbErr,
|
||||
"new_thumbprint", thumbprint,
|
||||
"old_thumbprint", oldThumbprint)
|
||||
combined := fmt.Errorf("binding update failed (%w) AND rollback also failed (%v); manual operator inspection required", bindingErr, rbErr)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
Message: combined.Error(),
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"old_thumbprint": oldThumbprint,
|
||||
"cert_store": c.config.CertStore,
|
||||
"binding_error": bindingOutput,
|
||||
"rollback_error": rbErr.Error(),
|
||||
"rolled_back": "false",
|
||||
"manual_action_required": "true",
|
||||
},
|
||||
}, combined
|
||||
}
|
||||
|
||||
// Rollback succeeded. Best-effort verification — non-fatal warning
|
||||
// if the verify probe disagrees (only fires when there was an old
|
||||
// thumbprint to verify against).
|
||||
verifyNote := ""
|
||||
if oldThumbprint != "" {
|
||||
if vErr := c.verifyRollback(ctx, oldThumbprint); vErr != nil {
|
||||
verifyNote = fmt.Sprintf(" (warning: %v)", vErr)
|
||||
c.logger.Warn("IIS rollback verification disagreed",
|
||||
"error", vErr,
|
||||
"old_thumbprint", oldThumbprint)
|
||||
}
|
||||
}
|
||||
|
||||
errMsg := fmt.Sprintf("IIS binding update failed; rolled back%s: %v (output: %s)", verifyNote, bindingErr, bindingOutput)
|
||||
return &target.DeploymentResult{
|
||||
Success: false,
|
||||
TargetAddress: fmt.Sprintf("%s (IIS: %s)", c.config.Hostname, c.config.SiteName),
|
||||
@@ -404,9 +478,10 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
||||
DeployedAt: time.Now(),
|
||||
Metadata: map[string]string{
|
||||
"thumbprint": thumbprint,
|
||||
"old_thumbprint": oldThumbprint,
|
||||
"cert_store": c.config.CertStore,
|
||||
"import_success": "true",
|
||||
"binding_error": strings.TrimSpace(output),
|
||||
"binding_error": bindingOutput,
|
||||
"rolled_back": "true",
|
||||
},
|
||||
}, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
@@ -559,6 +634,134 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotOldBinding returns the SSL certificate thumbprint currently bound to
|
||||
// the configured (site, port). Returns "" + nil if there is no existing
|
||||
// binding (first-time deploy — rollback removes the new cert but does not
|
||||
// re-bind anything). Returns "" + error if the snapshot script itself fails;
|
||||
// the caller bails out of the deploy entirely (no cert-store mutation has
|
||||
// happened yet).
|
||||
//
|
||||
// Bundle 5 of the 2026-05-02 deployment-target audit.
|
||||
func (c *Connector) snapshotOldBinding(ctx context.Context) (string, error) {
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
// The "# CERTCTL_SNAPSHOT" comment tag makes the script uniquely
|
||||
// identifiable to test mocks via strings.Contains, isolating it from
|
||||
// the binding-update / rollback / verify scripts which all also call
|
||||
// Get-WebBinding.
|
||||
script := fmt.Sprintf(
|
||||
"# CERTCTL_SNAPSHOT\n"+
|
||||
"$existing = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; "+
|
||||
"if ($existing -and $existing.certificateHash) { Write-Output ('OLD_THUMBPRINT:' + $existing.certificateHash) } "+
|
||||
"else { Write-Output 'NO_OLD_BINDING' }",
|
||||
c.config.SiteName, port)
|
||||
output, err := c.executor.Execute(ctx, script)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Get-WebBinding snapshot: %w (output: %s)", err, strings.TrimSpace(output))
|
||||
}
|
||||
out := strings.TrimSpace(output)
|
||||
if strings.HasPrefix(out, "OLD_THUMBPRINT:") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(out, "OLD_THUMBPRINT:")), nil
|
||||
}
|
||||
// "NO_OLD_BINDING" or any other unexpected output — treat as
|
||||
// first-time deploy (no rollback target).
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// rollbackBinding removes the freshly-imported cert (newThumbprint) from the
|
||||
// configured store and, if oldThumbprint is non-empty, re-binds the old cert
|
||||
// via AddSslCertificate. Falls through to New-WebBinding + AddSslCertificate
|
||||
// when the old binding entry has been removed (e.g. by the failed binding
|
||||
// script's Remove-WebBinding step).
|
||||
//
|
||||
// Bundle 5 of the 2026-05-02 deployment-target audit. The "# CERTCTL_ROLLBACK"
|
||||
// comment tag identifies the script to test mocks.
|
||||
func (c *Connector) rollbackBinding(ctx context.Context, oldThumbprint, newThumbprint string) error {
|
||||
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
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# CERTCTL_ROLLBACK\n")
|
||||
// Always remove the freshly-imported cert. Even when oldThumbprint is
|
||||
// empty (first-time deploy), the new cert must come out so the store
|
||||
// is left in pre-deploy state.
|
||||
fmt.Fprintf(&b,
|
||||
"Remove-Item -Path 'Cert:\\LocalMachine\\%s\\%s' -Force -ErrorAction SilentlyContinue; ",
|
||||
c.config.CertStore, newThumbprint)
|
||||
if oldThumbprint != "" {
|
||||
// Re-bind the old cert. Two branches: if Get-WebBinding still
|
||||
// returns a binding (the failed bindingScript's Remove-WebBinding
|
||||
// either ran and a new binding was partially created, or didn't
|
||||
// run), AddSslCertificate against it. If no binding exists,
|
||||
// recreate via New-WebBinding + AddSslCertificate.
|
||||
fmt.Fprintf(&b,
|
||||
"$binding = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; "+
|
||||
"if ($binding) { $binding.AddSslCertificate('%s', '%s'); Write-Output 'REBOUND_EXISTING' } "+
|
||||
"else { New-WebBinding -Name '%s' -Protocol 'https' -Port %d -IPAddress '%s' -HostHeader '%s' -SslFlags %d; "+
|
||||
"$nb = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d; "+
|
||||
"$nb.AddSslCertificate('%s', '%s'); Write-Output 'REBOUND_NEW' }",
|
||||
c.config.SiteName, port,
|
||||
oldThumbprint, c.config.CertStore,
|
||||
c.config.SiteName, port, ipAddress, hostHeader, sniFlag,
|
||||
c.config.SiteName, port,
|
||||
oldThumbprint, c.config.CertStore)
|
||||
} else {
|
||||
b.WriteString("Write-Output 'CERT_REMOVED_NO_REBIND'")
|
||||
}
|
||||
|
||||
output, err := c.executor.Execute(ctx, b.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("rollback script: %w (output: %s)", err, strings.TrimSpace(output))
|
||||
}
|
||||
c.logger.Info("IIS rollback completed",
|
||||
"old_thumbprint", oldThumbprint,
|
||||
"new_thumbprint", newThumbprint,
|
||||
"output", strings.TrimSpace(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyRollback re-reads Get-WebBinding and confirms the bound thumbprint
|
||||
// matches oldThumbprint. Returns nil on match; returns a non-fatal warning
|
||||
// error on mismatch (the rollback's Remove-Item already ran; the verify is
|
||||
// best-effort confirmation that the rebind succeeded).
|
||||
//
|
||||
// Bundle 5 of the 2026-05-02 deployment-target audit.
|
||||
func (c *Connector) verifyRollback(ctx context.Context, oldThumbprint string) error {
|
||||
port := c.config.Port
|
||||
if port == 0 {
|
||||
port = 443
|
||||
}
|
||||
script := fmt.Sprintf(
|
||||
"# CERTCTL_VERIFY\n"+
|
||||
"$check = Get-WebBinding -Name '%s' -Protocol 'https' -Port %d -ErrorAction SilentlyContinue; "+
|
||||
"if ($check -and $check.certificateHash -eq '%s') { Write-Output 'VERIFY_OK' } "+
|
||||
"elseif ($check) { Write-Output ('VERIFY_FAILED:' + $check.certificateHash) } "+
|
||||
"else { Write-Output 'VERIFY_FAILED:NO_BINDING' }",
|
||||
c.config.SiteName, port, oldThumbprint)
|
||||
output, err := c.executor.Execute(ctx, script)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify probe: %w", err)
|
||||
}
|
||||
out := strings.TrimSpace(output)
|
||||
if out == "VERIFY_OK" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("rollback verification disagreed: %s", out)
|
||||
}
|
||||
|
||||
// NOTE: PFX creation, key parsing, thumbprint computation, and password generation
|
||||
// have been extracted to the shared certutil package (internal/connector/target/certutil)
|
||||
// for reuse by WinCertStore and JavaKeystore connectors.
|
||||
|
||||
@@ -316,19 +316,25 @@ func TestIISConnector_DeployCertificate_Success(t *testing.T) {
|
||||
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))
|
||||
// Bundle 5: snapshot script runs FIRST, then import, then binding.
|
||||
// Three PowerShell commands total on the success path.
|
||||
if len(executor.commands) != 3 {
|
||||
t.Errorf("expected 3 PowerShell commands (snapshot, import, binding), 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])
|
||||
// First command should be the Bundle 5 snapshot.
|
||||
if len(executor.commands) > 0 && !strings.Contains(executor.commands[0], "# CERTCTL_SNAPSHOT") {
|
||||
t.Errorf("expected # CERTCTL_SNAPSHOT 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])
|
||||
// Second command should be PFX import.
|
||||
if len(executor.commands) > 1 && !strings.Contains(executor.commands[1], "Import-PfxCertificate") {
|
||||
t.Errorf("expected Import-PfxCertificate in second command, got: %s", executor.commands[1])
|
||||
}
|
||||
|
||||
// Third command should be binding update.
|
||||
if len(executor.commands) > 2 && !strings.Contains(executor.commands[2], "New-WebBinding") {
|
||||
t.Errorf("expected New-WebBinding in third command, got: %s", executor.commands[2])
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
@@ -451,20 +457,48 @@ func TestIISConnector_DeployCertificate_ImportFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIISConnector_DeployCertificate_BindingFails(t *testing.T) {
|
||||
// --- Bundle 5: pre-deploy binding snapshot + on-failure rollback ---
|
||||
//
|
||||
// Mock matchers below use the unique `# CERTCTL_*` PowerShell comment tags
|
||||
// inserted by snapshotOldBinding / rollbackBinding / verifyRollback. The
|
||||
// binding-update script is matched via "Remove-WebBinding" — that token is
|
||||
// only present in the binding-update script (the rollback script uses
|
||||
// "Remove-Item" instead, and the snapshot/verify scripts only read state).
|
||||
// The import script is matched via "Import-PfxCertificate" (only present
|
||||
// in the import script). This isolation is required because the rollback
|
||||
// script's no-old-binding fallback branch contains "New-WebBinding", which
|
||||
// would otherwise collide with the binding-update script and produce
|
||||
// non-deterministic mock matching under Go's randomized map iteration.
|
||||
|
||||
func TestIIS_BindingUpdateFails_RemovesNewCert_RebindsOld(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
// Import succeeds
|
||||
// Snapshot returns a pre-existing thumbprint (rollback target).
|
||||
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
||||
output: "OLD_THUMBPRINT:abc123\n",
|
||||
err: nil,
|
||||
}
|
||||
// Import succeeds.
|
||||
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
||||
// Binding fails
|
||||
executor.responses["New-WebBinding"] = mockResponse{
|
||||
// Binding update fails.
|
||||
executor.responses["Remove-WebBinding"] = mockResponse{
|
||||
output: "The website 'Default Web Site' already has a binding",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
}
|
||||
// Rollback succeeds.
|
||||
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
||||
output: "REBOUND_EXISTING\n",
|
||||
err: nil,
|
||||
}
|
||||
// Verify confirms old thumbprint is back.
|
||||
executor.responses["# CERTCTL_VERIFY"] = mockResponse{
|
||||
output: "VERIFY_OK\n",
|
||||
err: nil,
|
||||
}
|
||||
|
||||
connector := NewWithExecutor(&Config{
|
||||
Hostname: "web01",
|
||||
@@ -484,12 +518,203 @@ func TestIISConnector_DeployCertificate_BindingFails(t *testing.T) {
|
||||
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 !strings.Contains(err.Error(), "binding update failed") {
|
||||
t.Errorf("expected error to mention 'binding update failed', got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rolled back") {
|
||||
t.Errorf("expected error to mention 'rolled back', got: %v", err)
|
||||
}
|
||||
|
||||
// Find the rollback script in the recorded commands.
|
||||
var rollbackCmd string
|
||||
for _, cmd := range executor.commands {
|
||||
if strings.Contains(cmd, "# CERTCTL_ROLLBACK") {
|
||||
rollbackCmd = cmd
|
||||
break
|
||||
}
|
||||
}
|
||||
if rollbackCmd == "" {
|
||||
t.Fatal("expected rollback script to be executed")
|
||||
}
|
||||
|
||||
// Rollback must remove the freshly-imported cert.
|
||||
thumbprint := result.Metadata["thumbprint"]
|
||||
if thumbprint == "" {
|
||||
t.Fatal("expected thumbprint in metadata")
|
||||
}
|
||||
if !strings.Contains(rollbackCmd, "Remove-Item") {
|
||||
t.Errorf("expected rollback to contain Remove-Item, got: %s", rollbackCmd)
|
||||
}
|
||||
if !strings.Contains(rollbackCmd, thumbprint) {
|
||||
t.Errorf("expected rollback to reference new thumbprint %q, got: %s", thumbprint, rollbackCmd)
|
||||
}
|
||||
// Rollback must re-bind the old thumbprint.
|
||||
if !strings.Contains(rollbackCmd, "AddSslCertificate('abc123'") {
|
||||
t.Errorf("expected rollback to AddSslCertificate('abc123', ...), got: %s", rollbackCmd)
|
||||
}
|
||||
|
||||
if result.Metadata["old_thumbprint"] != "abc123" {
|
||||
t.Errorf("expected old_thumbprint=abc123 in metadata, got: %s", result.Metadata["old_thumbprint"])
|
||||
}
|
||||
if result.Metadata["rolled_back"] != "true" {
|
||||
t.Errorf("expected rolled_back=true in metadata, got: %s", result.Metadata["rolled_back"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIIS_BindingUpdateFails_NoOldBinding_RemovesNewCertOnly(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
// First-time deploy: snapshot finds no existing binding.
|
||||
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
||||
output: "NO_OLD_BINDING\n",
|
||||
err: nil,
|
||||
}
|
||||
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
||||
executor.responses["Remove-WebBinding"] = mockResponse{
|
||||
output: "binding update failed",
|
||||
err: fmt.Errorf("exit status 1"),
|
||||
}
|
||||
// Rollback succeeds (cert removed, no rebind).
|
||||
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
||||
output: "CERT_REMOVED_NO_REBIND\n",
|
||||
err: nil,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// Find the rollback script.
|
||||
var rollbackCmd string
|
||||
for _, cmd := range executor.commands {
|
||||
if strings.Contains(cmd, "# CERTCTL_ROLLBACK") {
|
||||
rollbackCmd = cmd
|
||||
break
|
||||
}
|
||||
}
|
||||
if rollbackCmd == "" {
|
||||
t.Fatal("expected rollback script to be executed")
|
||||
}
|
||||
|
||||
// Rollback must remove the freshly-imported cert.
|
||||
if !strings.Contains(rollbackCmd, "Remove-Item") {
|
||||
t.Errorf("expected rollback to contain Remove-Item, got: %s", rollbackCmd)
|
||||
}
|
||||
// First-time deploy: rollback must NOT call AddSslCertificate (nothing
|
||||
// to re-bind to). The rollback emits the CERT_REMOVED_NO_REBIND marker
|
||||
// instead.
|
||||
if strings.Contains(rollbackCmd, "AddSslCertificate") {
|
||||
t.Errorf("expected no AddSslCertificate call when oldThumbprint is empty, got: %s", rollbackCmd)
|
||||
}
|
||||
if !strings.Contains(rollbackCmd, "CERT_REMOVED_NO_REBIND") {
|
||||
t.Errorf("expected CERT_REMOVED_NO_REBIND marker in rollback script, got: %s", rollbackCmd)
|
||||
}
|
||||
|
||||
// No verify script should run when oldThumbprint is empty.
|
||||
for _, cmd := range executor.commands {
|
||||
if strings.Contains(cmd, "# CERTCTL_VERIFY") {
|
||||
t.Errorf("did not expect verify script when oldThumbprint is empty, got: %s", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
if result.Metadata["old_thumbprint"] != "" {
|
||||
t.Errorf("expected empty old_thumbprint in metadata, got: %s", result.Metadata["old_thumbprint"])
|
||||
}
|
||||
if result.Metadata["rolled_back"] != "true" {
|
||||
t.Errorf("expected rolled_back=true in metadata, got: %s", result.Metadata["rolled_back"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIIS_BindingUpdateFails_RollbackAlsoFails_OperatorActionable(t *testing.T) {
|
||||
certPEM, keyPEM, chainPEM, err := generateTestCertAndKey()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test cert: %v", err)
|
||||
}
|
||||
|
||||
executor := newMockExecutor()
|
||||
executor.responses["# CERTCTL_SNAPSHOT"] = mockResponse{
|
||||
output: "OLD_THUMBPRINT:abc123\n",
|
||||
err: nil,
|
||||
}
|
||||
executor.responses["Import-PfxCertificate"] = mockResponse{output: "OK", err: nil}
|
||||
executor.responses["Remove-WebBinding"] = mockResponse{
|
||||
output: "binding error",
|
||||
err: fmt.Errorf("binding-step exit status 1"),
|
||||
}
|
||||
// Rollback ALSO fails — operator-actionable case.
|
||||
executor.responses["# CERTCTL_ROLLBACK"] = mockResponse{
|
||||
output: "rollback step failed",
|
||||
err: fmt.Errorf("rollback-step exit status 2"),
|
||||
}
|
||||
|
||||
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 both binding and rollback fail")
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatal("expected failure result")
|
||||
}
|
||||
|
||||
// Wrapped error must reference BOTH the binding error and the rollback
|
||||
// error so an operator can see what state the host is in.
|
||||
if !strings.Contains(err.Error(), "binding update failed") {
|
||||
t.Errorf("expected error to mention binding error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rollback also failed") {
|
||||
t.Errorf("expected error to mention rollback error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "manual operator inspection required") {
|
||||
t.Errorf("expected error to flag manual operator inspection, got: %v", err)
|
||||
}
|
||||
|
||||
// Metadata must explicitly flag manual action and surface both errors.
|
||||
if result.Metadata["manual_action_required"] != "true" {
|
||||
t.Errorf("expected manual_action_required=true in metadata, got: %s", result.Metadata["manual_action_required"])
|
||||
}
|
||||
if result.Metadata["rolled_back"] != "false" {
|
||||
t.Errorf("expected rolled_back=false in metadata, got: %s", result.Metadata["rolled_back"])
|
||||
}
|
||||
if result.Metadata["rollback_error"] == "" {
|
||||
t.Error("expected rollback_error to be populated in metadata")
|
||||
}
|
||||
if result.Metadata["binding_error"] == "" {
|
||||
t.Error("expected binding_error to be populated in metadata")
|
||||
}
|
||||
if result.Metadata["thumbprint"] == "" {
|
||||
t.Error("expected thumbprint in metadata even on binding failure")
|
||||
t.Error("expected thumbprint in metadata even on rollback failure")
|
||||
}
|
||||
if result.Metadata["old_thumbprint"] != "abc123" {
|
||||
t.Errorf("expected old_thumbprint=abc123 in metadata, got: %s", result.Metadata["old_thumbprint"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,11 +748,11 @@ func TestIISConnector_DeployCertificate_SNIEnabled(t *testing.T) {
|
||||
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")
|
||||
// Bundle 5: snapshot is commands[0], import is commands[1], binding is commands[2].
|
||||
if len(executor.commands) < 3 {
|
||||
t.Fatal("expected at least 3 commands (snapshot, import, binding)")
|
||||
}
|
||||
bindingCmd := executor.commands[1]
|
||||
bindingCmd := executor.commands[2]
|
||||
if !strings.Contains(bindingCmd, "-SslFlags 1") {
|
||||
t.Errorf("expected -SslFlags 1 for SNI, got: %s", bindingCmd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user