Files
Charon/docs/plans/dns_challenge_future_features.md
GitHub Actions 7e2c7005c9 test: add comprehensive frontend tests for DNS provider feature
- Add 97 test cases covering API, hooks, and components
- Achieve 87.8% frontend coverage (exceeds 85% requirement)
- Fix CodeQL informational findings
- Ensure type safety and code quality standards

Resolves coverage failure in PR #460
2026-01-02 01:46:28 +00:00

50 KiB

DNS Challenge Future Features - Planning Document

Issue: #21 Follow-up - Future DNS Challenge Enhancements Status: Planning Phase Version: 1.0 Date: January 2, 2026


Executive Summary

This document outlines the implementation plan for 5 future enhancements to Charon's DNS Challenge Support feature (Issue #21). These features were intentionally deferred from the initial MVP to accelerate beta release, but represent significant value-adds for production deployments requiring enterprise-grade security, multi-tenancy, and extensibility.

Features Overview

Feature Business Value User Demand Complexity Priority
Audit Logging High (Compliance) High Low P0 - Critical
Multi-Credential per Provider Medium Medium Medium P1
Key Rotation Automation High (Security) Low High P1
DNS Provider Auto-Detection Low Medium Medium P2
Custom DNS Provider Plugins Low Low Very High P3

Recommended Implementation Order:

  1. Audit Logging (Security/Compliance baseline)
  2. Key Rotation (Security hardening)
  3. Multi-Credential (Advanced use cases)
  4. Auto-Detection (UX improvement)
  5. Custom Plugins (Extensibility for power users)

1. Audit Logging for Credential Operations

1.1 Business Case

Problem: Currently, there is no record of who accessed, modified, or used DNS provider credentials. This creates security blind spots and prevents forensic analysis of credential misuse or breach attempts.

Impact:

  • Compliance Risk: SOC 2, GDPR, HIPAA all require audit trails for sensitive data access
  • Security Risk: No ability to detect credential theft or unauthorized changes
  • Operational Risk: Cannot diagnose certificate issuance failures retrospectively

User Stories:

  • As a security auditor, I need to see all credential access events for compliance reporting
  • As an administrator, I want alerts when credentials are accessed outside business hours
  • As a developer, I need audit logs to debug failed certificate issuances

1.2 Technical Design

Database Schema

Extend Existing security_audits Table:

-- File: backend/internal/models/security_audit.go (extend existing)

ALTER TABLE security_audits ADD COLUMN event_category TEXT; -- 'dns_provider', 'certificate', etc.
ALTER TABLE security_audits ADD COLUMN resource_id INTEGER;  -- DNSProvider.ID
ALTER TABLE security_audits ADD COLUMN resource_uuid TEXT;  -- DNSProvider.UUID
ALTER TABLE security_audits ADD COLUMN ip_address TEXT;     -- Request originator IP
ALTER TABLE security_audits ADD COLUMN user_agent TEXT;     -- Browser/API client

Model Extension:

type SecurityAudit struct {
    ID             uint      `json:"id" gorm:"primaryKey"`
    UUID           string    `json:"uuid" gorm:"uniqueIndex"`
    Actor          string    `json:"actor"`                    // User ID or "system"
    Action         string    `json:"action"`                   // "dns_provider_create", "credential_decrypt", etc.
    EventCategory  string    `json:"event_category"`           // "dns_provider"
    ResourceID     *uint     `json:"resource_id,omitempty"`    // DNSProvider.ID
    ResourceUUID   string    `json:"resource_uuid,omitempty"`  // DNSProvider.UUID
    Details        string    `json:"details" gorm:"type:text"` // JSON blob with event metadata
    IPAddress      string    `json:"ip_address"`               // Request IP
    UserAgent      string    `json:"user_agent"`               // Client identifier
    CreatedAt      time.Time `json:"created_at"`
}

Events to Log

Event Trigger Details Captured
dns_provider_create POST /api/v1/dns-providers Provider name, type, is_default
dns_provider_update PUT /api/v1/dns-providers/:id Changed fields, old_value, new_value
dns_provider_delete DELETE /api/v1/dns-providers/:id Provider name, type, had_credentials
credential_test POST /api/v1/dns-providers/:id/test Provider name, test_result, error
credential_decrypt Caddy config generation Provider name, purpose ("certificate_issuance")
certificate_issued Caddy webhook/polling Domain, provider used, success/failure
credential_export Future: Backup/export feature Provider name, export_format

Audit Service Integration

File: backend/internal/services/dns_provider_service.go

// Add audit logging to all CRUD operations
func (s *dnsProviderService) Create(ctx context.Context, req CreateDNSProviderRequest) (*models.DNSProvider, error) {
    // ... existing create logic ...

    // Log audit event
    audit := &models.SecurityAudit{
        Actor:         getUserIDFromContext(ctx),
        Action:        "dns_provider_create",
        EventCategory: "dns_provider",
        ResourceID:    &provider.ID,
        ResourceUUID:  provider.UUID,
        Details:       fmt.Sprintf(`{"name":"%s","type":"%s","is_default":%t}`, provider.Name, provider.ProviderType, provider.IsDefault),
        IPAddress:     getIPFromContext(ctx),
        UserAgent:     getUserAgentFromContext(ctx),
    }
    s.securityService.LogAudit(audit) // Non-blocking, errors logged but not returned

    return provider, nil
}

File: backend/internal/caddy/manager.go

// Log credential decryption for Caddy config generation
for _, provider := range dnsProviders {
    decryptedData, err := encryptor.Decrypt(provider.CredentialsEncrypted)
    if err != nil {
        continue
    }

    // Log audit event (system actor)
    audit := &models.SecurityAudit{
        Actor:         "system",
        Action:        "credential_decrypt",
        EventCategory: "dns_provider",
        ResourceID:    &provider.ID,
        ResourceUUID:  provider.UUID,
        Details:       fmt.Sprintf(`{"purpose":"certificate_issuance","success":true}`),
    }
    securityService.LogAudit(audit)
}

1.3 Frontend UI

New Page: /security/audit-logs

  • Table View:

    • Columns: Timestamp, Actor, Action, Resource, IP Address, Details
    • Filters: Date range, Event category, Actor, Action type
    • Search: Free-text search in Details field
    • Export: Download as CSV or JSON
  • Details Modal:

    • Full event JSON
    • Related events (same resource_uuid)
    • Timeline visualization

Integration:

  • Add "Audit Logs" link to Security page
  • Add "View Audit History" button to DNS Provider edit form

1.4 API Endpoints

Method Endpoint Description
GET /api/v1/audit-logs List audit logs (paginated, filterable)
GET /api/v1/audit-logs/:uuid Get single audit event
GET /api/v1/dns-providers/:id/audit-logs Get audit history for specific provider

1.5 Implementation Checklist

  • Extend SecurityAudit model with new fields
  • Run database migration (add columns to security_audits table)
  • Add audit logging to DNSProviderService CRUD operations
  • Add audit logging to Caddy Manager credential decryption
  • Create AuditLogService with filtering and pagination
  • Create AuditLogHandler with REST endpoints
  • Register audit log routes in routes.go
  • Create frontend AuditLogs page with table and filters
  • Add audit log API client functions
  • Create React Query hooks for audit logs
  • Add translations for audit log UI
  • Write unit tests for audit logging (backend: 85% coverage)
  • Write unit tests for audit log UI (frontend: 85% coverage)
  • Update documentation with audit log usage
  • Add retention policy configuration (e.g., 90 days)

1.6 Performance Considerations

Audit Log Growth: Audit logs can grow rapidly. Implement:

  • Automatic Cleanup: Background job to delete logs older than retention period (default: 90 days, configurable)
  • Indexed Queries: Add database indexes on created_at, event_category, resource_uuid, actor
  • Async Logging: Audit logging must not block API requests (use buffered channel + goroutine)

Estimated Implementation Time: 8-12 hours


2. Multi-Credential per Provider (Zone-Specific Credentials)

2.1 Business Case

Problem: Large organizations manage multiple DNS zones (e.g., example.com, example.org, customers.example.com) with different API tokens for security isolation. Currently, Charon only supports one credential set per provider.

Impact:

  • Security: Overly broad API tokens violate least privilege principle
  • Multi-Tenancy: Cannot isolate customer zones with separate credentials
  • Operational Risk: Credential compromise affects all zones

User Stories:

  • As a managed service provider, I need separate API tokens for each customer's DNS zone
  • As a security engineer, I want to rotate credentials for specific zones without affecting others
  • As an administrator, I need zone-level access control for different teams

2.2 Technical Design

Database Schema Changes

New Table: dns_provider_credentials

CREATE TABLE dns_provider_credentials (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uuid TEXT UNIQUE NOT NULL,
    dns_provider_id INTEGER NOT NULL,
    label TEXT NOT NULL,                     -- "Production Zone", "Customer ABC"
    zone_filter TEXT,                        -- "example.com,*.example.com" (comma-separated domains)
    credentials_encrypted TEXT NOT NULL,     -- AES-256-GCM encrypted JSON blob
    enabled BOOLEAN DEFAULT 1,
    propagation_timeout INTEGER DEFAULT 120,
    polling_interval INTEGER DEFAULT 5,
    last_used_at DATETIME,
    success_count INTEGER DEFAULT 0,
    failure_count INTEGER DEFAULT 0,
    last_error TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (dns_provider_id) REFERENCES dns_providers(id) ON DELETE CASCADE
);

CREATE INDEX idx_dns_creds_provider ON dns_provider_credentials(dns_provider_id);
CREATE INDEX idx_dns_creds_zone ON dns_provider_credentials(zone_filter);

Updated dns_providers Table:

-- Add flag to indicate if provider uses multi-credentials
ALTER TABLE dns_providers ADD COLUMN use_multi_credentials BOOLEAN DEFAULT 0;

-- Keep existing credentials_encrypted for backward compatibility (default credential)

Model Changes

New Model: DNSProviderCredential

// File: backend/internal/models/dns_provider_credential.go

type DNSProviderCredential struct {
    ID                   uint       `json:"id" gorm:"primaryKey"`
    UUID                 string     `json:"uuid" gorm:"uniqueIndex;size:36"`
    DNSProviderID        uint       `json:"dns_provider_id" gorm:"index;not null"`
    DNSProvider          *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`

    Label                string     `json:"label" gorm:"not null;size:255"`
    ZoneFilter           string     `json:"zone_filter" gorm:"type:text"` // Comma-separated domains
    CredentialsEncrypted string     `json:"-" gorm:"type:text;not null;column:credentials_encrypted"`
    Enabled              bool       `json:"enabled" gorm:"default:true"`

    PropagationTimeout   int        `json:"propagation_timeout" gorm:"default:120"`
    PollingInterval      int        `json:"polling_interval" gorm:"default:5"`

    LastUsedAt           *time.Time `json:"last_used_at,omitempty"`
    SuccessCount         int        `json:"success_count" gorm:"default:0"`
    FailureCount         int        `json:"failure_count" gorm:"default:0"`
    LastError            string     `json:"last_error,omitempty" gorm:"type:text"`

    CreatedAt            time.Time  `json:"created_at"`
    UpdatedAt            time.Time  `json:"updated_at"`
}

func (DNSProviderCredential) TableName() string {
    return "dns_provider_credentials"
}

Updated DNSProvider Model:

type DNSProvider struct {
    // ... existing fields ...
    UseMultiCredentials  bool                     `json:"use_multi_credentials" gorm:"default:false"`
    Credentials          []DNSProviderCredential  `json:"credentials,omitempty" gorm:"foreignKey:DNSProviderID"`
}

Zone Matching Logic

File: backend/internal/services/dns_provider_service.go

// GetCredentialForDomain selects the best credential match for a domain
func (s *dnsProviderService) GetCredentialForDomain(ctx context.Context, providerID uint, domain string) (*models.DNSProviderCredential, error) {
    var provider models.DNSProvider
    if err := s.db.Preload("Credentials").First(&provider, providerID).Error; err != nil {
        return nil, err
    }

    // If not using multi-credentials, return default
    if !provider.UseMultiCredentials || len(provider.Credentials) == 0 {
        return s.getDefaultCredential(&provider)
    }

    // Find best match: exact domain > wildcard > default
    var bestMatch *models.DNSProviderCredential
    for _, cred := range provider.Credentials {
        if !cred.Enabled {
            continue
        }

        zones := strings.Split(cred.ZoneFilter, ",")
        for _, zone := range zones {
            zone = strings.TrimSpace(zone)

            // Exact match
            if zone == domain {
                return &cred, nil
            }

            // Wildcard match (*.example.com matches app.example.com)
            if strings.HasPrefix(zone, "*.") {
                baseDomain := zone[2:] // Remove "*."
                if strings.HasSuffix(domain, "."+baseDomain) || domain == baseDomain {
                    bestMatch = &cred
                }
            }
        }
    }

    if bestMatch != nil {
        return bestMatch, nil
    }

    // Fallback to credential with empty zone_filter (catch-all)
    for _, cred := range provider.Credentials {
        if cred.Enabled && cred.ZoneFilter == "" {
            return &cred, nil
        }
    }

    return nil, fmt.Errorf("no credential found for domain %s", domain)
}

2.3 API Changes

New Endpoints:

POST   /api/v1/dns-providers/:id/credentials          # Create credential
GET    /api/v1/dns-providers/:id/credentials          # List credentials
GET    /api/v1/dns-providers/:id/credentials/:cred_id # Get credential
PUT    /api/v1/dns-providers/:id/credentials/:cred_id # Update credential
DELETE /api/v1/dns-providers/:id/credentials/:cred_id # Delete credential
POST   /api/v1/dns-providers/:id/credentials/:cred_id/test # Test credential

Updated Endpoints:

PUT /api/v1/dns-providers/:id
  # Add field: "use_multi_credentials": true

2.4 Frontend UI

DNS Provider Form Changes:

  • Add toggle: "Use Multiple Credentials (Advanced)"
  • When enabled:
    • Show "Manage Credentials" button → opens modal
    • Modal displays table of credentials with zone filters
    • Add/Edit credential with zone filter input (comma-separated domains)
    • Test button for each credential

Credential Management Modal:

┌───────────────────────────────────────────────────────────┐
│ Manage Credentials: Cloudflare Production                 │
├───────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Label              │ Zones           │ Status │ Action│ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ Main Zone          │ example.com     │ ✅ OK  │ [Edit]│ │
│ │ Customer A         │ *.customer-a... │ ✅ OK  │ [Edit]│ │
│ │ Staging            │ *.staging.exam..│ ⚠️ Warn│ [Edit]│ │
│ └───────────────────────────────────────────────────────┘ │
│                                       [+ Add Credential]   │
└───────────────────────────────────────────────────────────┘

2.5 Migration Strategy

Backward Compatibility:

  • Existing providers continue using credentials_encrypted field (default credential)
  • New field use_multi_credentials defaults to false
  • When toggled on, existing credential is migrated to first dns_provider_credentials row with empty zone_filter

Migration Code:

// backend/internal/services/dns_provider_service.go

func (s *dnsProviderService) EnableMultiCredentials(ctx context.Context, providerID uint) error {
    provider, err := s.Get(ctx, providerID)
    if err != nil {
        return err
    }

    // Migrate existing credential to multi-credential table
    if provider.CredentialsEncrypted != "" {
        migrated := &models.DNSProviderCredential{
            UUID:                 uuid.NewString(),
            DNSProviderID:        provider.ID,
            Label:                "Default (migrated)",
            ZoneFilter:           "", // Catch-all
            CredentialsEncrypted: provider.CredentialsEncrypted,
            Enabled:              true,
            PropagationTimeout:   provider.PropagationTimeout,
            PollingInterval:      provider.PollingInterval,
        }
        if err := s.db.Create(migrated).Error; err != nil {
            return err
        }
    }

    provider.UseMultiCredentials = true
    return s.db.Save(provider).Error
}

2.6 Implementation Checklist

  • Create DNSProviderCredential model
  • Add migration for dns_provider_credentials table
  • Update DNSProvider model with UseMultiCredentials flag
  • Implement GetCredentialForDomain zone matching logic
  • Create CredentialService for CRUD operations
  • Create CredentialHandler with REST endpoints
  • Register credential routes in routes.go
  • Update Caddy Manager to use zone-specific credentials
  • Create frontend CredentialManager modal component
  • Update DNSProviderForm with multi-credential toggle
  • Add credential management API client functions
  • Write unit tests for zone matching logic (85% coverage)
  • Write unit tests for credential UI (85% coverage)
  • Update documentation with multi-credential usage
  • Add migration tool for existing providers

Estimated Implementation Time: 12-16 hours


3. Key Rotation Automation

3.1 Business Case

Problem: Changing CHARON_ENCRYPTION_KEY currently requires manual re-encryption of all DNS provider credentials and system downtime. This prevents regular key rotation, a critical security practice.

Impact:

  • Security Risk: Key compromise affects all historical and current credentials
  • Compliance Risk: Many security frameworks require periodic key rotation (e.g., PCI-DSS: every 12 months)
  • Operational Risk: Key loss results in complete data loss (no recovery)

User Stories:

  • As a security engineer, I need to rotate encryption keys annually without downtime
  • As an administrator, I want to schedule key rotation during maintenance windows
  • As a compliance officer, I need proof of key rotation for audit reports

3.2 Technical Design

Key Versioning Architecture

Concept: Support multiple encryption keys simultaneously with versioning

Database Changes:

-- Track active encryption key versions
CREATE TABLE encryption_keys (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    version INTEGER UNIQUE NOT NULL,        -- Monotonically increasing
    key_hash TEXT UNIQUE NOT NULL,          -- SHA-256 hash of the key (for identification, not storage)
    status TEXT NOT NULL,                   -- 'active', 'rotating', 'deprecated', 'retired'
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    rotated_at DATETIME,
    retired_at DATETIME
);

-- Add key_version to encrypted data
ALTER TABLE dns_providers ADD COLUMN key_version INTEGER DEFAULT 1;
ALTER TABLE dns_provider_credentials ADD COLUMN key_version INTEGER DEFAULT 1;

CREATE INDEX idx_dns_providers_key_version ON dns_providers(key_version);
CREATE INDEX idx_dns_creds_key_version ON dns_provider_credentials(key_version);

Environment Variable Naming Convention

# Primary key (current)
CHARON_ENCRYPTION_KEY=<base64-key>

# Rotation: Old key (for decryption only)
CHARON_ENCRYPTION_KEY_V1=<old-base64-key>
CHARON_ENCRYPTION_KEY_V2=<older-base64-key>

# New key (for encryption)
CHARON_ENCRYPTION_KEY_NEXT=<new-base64-key>

Rotation Service

File: backend/internal/crypto/rotation_service.go

type RotationService struct {
    db             *gorm.DB
    currentKey     *crypto.EncryptionService
    nextKey        *crypto.EncryptionService
    legacyKeys     map[int]*crypto.EncryptionService
    currentVersion int
}

func NewRotationService(db *gorm.DB) (*RotationService, error) {
    rs := &RotationService{
        db:         db,
        legacyKeys: make(map[int]*crypto.EncryptionService),
    }

    // Load current key
    currentKeyB64 := os.Getenv("CHARON_ENCRYPTION_KEY")
    if currentKeyB64 == "" {
        return nil, errors.New("CHARON_ENCRYPTION_KEY not set")
    }
    currentKey, err := crypto.NewEncryptionService(currentKeyB64)
    if err != nil {
        return nil, err
    }
    rs.currentKey = currentKey
    rs.currentVersion = 1 // Default version

    // Load legacy keys (V1, V2, etc.)
    for i := 1; i <= 10; i++ {
        keyEnvVar := fmt.Sprintf("CHARON_ENCRYPTION_KEY_V%d", i)
        if keyB64 := os.Getenv(keyEnvVar); keyB64 != "" {
            legacyKey, err := crypto.NewEncryptionService(keyB64)
            if err != nil {
                logger.Log().WithError(err).Warnf("Failed to load legacy key V%d", i)
                continue
            }
            rs.legacyKeys[i] = legacyKey
        }
    }

    // Load next key (for encryption during rotation)
    if nextKeyB64 := os.Getenv("CHARON_ENCRYPTION_KEY_NEXT"); nextKeyB64 != "" {
        nextKey, err := crypto.NewEncryptionService(nextKeyB64)
        if err != nil {
            logger.Log().WithError(err).Warn("Failed to load next encryption key")
        } else {
            rs.nextKey = nextKey
            rs.currentVersion += 1
        }
    }

    return rs, nil
}

// DecryptWithVersion decrypts data using the appropriate key version
func (rs *RotationService) DecryptWithVersion(ciphertextB64 string, version int) ([]byte, error) {
    if version == rs.currentVersion {
        return rs.currentKey.Decrypt(ciphertextB64)
    }

    if legacyKey, ok := rs.legacyKeys[version]; ok {
        return legacyKey.Decrypt(ciphertextB64)
    }

    return nil, fmt.Errorf("no key available for version %d", version)
}

// EncryptWithCurrentKey always uses the current (or next) key version
func (rs *RotationService) EncryptWithCurrentKey(plaintext []byte) (string, int, error) {
    keyToUse := rs.currentKey
    versionToUse := rs.currentVersion

    if rs.nextKey != nil {
        // During rotation, use next key for new encryptions
        keyToUse = rs.nextKey
        versionToUse = rs.currentVersion + 1
    }

    ciphertext, err := keyToUse.Encrypt(plaintext)
    return ciphertext, versionToUse, err
}

// RotateAllCredentials re-encrypts all DNS provider credentials with the next key
func (rs *RotationService) RotateAllCredentials(ctx context.Context) error {
    if rs.nextKey == nil {
        return errors.New("CHARON_ENCRYPTION_KEY_NEXT not set")
    }

    logger.Log().Info("Starting credential re-encryption with new key")

    // Fetch all providers
    var providers []models.DNSProvider
    if err := rs.db.Find(&providers).Error; err != nil {
        return err
    }

    successCount := 0
    errorCount := 0

    for _, provider := range providers {
        if provider.CredentialsEncrypted == "" {
            continue
        }

        // Decrypt with old key
        oldPlaintext, err := rs.DecryptWithVersion(provider.CredentialsEncrypted, provider.KeyVersion)
        if err != nil {
            logger.Log().WithError(err).Errorf("Failed to decrypt provider %d credentials", provider.ID)
            errorCount++
            continue
        }

        // Re-encrypt with new key
        newCiphertext, newVersion, err := rs.EncryptWithCurrentKey(oldPlaintext)
        if err != nil {
            logger.Log().WithError(err).Errorf("Failed to re-encrypt provider %d credentials", provider.ID)
            errorCount++
            continue
        }

        // Update database
        provider.CredentialsEncrypted = newCiphertext
        provider.KeyVersion = newVersion
        if err := rs.db.Save(&provider).Error; err != nil {
            logger.Log().WithError(err).Errorf("Failed to save provider %d with new credentials", provider.ID)
            errorCount++
            continue
        }

        successCount++
    }

    logger.Log().WithFields(map[string]interface{}{
        "success": successCount,
        "errors":  errorCount,
    }).Info("Credential re-encryption complete")

    if errorCount > 0 {
        return fmt.Errorf("rotation completed with %d errors", errorCount)
    }

    return nil
}

3.3 Rotation Workflow

Step 1: Prepare New Key

# Generate new key
openssl rand -base64 32

# Set as NEXT key (keep old key active)
export CHARON_ENCRYPTION_KEY_NEXT="<new-base64-key>"

Step 2: Trigger Rotation

# Via API (admin only)
curl -X POST https://charon.example.com/api/v1/admin/encryption/rotate \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Or via CLI tool (future)
charon-cli encryption rotate

Step 3: Verify Re-encryption

# Check rotation status
curl https://charon.example.com/api/v1/admin/encryption/status

# Response:
{
  "current_version": 2,
  "providers_rotated": 15,
  "providers_pending": 0,
  "rotation_status": "complete"
}

Step 4: Promote New Key

# Move old key to legacy
export CHARON_ENCRYPTION_KEY_V1="$CHARON_ENCRYPTION_KEY"

# Promote new key to current
export CHARON_ENCRYPTION_KEY="$CHARON_ENCRYPTION_KEY_NEXT"
unset CHARON_ENCRYPTION_KEY_NEXT

# Restart Charon (zero downtime - gradual pod replacement)

Step 5: Retire Old Key (after grace period)

# After 30 days, remove legacy key
unset CHARON_ENCRYPTION_KEY_V1

3.4 API Endpoints

Method Endpoint Description
GET /api/v1/admin/encryption/status Current key version, rotation status
POST /api/v1/admin/encryption/rotate Trigger credential re-encryption
GET /api/v1/admin/encryption/history Key rotation audit log

3.5 Implementation Checklist

  • Create encryption_keys table
  • Add key_version columns to dns_providers and dns_provider_credentials
  • Create RotationService with multi-key support
  • Implement DecryptWithVersion fallback logic
  • Implement RotateAllCredentials background job
  • Create admin encryption endpoints (status, rotate, history)
  • Add rotation progress tracking (% complete)
  • Create frontend admin page for key management
  • Add monitoring alerts for rotation failures
  • Write unit tests for rotation logic (85% coverage)
  • Document rotation procedure in operations guide
  • Add rollback procedure for failed rotations
  • Implement automatic key version detection
  • Create CLI tool for key rotation (optional)

Estimated Implementation Time: 16-20 hours


4. DNS Provider Auto-Detection

4.1 Business Case

Problem: Users must manually select DNS provider when creating wildcard proxy hosts. Many users don't know which DNS provider manages their domain's nameservers.

Impact:

  • UX Friction: Users waste time checking DNS registrar/provider
  • Configuration Errors: Selecting wrong provider causes certificate failures
  • Support Burden: Common support question: "Which provider do I use?"

User Stories:

  • As a user, I want Charon to automatically suggest the correct DNS provider for my domain
  • As a support engineer, I want to reduce configuration errors from wrong provider selection
  • As a developer, I want auto-detection to work even with custom nameservers

4.2 Technical Design

Nameserver Detection Service

File: backend/internal/services/dns_detection_service.go

type DNSDetectionService struct {
    db             *gorm.DB
    nameserverDB   map[string]string // Nameserver pattern → provider_type
    cache          *cache.Cache      // Domain → detected provider (TTL: 1 hour)
}

// Nameserver pattern database (built-in)
var BuiltInNameservers = map[string]string{
    // Cloudflare
    ".ns.cloudflare.com":   "cloudflare",

    // AWS Route 53
    ".awsdns":              "route53",

    // DigitalOcean
    ".digitalocean.com":    "digitalocean",

    // Google Cloud DNS
    ".googledomains.com":   "googleclouddns",
    "ns-cloud":             "googleclouddns",

    // Azure DNS
    ".azure-dns":           "azure",

    // Namecheap
    ".registrar-servers.com": "namecheap",

    // GoDaddy
    ".domaincontrol.com":   "godaddy",

    // Hetzner
    ".hetzner.com":         "hetzner",
    ".hetzner.de":          "hetzner",

    // Vultr
    ".vultr.com":           "vultr",

    // DNSimple
    ".dnsimple.com":        "dnsimple",
}

func (s *DNSDetectionService) DetectProvider(domain string) (*DetectionResult, error) {
    // Check cache first
    if cached, found := s.cache.Get(domain); found {
        return cached.(*DetectionResult), nil
    }

    // Query nameservers for domain
    nameservers, err := net.LookupNS(domain)
    if err != nil {
        return &DetectionResult{
            Domain:     domain,
            Detected:   false,
            Error:      err.Error(),
        }, err
    }

    // Match nameservers against known patterns
    for _, ns := range nameservers {
        nsHost := strings.ToLower(ns.Host)
        for pattern, providerType := range s.nameserverDB {
            if strings.Contains(nsHost, pattern) {
                result := &DetectionResult{
                    Domain:       domain,
                    Detected:     true,
                    ProviderType: providerType,
                    Nameservers:  extractNSHosts(nameservers),
                    Confidence:   "high",
                }
                s.cache.Set(domain, result, 1*time.Hour)
                return result, nil
            }
        }
    }

    // No match found
    result := &DetectionResult{
        Domain:      domain,
        Detected:    false,
        Nameservers: extractNSHosts(nameservers),
        Confidence:  "none",
    }
    return result, nil
}

// SuggestConfiguredProvider checks if user has a provider configured matching detected type
func (s *DNSDetectionService) SuggestConfiguredProvider(ctx context.Context, domain string) (*models.DNSProvider, error) {
    detection, err := s.DetectProvider(domain)
    if err != nil || !detection.Detected {
        return nil, nil
    }

    // Find enabled provider matching detected type
    var provider models.DNSProvider
    err = s.db.Where("provider_type = ? AND enabled = ?", detection.ProviderType, true).First(&provider).Error
    if err != nil {
        return nil, nil // No matching provider configured
    }

    return &provider, nil
}

type DetectionResult struct {
    Domain       string   `json:"domain"`
    Detected     bool     `json:"detected"`
    ProviderType string   `json:"provider_type,omitempty"`
    Nameservers  []string `json:"nameservers"`
    Confidence   string   `json:"confidence"` // "high", "medium", "low", "none"
    Error        string   `json:"error,omitempty"`
}

4.3 API Integration

New Endpoint:

POST /api/v1/dns-providers/detect
{
  "domain": "example.com"
}

Response:
{
  "detected": true,
  "provider_type": "cloudflare",
  "nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"],
  "confidence": "high",
  "suggested_provider": {
    "id": 1,
    "name": "Production Cloudflare",
    "provider_type": "cloudflare"
  }
}

4.4 Frontend Integration

ProxyHostForm.tsx Enhancement:

// When user types a wildcard domain, trigger auto-detection
const [detectionResult, setDetectionResult] = useState<DetectionResult | null>(null)

useEffect(() => {
  if (hasWildcardDomain && formData.domain_names) {
    const domain = formData.domain_names.split(',')[0].trim().replace(/^\*\./, '')
    detectDNSProvider(domain).then(result => {
      setDetectionResult(result)
      if (result.suggested_provider) {
        setFormData(prev => ({
          ...prev,
          dns_provider_id: result.suggested_provider.id
        }))
        toast.info(`Auto-detected: ${result.suggested_provider.name}`)
      }
    })
  }
}, [formData.domain_names, hasWildcardDomain])

// UI: Show detection result
{detectionResult && detectionResult.detected && (
  <Alert variant="info">
    <Info className="h-4 w-4" />
    <AlertDescription>
      Detected DNS provider: <strong>{detectionResult.provider_type}</strong>
      <br />
      Nameservers: {detectionResult.nameservers.join(', ')}
    </AlertDescription>
  </Alert>
)}

4.5 Implementation Checklist

  • Create DNSDetectionService with nameserver pattern matching
  • Build nameserver pattern database (BuiltInNameservers)
  • Add caching layer with 1-hour TTL
  • Create detection endpoint (POST /api/v1/dns-providers/detect)
  • Add suggestion logic (match detected type to configured providers)
  • Integrate detection into ProxyHostForm (auto-fill DNS provider)
  • Add manual override button (user can change auto-detected provider)
  • Create admin page to view/edit nameserver patterns
  • Add telemetry for detection accuracy (correct/incorrect suggestions)
  • Write unit tests for nameserver pattern matching (85% coverage)
  • Write unit tests for detection UI (85% coverage)
  • Update documentation with auto-detection behavior
  • Add fallback for custom nameservers (unknown providers)

Estimated Implementation Time: 6-8 hours


5. Custom DNS Provider Plugins

5.1 Business Case

Problem: Charon currently supports 10 major DNS providers. Organizations using niche or internal DNS providers (e.g., internal PowerDNS, custom DNS APIs) cannot use DNS-01 challenges without forking Charon.

Impact:

  • Vendor Lock-in: Users with unsupported providers must switch DNS or manually manage certificates
  • Enterprise Blocker: Large enterprises with internal DNS cannot adopt Charon
  • Community Growth: Cannot leverage community contributions for new providers

User Stories:

  • As a power user, I want to create a plugin for my custom DNS provider
  • As an enterprise architect, I need to integrate Charon with our internal DNS API
  • As a community contributor, I want to publish DNS provider plugins for others to use

5.2 Technical Design (Go Plugins)

Plugin Architecture

Concept: Use Go's plugin package for runtime loading of DNS provider implementations

Plugin Interface:

File: backend/pkg/dnsprovider/interface.go

package dnsprovider

type Provider interface {
    // GetType returns the provider type identifier (e.g., "custom_powerdns")
    GetType() string

    // GetMetadata returns provider metadata for UI
    GetMetadata() ProviderMetadata

    // ValidateCredentials checks if credentials are valid
    ValidateCredentials(credentials map[string]string) error

    // CreateTXTRecord creates a DNS TXT record for ACME challenge
    CreateTXTRecord(zone, name, value string, credentials map[string]string) error

    // DeleteTXTRecord removes a DNS TXT record after challenge
    DeleteTXTRecord(zone, name string, credentials map[string]string) error

    // GetPropagationTimeout returns recommended DNS propagation wait time
    GetPropagationTimeout() time.Duration
}

type ProviderMetadata struct {
    Type              string            `json:"type"`
    Name              string            `json:"name"`
    Description       string            `json:"description"`
    DocumentationURL  string            `json:"documentation_url"`
    CredentialFields  []CredentialField `json:"credential_fields"`
    Author            string            `json:"author"`
    Version           string            `json:"version"`
}

type CredentialField struct {
    Name        string `json:"name"`
    Label       string `json:"label"`
    Type        string `json:"type"` // "text", "password", "textarea"
    Required    bool   `json:"required"`
    Placeholder string `json:"placeholder"`
    Hint        string `json:"hint"`
}

Example Plugin Implementation:

File: plugins/powerdns/powerdns_plugin.go

package main

import (
    "fmt"
    "time"
    "github.com/Wikid82/charon/backend/pkg/dnsprovider"
)

type PowerDNSProvider struct{}

func (p *PowerDNSProvider) GetType() string {
    return "powerdns"
}

func (p *PowerDNSProvider) GetMetadata() dnsprovider.ProviderMetadata {
    return dnsprovider.ProviderMetadata{
        Type:        "powerdns",
        Name:        "PowerDNS",
        Description: "PowerDNS Authoritative Server with HTTP API",
        DocumentationURL: "https://doc.powerdns.com/authoritative/http-api/",
        CredentialFields: []dnsprovider.CredentialField{
            {
                Name:     "api_url",
                Label:    "PowerDNS API URL",
                Type:     "text",
                Required: true,
                Placeholder: "https://pdns.example.com:8081",
            },
            {
                Name:     "api_key",
                Label:    "API Key",
                Type:     "password",
                Required: true,
                Hint:     "X-API-Key header value",
            },
            {
                Name:     "server_id",
                Label:    "Server ID",
                Type:     "text",
                Required: false,
                Placeholder: "localhost",
            },
        },
        Author:  "Your Name",
        Version: "1.0.0",
    }
}

func (p *PowerDNSProvider) ValidateCredentials(credentials map[string]string) error {
    required := []string{"api_url", "api_key"}
    for _, field := range required {
        if credentials[field] == "" {
            return fmt.Errorf("missing required field: %s", field)
        }
    }

    // Optional: Make test API call
    // ...

    return nil
}

func (p *PowerDNSProvider) CreateTXTRecord(zone, name, value string, credentials map[string]string) error {
    apiURL := credentials["api_url"]
    apiKey := credentials["api_key"]
    serverID := credentials["server_id"]
    if serverID == "" {
        serverID = "localhost"
    }

    // Implement PowerDNS API call to create TXT record
    // POST /api/v1/servers/{server_id}/zones/{zone_id}
    // ...

    return nil
}

func (p *PowerDNSProvider) DeleteTXTRecord(zone, name string, credentials map[string]string) error {
    // Implement PowerDNS API call to delete TXT record
    // ...

    return nil
}

func (p *PowerDNSProvider) GetPropagationTimeout() time.Duration {
    return 60 * time.Second // PowerDNS is usually fast
}

// Required: Export symbol for Go plugin system
var Provider PowerDNSProvider

Compile Plugin:

go build -buildmode=plugin -o powerdns.so plugins/powerdns/powerdns_plugin.go

Plugin Loader Service

File: backend/internal/services/plugin_loader.go

type PluginLoader struct {
    pluginDir  string
    providers  map[string]dnsprovider.Provider
    mu         sync.RWMutex
}

func NewPluginLoader(pluginDir string) *PluginLoader {
    return &PluginLoader{
        pluginDir: pluginDir,
        providers: make(map[string]dnsprovider.Provider),
    }
}

func (pl *PluginLoader) LoadPlugins() error {
    files, err := os.ReadDir(pl.pluginDir)
    if err != nil {
        return err
    }

    for _, file := range files {
        if !strings.HasSuffix(file.Name(), ".so") {
            continue
        }

        pluginPath := filepath.Join(pl.pluginDir, file.Name())
        if err := pl.LoadPlugin(pluginPath); err != nil {
            logger.Log().WithError(err).Warnf("Failed to load plugin: %s", file.Name())
            continue
        }
    }

    logger.Log().Infof("Loaded %d DNS provider plugins", len(pl.providers))
    return nil
}

func (pl *PluginLoader) LoadPlugin(path string) error {
    p, err := plugin.Open(path)
    if err != nil {
        return err
    }

    // Look up exported Provider symbol
    symbol, err := p.Lookup("Provider")
    if err != nil {
        return fmt.Errorf("plugin missing 'Provider' symbol: %w", err)
    }

    provider, ok := symbol.(dnsprovider.Provider)
    if !ok {
        return fmt.Errorf("symbol 'Provider' does not implement dnsprovider.Provider interface")
    }

    // Validate plugin
    metadata := provider.GetMetadata()
    if metadata.Type == "" || metadata.Name == "" {
        return fmt.Errorf("plugin metadata invalid")
    }

    pl.mu.Lock()
    pl.providers[provider.GetType()] = provider
    pl.mu.Unlock()

    logger.Log().WithFields(map[string]interface{}{
        "type":    metadata.Type,
        "name":    metadata.Name,
        "version": metadata.Version,
        "author":  metadata.Author,
    }).Info("Loaded DNS provider plugin")

    return nil
}

func (pl *PluginLoader) GetProvider(providerType string) (dnsprovider.Provider, bool) {
    pl.mu.RLock()
    defer pl.mu.RUnlock()
    provider, ok := pl.providers[providerType]
    return provider, ok
}

func (pl *PluginLoader) ListProviders() []dnsprovider.ProviderMetadata {
    pl.mu.RLock()
    defer pl.mu.RUnlock()

    metadata := make([]dnsprovider.ProviderMetadata, 0, len(pl.providers))
    for _, provider := range pl.providers {
        metadata = append(metadata, provider.GetMetadata())
    }
    return metadata
}

5.3 Security Considerations

Plugin Sandboxing: Go plugins run in the same process space as Charon, so:

  • Code Review: All plugins must be reviewed before loading
  • Digital Signatures: Use code signing to verify plugin authenticity
  • Allowlist: Admin must explicitly enable each plugin via config

Configuration:

# config/plugins.yaml
dns_providers:
  - plugin: powerdns
    enabled: true
    verified_signature: "sha256:abcd1234..."
  - plugin: custom_internal
    enabled: true
    verified_signature: "sha256:5678efgh..."

Signature Verification:

func (pl *PluginLoader) VerifySignature(pluginPath string, expectedSig string) error {
    data, err := os.ReadFile(pluginPath)
    if err != nil {
        return err
    }

    hash := sha256.Sum256(data)
    actualSig := "sha256:" + hex.EncodeToString(hash[:])

    if actualSig != expectedSig {
        return fmt.Errorf("signature mismatch: expected %s, got %s", expectedSig, actualSig)
    }

    return nil
}

5.4 Plugin Marketplace (Future)

Concept: Community-driven plugin registry

  • Website: https://plugins.charon.io
  • Submission: Developers submit plugins via GitHub PR
  • Review: Core team reviews code for security and quality
  • Signing: Approved plugins signed with Charon's GPG key
  • Distribution: Plugins downloadable as .so files with signatures

5.5 Alternative: gRPC Plugin System

Pros:

  • Language-agnostic (write plugins in Python, Rust, etc.)
  • Better sandboxing (separate process)
  • Easier testing and development

Cons:

  • More complex (requires gRPC server/client)
  • Performance overhead (inter-process communication)
  • More moving parts (plugin lifecycle management)

Recommendation: Start with Go plugins for simplicity, evaluate gRPC if community demand is high.

5.6 Implementation Checklist

  • Define dnsprovider.Provider interface
  • Create PluginLoader service
  • Add plugin directory configuration (CHARON_PLUGIN_DIR)
  • Implement plugin loading at startup
  • Add signature verification for plugins
  • Create example plugin (PowerDNS, Infoblox, or Bind)
  • Update DNSProviderService to use plugin providers
  • Add plugin management API endpoints (list, enable, disable)
  • Create frontend admin page for plugin management
  • Write plugin development guide (docs/development/dns-plugins.md)
  • Create plugin SDK with helper functions
  • Write unit tests for plugin loader (85% coverage)
  • Add integration tests with example plugin
  • Document plugin security best practices
  • Create GitHub plugin template repository

Estimated Implementation Time: 20-24 hours


Implementation Roadmap

Phase 1: Security Baseline (P0)

Duration: 8-12 hours Features: Audit Logging

Justification: Establishes compliance foundation before adding advanced features. Required for SOC 2, GDPR, HIPAA compliance.

Deliverables:

  • SecurityAudit model extended with DNS provider fields
  • Audit logging integrated into all DNS provider CRUD operations
  • Audit log UI with filtering and export
  • Documentation updated with audit log usage

Phase 2: Security Hardening (P1)

Duration: 16-20 hours Features: Key Rotation Automation

Justification: Critical for security posture. Must be implemented before first production deployment with sensitive customer data.

Deliverables:

  • Encryption key versioning system
  • RotationService with multi-key support
  • Zero-downtime rotation workflow
  • Admin UI for key management
  • Operations guide with rotation procedures

Phase 3: Advanced Use Cases (P1)

Duration: 12-16 hours Features: Multi-Credential per Provider

Justification: Unlocks multi-tenancy and zone-level security isolation. High demand from MSPs and large enterprises.

Deliverables:

  • DNSProviderCredential model and table
  • Zone-specific credential matching logic
  • Credential management UI
  • Migration tool for existing providers
  • Documentation with multi-tenant setup guide

Phase 4: UX Improvement (P2)

Duration: 6-8 hours Features: DNS Provider Auto-Detection

Justification: Reduces configuration errors and support burden. Nice-to-have for improving user experience.

Deliverables:

  • DNSDetectionService with nameserver pattern matching
  • Auto-detection integrated into ProxyHostForm
  • Admin page for managing nameserver patterns
  • Telemetry for detection accuracy

Phase 5: Extensibility (P3)

Duration: 20-24 hours Features: Custom DNS Provider Plugins

Justification: Enables community contributions and enterprise-specific integrations. Low priority unless significant community demand.

Deliverables:

  • Plugin system architecture and interface
  • PluginLoader service with signature verification
  • Example plugin (PowerDNS or Infoblox)
  • Plugin development guide and SDK
  • Admin UI for plugin management

Dependency Graph

Audit Logging (P0)
    │
    ├─────► Key Rotation (P1)
    │           │
    │           └─────► Multi-Credential (P1)
    │                       │
    │                       └─────► Custom Plugins (P3)
    │
    └─────► DNS Auto-Detection (P2)

Explanation:

  • Audit Logging should be implemented first as it establishes the foundation for tracking all future features
  • Key Rotation depends on audit logging to track rotation events
  • Multi-Credential can be implemented in parallel with Key Rotation but benefits from audit logging
  • DNS Auto-Detection is independent and can be implemented anytime
  • Custom Plugins should be last as it's the most complex and benefits from mature audit/rotation systems

Risk Assessment Matrix

Feature Security Risk Complexity Risk Maintenance Burden
Audit Logging Low Low Low (append-only logs)
Key Rotation Medium (key mgmt) High (zero-downtime) Medium (periodic validation)
Multi-Credential Medium (zone isolation) Medium (matching logic) Medium (zone updates)
DNS Auto-Detection Low Low High (nameserver DB updates)
Custom Plugins High (code exec) Very High (sandboxing) High (security reviews)

Mitigation Strategies:

  • Key Rotation: Extensive testing in staging, phased rollout, rollback plan documented
  • Multi-Credential: Thorough zone matching tests, fallback to catch-all credential
  • Custom Plugins: Mandatory code review, signature verification, allowlist-only loading, separate process space (gRPC alternative)

Resource Requirements

Development Time (Total: 62-80 hours)

  • Audit Logging: 8-12 hours
  • Key Rotation: 16-20 hours
  • Multi-Credential: 12-16 hours
  • Auto-Detection: 6-8 hours
  • Custom Plugins: 20-24 hours

Testing Time (Estimate: 40% of dev time)

  • Unit tests: 25-32 hours
  • Integration tests: 10-12 hours
  • Security testing: 8-10 hours

Documentation Time (Estimate: 20% of dev time)

  • User guides: 8-10 hours
  • API documentation: 4-6 hours
  • Operations guides: 6-8 hours

Total Project Time: 130-160 hours (~3-4 weeks for one developer)


Success Metrics

Audit Logging

  • 100% of DNS provider operations logged
  • Audit log retention policy enforced automatically
  • Zero performance impact (<1ms per log entry)

Key Rotation

  • Zero downtime during rotation
  • 100% credential re-encryption success rate
  • Rotation time <5 minutes for 100 providers

Multi-Credential

  • Zone matching accuracy >99%
  • Support for 10+ credentials per provider
  • No certificate issuance failures due to wrong credential

DNS Auto-Detection

  • Detection accuracy >95% for supported providers
  • Auto-detection time <500ms per domain
  • User override available for edge cases

Custom Plugins

  • Plugin loading time <100ms per plugin
  • Zero crashes from malicious plugins (sandbox effective)
  • 5 community-contributed plugins within 6 months


Conclusion

These 5 features represent the natural evolution of Charon's DNS Challenge Support from MVP to enterprise-ready. The recommended implementation order prioritizes security and compliance (Audit Logging, Key Rotation) before advanced features (Multi-Credential, Auto-Detection, Custom Plugins).

Next Steps:

  1. Review and approve this planning document
  2. Create GitHub issues for each feature (link to this spec)
  3. Begin implementation starting with Audit Logging (P0)
  4. Establish automated testing and documentation standards
  5. Monitor community feedback to adjust priorities

Document Version: 1.0 Last Updated: January 2, 2026 Status: Planning Phase - Awaiting Approval