Files
Charon/docs/plans/phase3_caddy_integration_completion.md
GitHub Actions 1a41f50f64 feat: add multi-credential support in DNS provider form
- Updated DNSProviderForm to include multi-credential mode toggle.
- Integrated CredentialManager component for managing multiple credentials.
- Added hooks for enabling multi-credentials and managing credential operations.
- Implemented tests for CredentialManager and useCredentials hooks.
2026-01-04 06:02:51 +00:00

26 KiB

Phase 3: Caddy Manager Multi-Credential Integration - Completion Plan

Status: 95% Complete - Final Integration Required Created: 2026-01-04 Target Completion: Sprint 11

Executive Summary

The multi-credential infrastructure is complete (models, services, API, helpers, tests). The remaining 5% is integrating the credential resolution logic into the Caddy Manager's config generation flow.

Completion Checklist

  • DNSProviderCredential model created
  • CredentialService with zone matching
  • API handlers (7 endpoints)
  • Helper functions (extractBaseDomain, matchesZoneFilter, getCredentialForDomain)
  • Helper function tests
  • ApplyConfig credential resolution loop ← THIS STEP
  • buildDNSChallengeIssuer integration ← THIS STEP
  • Integration tests
  • Backward compatibility validation

Part 1: Understanding Current Flow

Current Architecture (Single Credential)

File: backend/internal/caddy/manager.go Method: ApplyConfig() (Lines 80-140)

// Current flow:
1. Load proxy hosts from DB
2. Load DNS providers from DB
3. Decrypt DNS provider credentials (single set per provider)
4. Build dnsProviderConfigs []DNSProviderConfig
5. Pass to GenerateConfig()

File: backend/internal/caddy/config.go Method: GenerateConfig() (Lines 18-130) Submethods: DNS policy generation (Lines 131-220)

// Current flow:
1. Group hosts by DNS provider
2. For each provider: Build DNS challenge issuer with provider.Credentials
3. Create TLS automation policy with DNS challenge

New Architecture (Multi-Credential)

ApplyConfig()
  ↓
  For each proxy host with DNS challenge:
    ↓
    getCredentialForDomain(providerID, baseDomain, provider)
      ↓
      Returns zone-specific credentials (or provider default)
  ↓
  Store credentials in map[baseDomain]map[string]string
  ↓
  Pass map to GenerateConfig()
  ↓
  buildDNSChallengeIssuer() uses per-domain credentials

Part 2: Code Changes Required

Change 1: Add Fields to DNSProviderConfig

File: backend/internal/caddy/manager.go Location: Lines 38-44 (DNSProviderConfig struct)

Before:

// DNSProviderConfig contains a DNS provider with its decrypted credentials
// for use in Caddy DNS challenge configuration generation
type DNSProviderConfig struct {
	ID                 uint
	ProviderType       string
	PropagationTimeout int
	Credentials        map[string]string
}

After:

// DNSProviderConfig contains a DNS provider with its decrypted credentials
// for use in Caddy DNS challenge configuration generation
type DNSProviderConfig struct {
	ID                 uint
	ProviderType       string
	PropagationTimeout int

	// Single-credential mode: Use these credentials for all domains
	Credentials        map[string]string

	// Multi-credential mode: Use zone-specific credentials
	UseMultiCredentials bool
	ZoneCredentials     map[string]map[string]string // map[baseDomain]credentials
}

Why:

  • Backwards compatible: Existing Credentials field still works for single-cred mode
  • New ZoneCredentials field stores per-domain credentials
  • UseMultiCredentials flag determines which field to use

Change 2: Credential Resolution in ApplyConfig

File: backend/internal/caddy/manager.go Method: ApplyConfig() Location: Lines 80-140 (between provider decryption and GenerateConfig call)

Context (Lines 93-125):

	// Decrypt DNS provider credentials for config generation
	// We need an encryption service to decrypt the credentials
	var dnsProviderConfigs []DNSProviderConfig
	if len(dnsProviders) > 0 {
		// Try to get encryption key from environment
		encryptionKey := os.Getenv("CHARON_ENCRYPTION_KEY")
		if encryptionKey == "" {
			// Try alternative env vars
			for _, key := range []string{"ENCRYPTION_KEY", "CERBERUS_ENCRYPTION_KEY"} {
				if val := os.Getenv(key); val != "" {
					encryptionKey = val
					break
				}
			}
		}

		if encryptionKey != "" {
			// Import crypto package for inline decryption
			encryptor, err := crypto.NewEncryptionService(encryptionKey)
			if err != nil {
				logger.Log().WithError(err).Warn("failed to initialize encryption service for DNS provider credentials")
			} else {
				// Decrypt each DNS provider's credentials
				for _, provider := range dnsProviders {
					if provider.CredentialsEncrypted == "" {
						continue
					}

					decryptedData, err := encryptor.Decrypt(provider.CredentialsEncrypted)
					if err != nil {
						logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to decrypt DNS provider credentials")
						continue
					}

					var credentials map[string]string
					if err := json.Unmarshal(decryptedData, &credentials); err != nil {
						logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to parse DNS provider credentials")
						continue
					}

					dnsProviderConfigs = append(dnsProviderConfigs, DNSProviderConfig{
						ID:                 provider.ID,
						ProviderType:       provider.ProviderType,
						PropagationTimeout: provider.PropagationTimeout,
						Credentials:        credentials,
					})
				}
			}
		} else {
			logger.Log().Warn("CHARON_ENCRYPTION_KEY not set, DNS challenge configuration will be skipped")
		}
	}

Insert After Line 125 (after dnsProviderConfigs built, before acmeEmail fetch):

	// Phase 2: Resolve zone-specific credentials for multi-credential providers
	// For each provider with UseMultiCredentials=true, build a map of domain->credentials
	// by iterating through all proxy hosts that use DNS challenge
	for i := range dnsProviderConfigs {
		cfg := &dnsProviderConfigs[i]

		// Find the provider in the dnsProviders slice to check UseMultiCredentials
		var provider *models.DNSProvider
		for j := range dnsProviders {
			if dnsProviders[j].ID == cfg.ID {
				provider = &dnsProviders[j]
				break
			}
		}

		// Skip if not multi-credential mode or provider not found
		if provider == nil || !provider.UseMultiCredentials {
			continue
		}

		// Enable multi-credential mode for this provider config
		cfg.UseMultiCredentials = true
		cfg.ZoneCredentials = make(map[string]map[string]string)

		// Preload credentials for this provider (eager loading for better logging)
		if err := m.db.Preload("Credentials").First(provider, provider.ID).Error; err != nil {
			logger.Log().WithError(err).WithField("provider_id", provider.ID).Warn("failed to preload credentials for provider")
			continue
		}

		// Iterate through proxy hosts to find domains that use this provider
		for _, host := range hosts {
			if !host.Enabled || host.DNSProviderID == nil || *host.DNSProviderID != provider.ID {
				continue
			}

			// Extract base domain from host's domain names
			baseDomain := extractBaseDomain(host.DomainNames)
			if baseDomain == "" {
				continue
			}

			// Skip if we already resolved credentials for this domain
			if _, exists := cfg.ZoneCredentials[baseDomain]; exists {
				continue
			}

			// Resolve the appropriate credential for this domain
			credentials, err := m.getCredentialForDomain(provider.ID, baseDomain, provider)
			if err != nil {
				logger.Log().
					WithError(err).
					WithField("provider_id", provider.ID).
					WithField("domain", baseDomain).
					Warn("failed to resolve credential for domain, DNS challenge will be skipped for this domain")
				continue
			}

			// Store resolved credentials for this domain
			cfg.ZoneCredentials[baseDomain] = credentials

			logger.Log().WithFields(map[string]any{
				"provider_id":   provider.ID,
				"provider_type": provider.ProviderType,
				"domain":        baseDomain,
			}).Debug("resolved credential for domain")
		}

		// Log summary of credential resolution for audit trail
		logger.Log().WithFields(map[string]any{
			"provider_id":      provider.ID,
			"provider_type":    provider.ProviderType,
			"domains_resolved": len(cfg.ZoneCredentials),
		}).Info("multi-credential DNS provider resolution complete")
	}

Why This Works:

  1. Non-invasive: Only adds logic for providers with UseMultiCredentials=true
  2. Backward compatible: Single-cred providers skip this entire block
  3. Efficient: Pre-resolves credentials once, before config generation
  4. Auditable: Logs credential selection for security compliance
  5. Error-resilient: Failed credential resolution logs warning, doesn't block entire config

Change 3: Use Resolved Credentials in Config Generation

File: backend/internal/caddy/config.go Method: GenerateConfig() Location: Lines 131-220 (DNS challenge policy generation)

Context (Lines 131-140):

	// Group hosts by DNS provider for TLS automation policies
	// We need separate policies for:
	// 1. Wildcard domains with DNS challenge (per DNS provider)
	// 2. Regular domains with HTTP challenge (default policy)
	var tlsPolicies []*AutomationPolicy

	// Build a map of DNS provider ID to DNS provider config for quick lookup
	dnsProviderMap := make(map[uint]DNSProviderConfig)
	for _, cfg := range dnsProviderConfigs {
		dnsProviderMap[cfg.ID] = cfg
	}

Find the section that builds DNS challenge issuer (Lines 180-230):

		// Create DNS challenge policies for each DNS provider
		for providerID, domains := range dnsProviderDomains {
			// Find the DNS provider config
			dnsConfig, ok := dnsProviderMap[providerID]
			if !ok {
				logger.Log().WithField("provider_id", providerID).Warn("DNS provider not found in decrypted configs")
				continue
			}

			// Build provider config for Caddy with decrypted credentials
			providerConfig := map[string]any{
				"name": dnsConfig.ProviderType,
			}

			// Add all credential fields to the provider config
			for key, value := range dnsConfig.Credentials {
				providerConfig[key] = value
			}

Replace Lines 190-198 (credential assembly) with multi-credential logic:

		// Create DNS challenge policies for each DNS provider
		for providerID, domains := range dnsProviderDomains {
			// Find the DNS provider config
			dnsConfig, ok := dnsProviderMap[providerID]
			if !ok {
				logger.Log().WithField("provider_id", providerID).Warn("DNS provider not found in decrypted configs")
				continue
			}

			// **CHANGED: Multi-credential support**
			// If provider uses multi-credentials, create separate policies per domain
			if dnsConfig.UseMultiCredentials && len(dnsConfig.ZoneCredentials) > 0 {
				// Create a separate TLS automation policy for each domain with its own credentials
				for baseDomain, credentials := range dnsConfig.ZoneCredentials {
					// Find all domains that match this base domain
					var matchingDomains []string
					for _, domain := range domains {
						if extractBaseDomain(domain) == baseDomain {
							matchingDomains = append(matchingDomains, domain)
						}
					}

					if len(matchingDomains) == 0 {
						continue // No domains for this credential
					}

					// Build provider config with zone-specific credentials
					providerConfig := map[string]any{
						"name": dnsConfig.ProviderType,
					}
					for key, value := range credentials {
						providerConfig[key] = value
					}

					// Build issuer config with these credentials
					var issuers []any
					switch sslProvider {
					case "letsencrypt":
						acmeIssuer := map[string]any{
							"module": "acme",
							"email":  acmeEmail,
							"challenges": map[string]any{
								"dns": map[string]any{
									"provider":            providerConfig,
									"propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
								},
							},
						}
						if acmeStaging {
							acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
						}
						issuers = append(issuers, acmeIssuer)
					case "zerossl":
						issuers = append(issuers, map[string]any{
							"module": "zerossl",
							"challenges": map[string]any{
								"dns": map[string]any{
									"provider":            providerConfig,
									"propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
								},
							},
						})
					default: // "both" or empty
						acmeIssuer := map[string]any{
							"module": "acme",
							"email":  acmeEmail,
							"challenges": map[string]any{
								"dns": map[string]any{
									"provider":            providerConfig,
									"propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
								},
							},
						}
						if acmeStaging {
							acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
						}
						issuers = append(issuers, acmeIssuer)
						issuers = append(issuers, map[string]any{
							"module": "zerossl",
							"challenges": map[string]any{
								"dns": map[string]any{
									"provider":            providerConfig,
									"propagation_timeout": int64(dnsConfig.PropagationTimeout) * 1_000_000_000,
								},
							},
						})
					}

					// Create TLS automation policy for this domain with zone-specific credentials
					tlsPolicies = append(tlsPolicies, &AutomationPolicy{
						Subjects:   dedupeDomains(matchingDomains),
						IssuersRaw: issuers,
					})

					logger.Log().WithFields(map[string]any{
						"provider_id":    providerID,
						"base_domain":    baseDomain,
						"domain_count":   len(matchingDomains),
						"credential_used": true,
					}).Debug("created DNS challenge policy with zone-specific credential")
				}

				// Skip the original single-credential logic below
				continue
			}

			// **ORIGINAL: Single-credential mode (backward compatible)**
			// Build provider config for Caddy with decrypted credentials
			providerConfig := map[string]any{
				"name": dnsConfig.ProviderType,
			}

			// Add all credential fields to the provider config
			for key, value := range dnsConfig.Credentials {
				providerConfig[key] = value
			}

			// [KEEP EXISTING CODE FROM HERE - Lines 201-235 for single-credential issuer creation]

Why This Works:

  1. Conditional branching: Checks UseMultiCredentials flag
  2. Per-domain policies: Creates separate TLS automation policies per domain
  3. Credential isolation: Each domain gets its own credential set
  4. Backward compatible: Falls back to original logic for single-cred mode
  5. Auditable: Logs which credential is used for each domain

Part 3: Testing Strategy

Test 1: Backward Compatibility (Single Credential)

File: backend/internal/caddy/manager_test.go

func TestApplyConfig_SingleCredential_BackwardCompatibility(t *testing.T) {
	// Setup: Create provider with UseMultiCredentials=false
	provider := models.DNSProvider{
		ProviderType:        "cloudflare",
		UseMultiCredentials: false,
		CredentialsEncrypted: encryptJSON(t, map[string]string{
			"api_token": "test-token",
		}),
	}

	// Setup: Create proxy host with wildcard domain
	host := models.ProxyHost{
		DomainNames:    "*.example.com",
		DNSProviderID:  &provider.ID,
		ForwardHost:    "localhost",
		ForwardPort:    8080,
		Enabled:        true,
	}

	// Act: Apply config
	err := manager.ApplyConfig(ctx)

	// Assert: No errors
	require.NoError(t, err)

	// Assert: Generated config uses provider credentials
	config, err := manager.GetCurrentConfig(ctx)
	require.NoError(t, err)

	// Assert: TLS policy has DNS challenge with correct credentials
	assertDNSChallengePolicy(t, config, "example.com", "cloudflare", "test-token")
}

Test 2: Multi-Credential Zone Matching

File: backend/internal/caddy/manager_multicred_integration_test.go (new file)

func TestApplyConfig_MultiCredential_ZoneMatching(t *testing.T) {
	// Setup: Create provider with UseMultiCredentials=true
	provider := models.DNSProvider{
		ProviderType:        "cloudflare",
		UseMultiCredentials: true,
		Credentials: []models.DNSProviderCredential{
			{
				Label:      "Example.com Credential",
				ZoneFilter: "example.com",
				CredentialsEncrypted: encryptJSON(t, map[string]string{
					"api_token": "token-example-com",
				}),
				Enabled: true,
			},
			{
				Label:      "Example.org Credential",
				ZoneFilter: "example.org",
				CredentialsEncrypted: encryptJSON(t, map[string]string{
					"api_token": "token-example-org",
				}),
				Enabled: true,
			},
		},
	}

	// Setup: Create proxy hosts for different domains
	hosts := []models.ProxyHost{
		{
			DomainNames:    "*.example.com",
			DNSProviderID:  &provider.ID,
			ForwardHost:    "localhost",
			ForwardPort:    8080,
			Enabled:        true,
		},
		{
			DomainNames:    "*.example.org",
			DNSProviderID:  &provider.ID,
			ForwardHost:    "localhost",
			ForwardPort:    8081,
			Enabled:        true,
		},
	}

	// Act: Apply config
	err := manager.ApplyConfig(ctx)
	require.NoError(t, err)

	// Assert: Generated config has separate policies with correct credentials
	config, err := manager.GetCurrentConfig(ctx)
	require.NoError(t, err)

	assertDNSChallengePolicy(t, config, "example.com", "cloudflare", "token-example-com")
	assertDNSChallengePolicy(t, config, "example.org", "cloudflare", "token-example-org")
}

Test 3: Wildcard and Catch-All Matching

File: backend/internal/caddy/manager_multicred_integration_test.go

func TestApplyConfig_MultiCredential_WildcardAndCatchAll(t *testing.T) {
	// Setup: Provider with wildcard and catch-all credentials
	provider := models.DNSProvider{
		ProviderType:        "cloudflare",
		UseMultiCredentials: true,
		Credentials: []models.DNSProviderCredential{
			{
				Label:      "Example.com Specific",
				ZoneFilter: "example.com",
				CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "specific"}),
				Enabled: true,
			},
			{
				Label:      "Example.org Wildcard",
				ZoneFilter: "*.example.org",
				CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "wildcard"}),
				Enabled: true,
			},
			{
				Label:      "Catch-All",
				ZoneFilter: "",
				CredentialsEncrypted: encryptJSON(t, map[string]string{"api_token": "catch-all"}),
				Enabled: true,
			},
		},
	}

	// Test exact match beats catch-all
	assertCredentialSelection(t, manager, provider.ID, "example.com", "specific")

	// Test wildcard match beats catch-all
	assertCredentialSelection(t, manager, provider.ID, "app.example.org", "wildcard")

	// Test catch-all for unmatched domain
	assertCredentialSelection(t, manager, provider.ID, "random.net", "catch-all")
}

Test 4: Error Handling

File: backend/internal/caddy/manager_multicred_integration_test.go

func TestApplyConfig_MultiCredential_ErrorHandling(t *testing.T) {
	tests := []struct {
		name          string
		setup         func(*models.DNSProvider)
		expectError   bool
		expectWarning string
	}{
		{
			name: "no matching credential",
			setup: func(p *models.DNSProvider) {
				p.Credentials = []models.DNSProviderCredential{
					{
						ZoneFilter: "example.com",
						Enabled:    true,
					},
				}
			},
			expectWarning: "failed to resolve credential for domain",
		},
		{
			name: "all credentials disabled",
			setup: func(p *models.DNSProvider) {
				p.Credentials = []models.DNSProviderCredential{
					{
						ZoneFilter: "example.com",
						Enabled:    false,
					},
				}
			},
			expectWarning: "no matching credential found",
		},
		{
			name: "decryption failure",
			setup: func(p *models.DNSProvider) {
				p.Credentials = []models.DNSProviderCredential{
					{
						ZoneFilter:           "example.com",
						CredentialsEncrypted: "invalid-encrypted-data",
						Enabled:              true,
					},
				}
			},
			expectWarning: "failed to decrypt credential",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Setup and run test
			// Assert warning is logged
		})
	}
}

Part 4: Integration Sequence

To avoid breaking intermediate states, apply changes in this order:

Step 1: Add Struct Fields

  • Modify DNSProviderConfig struct in manager.go
  • Add UseMultiCredentials and ZoneCredentials fields
  • Validation: Run go test ./internal/caddy -run TestApplyConfig - should still pass

Step 2: Add Credential Resolution Loop

  • Insert credential resolution code in ApplyConfig() after provider decryption
  • Validation: Run go test ./internal/caddy -run TestApplyConfig - should still pass
  • Validation: Check logs for "multi-credential DNS provider resolution complete"

Step 3: Update Config Generation

  • Modify GenerateConfig() to check UseMultiCredentials flag
  • Add per-domain policy creation logic
  • Keep fallback to original logic
  • Validation: Run go test ./internal/caddy/... - all tests should pass

Step 4: Add Integration Tests

  • Create manager_multicred_integration_test.go
  • Add 4 test scenarios above
  • Validation: All new tests pass

Step 5: Manual Validation

  • Start Charon with multi-credential provider
  • Create proxy hosts for different domains
  • Apply config and check generated Caddy config JSON
  • Verify separate TLS automation policies per domain

Part 5: Backward Compatibility Checklist

  • Single-credential providers (UseMultiCredentials=false) work unchanged
  • Existing proxy hosts with DNS challenge still get certificates
  • No breaking changes to DNSProviderConfig API (only additions)
  • Existing tests still pass without modification
  • New fields are optional (zero values = backward compatible behavior)
  • Error handling is non-fatal (warnings logged, doesn't block config)

Part 6: Performance Considerations

Optimization 1: Lazy Loading vs Eager Loading

Decision: Use eager loading in credential resolution loop Rationale:

  • Small dataset (typically <10 credentials per provider)
  • Better logging and debugging
  • Simpler error handling
  • Minimal performance impact

Optimization 2: Credential Caching

Decision: Pre-resolve credentials once in ApplyConfig, cache in ZoneCredentials map Rationale:

  • Avoids repeated DB queries during config generation
  • Credentials don't change during config generation
  • Simpler code flow

Optimization 3: Domain Deduplication

Decision: Skip already-resolved domains in credential resolution loop Rationale:

  • Multiple proxy hosts may use same base domain
  • Avoid redundant credential resolution
  • Slight performance gain

Part 7: Security Considerations

Audit Logging

  • Log credential selection for each domain (provider_id, domain, credential_uuid)
  • Log credential resolution summary (provider_id, domains_resolved)
  • Log credential selection in debug mode for troubleshooting

Error Handling

  • Failed credential resolution logs warning, doesn't block entire config
  • Decryption failures are non-fatal for individual credentials
  • No credentials in error messages (use UUIDs only)

Credential Isolation

  • Each domain gets its own credential set in Caddy config
  • No credential leakage between domains
  • Caddy enforces per-policy credential usage

Part 8: Rollback Plan

If issues arise after deployment:

  1. Immediate: Set UseMultiCredentials=false on all providers via API
  2. Short-term: Revert to previous Charon version
  3. Investigation: Check logs for credential resolution warnings
  4. Fix: Address specific credential matching or decryption issues

Part 9: Success Criteria

  • All existing tests pass
  • 4 new integration tests pass
  • Manual testing with 2+ domains per provider works
  • Backward compatibility validated with single-credential provider
  • No performance regression (config generation <2s for 100 hosts)
  • Audit logs show credential selection for all domains
  • Documentation updated (API docs, admin guide)

Part 10: Documentation Updates Required

  1. API Documentation: Add multi-credential endpoints to OpenAPI spec
  2. Admin Guide: Add section on multi-credential configuration
  3. Migration Guide: Document single→multi credential migration
  4. Troubleshooting Guide: Add credential resolution debugging section
  5. Changelog: Document multi-credential support in v0.3.0 release notes

Appendix A: Helper Function Reference

Already implemented in backend/internal/caddy/manager_helpers.go:

extractBaseDomain(domainNames string) string

  • Extracts base domain from comma-separated list
  • Strips wildcard prefix (*.example.com → example.com)
  • Returns lowercase domain

matchesZoneFilter(zoneFilter, domain string, exactOnly bool) bool

  • Checks if domain matches zone filter pattern
  • Supports exact match and wildcard match
  • Returns false for empty filter (handled separately as catch-all)

(m *Manager) getCredentialForDomain(providerID uint, domain string, provider *models.DNSProvider) (map[string]string, error)

  • Resolves appropriate credential for domain
  • Priority: exact match → wildcard match → catch-all
  • Returns decrypted credentials map
  • Logs credential selection for audit trail

Appendix B: Testing Helpers

Create these in manager_multicred_integration_test.go:

func encryptJSON(t *testing.T, data map[string]string) string {
	// Encrypt JSON for test fixtures
}

func assertDNSChallengePolicy(t *testing.T, config *Config, domain, provider, token string) {
	// Assert TLS automation policy exists with correct credentials
}

func assertCredentialSelection(t *testing.T, manager *Manager, providerID uint, domain, expectedToken string) {
	// Assert getCredentialForDomain returns expected credential
}

Appendix C: Error Scenarios

Scenario Behavior User Impact
No matching credential Log warning, skip domain Certificate not issued for that domain
Decryption failure Log warning, skip credential Fallback to catch-all or skip domain
Empty ZoneCredentials Fall back to single-cred mode Backward compatible behavior
Disabled credential Skip credential Next priority credential used
No encryption key Skip DNS challenge HTTP challenge used (if applicable)

End of Plan

Next Action: Implement changes in sequence (Steps 1-5) Review Required: Code review after Step 3 (before integration tests) Deployment: Sprint 11 release (after all success criteria met)