# 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 - [x] DNSProviderCredential model created - [x] CredentialService with zone matching - [x] API handlers (7 endpoints) - [x] Helper functions (extractBaseDomain, matchesZoneFilter, getCredentialForDomain) - [x] 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) ```go // 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) ```go // 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:** ```go // 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:** ```go // 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):** ```go // 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):** ```go // 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):** ```go // 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):** ```go // 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:** ```go // 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` ```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) ```go 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` ```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` ```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`: ```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)