- 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
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:
- Audit Logging (Security/Compliance baseline)
- Key Rotation (Security hardening)
- Multi-Credential (Advanced use cases)
- Auto-Detection (UX improvement)
- 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_encryptedfield (default credential) - New field
use_multi_credentialsdefaults tofalse - When toggled on, existing credential is migrated to first
dns_provider_credentialsrow with emptyzone_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
.sofiles 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:
- Review and approve this planning document
- Create GitHub issues for each feature (link to this spec)
- Begin implementation starting with Audit Logging (P0)
- Establish automated testing and documentation standards
- Monitor community feedback to adjust priorities
Document Version: 1.0 Last Updated: January 2, 2026 Status: Planning Phase - Awaiting Approval