diff --git a/internal/connector/target/iis/iis.go b/internal/connector/target/iis/iis.go index 416ce41..b24fdd0 100644 --- a/internal/connector/target/iis/iis.go +++ b/internal/connector/target/iis/iis.go @@ -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. diff --git a/internal/connector/target/iis/iis_test.go b/internal/connector/target/iis/iis_test.go index 3fb472a..af9c979 100644 --- a/internal/connector/target/iis/iis_test.go +++ b/internal/connector/target/iis/iis_test.go @@ -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) }