38 KiB
Executable File
Phase 5: Custom DNS Provider Plugins - Implementation Specification
Status: 📋 Planning Priority: P3 (Lowest) Estimated Time: 24-32 hours Author: Engineering Director Date: January 6, 2026
1. Executive Summary
This document specifies the implementation of a custom DNS provider plugin system for Charon. This feature enables users to integrate DNS providers not supported out-of-the-box by creating Go plugins that implement a standard interface.
Key Goals
- Enable extensibility for custom/internal DNS providers
- Maintain backward compatibility with existing providers
- Provide security controls (signature verification, allowlisting)
- Support community-contributed plugins
Critical Platform Limitation
⚠️ IMPORTANT: Go plugins only work on Linux and macOS.
The Go
pluginpackage does not support Windows. Users on Windows cannot load custom plugins. Built-in providers will continue to work on all platforms.
Critical Technical Limitations
⚠️ SUPERVISOR REVIEW FINDINGS: The following limitations must be understood before implementation:
| Limitation | Impact | Mitigation |
|---|---|---|
| Go Version Match | Plugin and host must use identical Go version | Document required Go version, verify at load time |
| Dependency Version Match | All shared dependencies must match exactly | Use dependency injection, minimize shared deps |
| No Hot Reload | Plugins cannot be unloaded from memory | Require restart for plugin updates |
| CGO Required | Plugins must be built with CGO_ENABLED=1 |
Document build requirements |
| Caddy Module Required | Plugins only handle UI/API - Caddy needs its own DNS module | Document Caddy-side requirements |
Caddy DNS Module Dependency
External plugins provide:
- UI credential field definitions
- Credential validation
- Caddy config generation
But Caddy itself must have the matching DNS provider module compiled in. For example, to use PowerDNS:
- Install Charon's PowerDNS plugin (this feature) - handles UI/API/credentials
- Use Caddy built with caddy-dns/powerdns - handles actual DNS challenge
2. Architecture Overview
2.1 High-Level Design
┌─────────────────────────────────────────────────────────────────────┐
│ Charon Backend │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Provider Registry │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Cloudflare │ │ Route53 │ │ DigitalOcean │ ... │ │
│ │ │ (built-in) │ │ (built-in) │ │ (built-in) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ PowerDNS │ │ Infoblox │ ← External Plugins │ │
│ │ │ (plugin) │ │ (plugin) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ ┌───────────────────────────┴─────────────────────────────────┐ │
│ │ Plugin Loader │ │
│ │ - Load .so files from plugins/ directory │ │
│ │ - Verify signatures against allowlist │ │
│ │ - Register valid plugins with registry │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ ┌───────────────────────────┴─────────────────────────────────┐ │
│ │ DNS Provider Service │ │
│ │ - Queries registry for provider types │ │
│ │ - Validates credentials via provider interface │ │
│ │ - Builds Caddy config via provider interface │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
2.2 Directory Structure
backend/
├── pkg/
│ └── dnsprovider/
│ ├── plugin.go # Plugin interface definition
│ ├── registry.go # Provider registry
│ ├── errors.go # Plugin-specific errors
│ └── builtin/
│ ├── cloudflare.go # Built-in Cloudflare provider
│ ├── route53.go # Built-in Route53 provider
│ ├── digitalocean.go # Built-in DigitalOcean provider
│ ├── googleclouddns.go
│ ├── azure.go
│ ├── namecheap.go
│ ├── godaddy.go
│ ├── hetzner.go
│ ├── vultr.go
│ └── dnsimple.go
├── internal/
│ ├── models/
│ │ └── plugin.go # Plugin database model
│ ├── services/
│ │ └── plugin_loader.go # Plugin loading service
│ └── api/
│ └── handlers/
│ └── plugin_handler.go # Plugin API handlers
plugins/ # External plugins directory
├── powerdns/
│ ├── main.go # PowerDNS plugin source
│ └── README.md
└── powerdns.so # Compiled plugin
config/
└── plugins.yaml # Plugin allowlist configuration
3. Backend Implementation
3.1 Plugin Interface
File: backend/pkg/dnsprovider/plugin.go
⚠️ SUPERVISOR CORRECTIONS APPLIED: Added lifecycle hooks (Init/Cleanup), multi-credential support, and version compatibility fields per Supervisor review.
package dnsprovider
import "time"
// InterfaceVersion is the current plugin interface version.
// Plugins built against a different version may not be compatible.
const InterfaceVersion = "v1"
// ProviderPlugin defines the interface that all DNS provider plugins must implement.
// Both built-in providers and external plugins implement this interface.
type ProviderPlugin interface {
// Type returns the unique provider type identifier (e.g., "cloudflare", "powerdns")
// This must be lowercase, alphanumeric with optional underscores.
Type() string
// Metadata returns descriptive information about the provider for UI display.
Metadata() ProviderMetadata
// Init is called after the plugin is registered. Use for startup initialization
// (loading config files, validating environment, establishing connections).
// Return an error to prevent the plugin from being registered.
Init() error
// Cleanup is called before the plugin is unregistered. Use for resource cleanup
// (closing connections, flushing caches). Note: Go plugins cannot be unloaded
// from memory - this is only called during graceful shutdown.
Cleanup() error
// RequiredCredentialFields returns the credential fields that must be provided.
RequiredCredentialFields() []CredentialFieldSpec
// OptionalCredentialFields returns credential fields that may be provided.
OptionalCredentialFields() []CredentialFieldSpec
// ValidateCredentials checks if the provided credentials are valid.
// Returns nil if valid, error describing the issue otherwise.
ValidateCredentials(creds map[string]string) error
// TestCredentials attempts to verify credentials work with the provider API.
// This may make network calls to the provider.
TestCredentials(creds map[string]string) error
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
// If true, BuildCaddyConfigForZone will be called instead of BuildCaddyConfig when
// multi-credential mode is enabled (Phase 3 feature).
SupportsMultiCredential() bool
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
// The returned map is embedded into Caddy's TLS automation policy.
// Used when multi-credential mode is disabled.
BuildCaddyConfig(creds map[string]string) map[string]any
// BuildCaddyConfigForZone constructs config for a specific zone (multi-credential mode).
// Only called if SupportsMultiCredential() returns true.
// baseDomain is the zone being configured (e.g., "example.com").
BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any
// PropagationTimeout returns the recommended DNS propagation wait time.
PropagationTimeout() time.Duration
// PollingInterval returns the recommended polling interval for DNS verification.
PollingInterval() time.Duration
}
// ProviderMetadata contains descriptive information about a DNS provider.
type ProviderMetadata struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
DocumentationURL string `json:"documentation_url,omitempty"`
Author string `json:"author,omitempty"`
Version string `json:"version,omitempty"`
IsBuiltIn bool `json:"is_built_in"`
// Version compatibility (required for external plugins)
GoVersion string `json:"go_version,omitempty"` // Go version used to build (e.g., "1.23.4")
InterfaceVersion string `json:"interface_version,omitempty"` // Plugin interface version (e.g., "v1")
}
// CredentialFieldSpec defines a credential field for UI rendering.
type CredentialFieldSpec struct {
Name string `json:"name"` // Field key (e.g., "api_token")
Label string `json:"label"` // Display label (e.g., "API Token")
Type string `json:"type"` // "text", "password", "textarea", "select"
Placeholder string `json:"placeholder"` // Input placeholder text
Hint string `json:"hint"` // Help text shown below field
Options []SelectOption `json:"options,omitempty"` // For "select" type
}
// SelectOption represents an option in a select dropdown.
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
3.2 Provider Registry
File: backend/pkg/dnsprovider/registry.go
package dnsprovider
import (
"fmt"
"sync"
)
// Registry is a thread-safe registry of DNS provider plugins.
type Registry struct {
providers map[string]ProviderPlugin
mu sync.RWMutex
}
// globalRegistry is the singleton registry instance.
var globalRegistry = &Registry{
providers: make(map[string]ProviderPlugin),
}
// Global returns the global provider registry.
func Global() *Registry {
return globalRegistry
}
// Register adds a provider to the registry.
func (r *Registry) Register(provider ProviderPlugin) error {
r.mu.Lock()
defer r.mu.Unlock()
providerType := provider.Type()
if providerType == "" {
return fmt.Errorf("provider type cannot be empty")
}
if _, exists := r.providers[providerType]; exists {
return fmt.Errorf("provider type %q already registered", providerType)
}
r.providers[providerType] = provider
return nil
}
// Get retrieves a provider by type.
func (r *Registry) Get(providerType string) (ProviderPlugin, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
provider, ok := r.providers[providerType]
return provider, ok
}
// List returns all registered providers.
func (r *Registry) List() []ProviderPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
providers := make([]ProviderPlugin, 0, len(r.providers))
for _, p := range r.providers {
providers = append(providers, p)
}
return providers
}
// Types returns all registered provider type identifiers.
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
types := make([]string, 0, len(r.providers))
for t := range r.providers {
types = append(types, t)
}
return types
}
// IsSupported checks if a provider type is registered.
func (r *Registry) IsSupported(providerType string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.providers[providerType]
return ok
}
// Unregister removes a provider from the registry.
// Used primarily for plugin unloading.
func (r *Registry) Unregister(providerType string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.providers, providerType)
}
3.3 Built-in Provider Example
File: backend/pkg/dnsprovider/builtin/cloudflare.go
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// CloudflareProvider implements the ProviderPlugin interface for Cloudflare DNS.
type CloudflareProvider struct{}
func init() {
dnsprovider.Global().Register(&CloudflareProvider{})
}
func (p *CloudflareProvider) Type() string {
return "cloudflare"
}
func (p *CloudflareProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "cloudflare",
Name: "Cloudflare",
Description: "Cloudflare DNS with API Token authentication",
DocumentationURL: "https://developers.cloudflare.com/api/tokens/create/",
IsBuiltIn: true,
}
}
func (p *CloudflareProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your Cloudflare API token",
Hint: "Token requires Zone:DNS:Edit permission",
},
}
}
func (p *CloudflareProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "zone_id",
Label: "Zone ID",
Type: "text",
Placeholder: "Optional: Specific zone ID",
Hint: "Leave empty to auto-detect zone",
},
}
}
func (p *CloudflareProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_token"] == "" {
return fmt.Errorf("api_token is required")
}
return nil
}
func (p *CloudflareProvider) TestCredentials(creds map[string]string) error {
// Implementation would make API call to verify token
// For now, just validate required fields
return p.ValidateCredentials(creds)
}
func (p *CloudflareProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "cloudflare",
"api_token": creds["api_token"],
}
if zoneID := creds["zone_id"]; zoneID != "" {
config["zone_id"] = zoneID
}
return config
}
func (p *CloudflareProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *CloudflareProvider) PollingInterval() time.Duration {
return 5 * time.Second
}
3.4 Plugin Loader Service
File: backend/internal/services/plugin_loader.go
package services
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"plugin"
"strings"
"sync"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// PluginLoaderService manages loading and unloading of DNS provider plugins.
type PluginLoaderService struct {
pluginDir string
allowedSigs map[string]string // plugin name -> expected signature
loadedPlugins map[string]string // plugin type -> file path
mu sync.RWMutex
}
// NewPluginLoaderService creates a new plugin loader.
func NewPluginLoaderService(pluginDir string, allowedSignatures map[string]string) *PluginLoaderService {
return &PluginLoaderService{
pluginDir: pluginDir,
allowedSigs: allowedSignatures,
loadedPlugins: make(map[string]string),
}
}
// LoadAllPlugins loads all .so files from the plugin directory.
func (s *PluginLoaderService) LoadAllPlugins() error {
if s.pluginDir == "" {
logger.Log().Info("Plugin directory not configured, skipping plugin loading")
return nil
}
entries, err := os.ReadDir(s.pluginDir)
if err != nil {
if os.IsNotExist(err) {
logger.Log().Info("Plugin directory does not exist, skipping plugin loading")
return nil
}
return fmt.Errorf("failed to read plugin directory: %w", err)
}
loadedCount := 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".so") {
continue
}
pluginPath := filepath.Join(s.pluginDir, entry.Name())
if err := s.LoadPlugin(pluginPath); err != nil {
logger.Log().WithError(err).Warnf("Failed to load plugin: %s", entry.Name())
continue
}
loadedCount++
}
logger.Log().Infof("Loaded %d external DNS provider plugins", loadedCount)
return nil
}
// LoadPlugin loads a single plugin from the specified path.
func (s *PluginLoaderService) LoadPlugin(path string) error {
// Verify signature if signatures are configured
if len(s.allowedSigs) > 0 {
pluginName := strings.TrimSuffix(filepath.Base(path), ".so")
expectedSig, ok := s.allowedSigs[pluginName]
if !ok {
return fmt.Errorf("plugin %q not in allowlist", pluginName)
}
actualSig, err := s.computeSignature(path)
if err != nil {
return fmt.Errorf("failed to compute signature: %w", err)
}
if actualSig != expectedSig {
return fmt.Errorf("signature mismatch for %q: expected %s, got %s", pluginName, expectedSig, actualSig)
}
}
// Load the plugin
p, err := plugin.Open(path)
if err != nil {
return fmt.Errorf("failed to open plugin: %w", err)
}
// Look up the Plugin symbol
symbol, err := p.Lookup("Plugin")
if err != nil {
return fmt.Errorf("plugin missing 'Plugin' symbol: %w", err)
}
// Assert the interface
provider, ok := symbol.(dnsprovider.ProviderPlugin)
if !ok {
// Try pointer to interface
providerPtr, ok := symbol.(*dnsprovider.ProviderPlugin)
if !ok {
return fmt.Errorf("'Plugin' symbol does not implement ProviderPlugin interface")
}
provider = *providerPtr
}
// Validate provider
meta := provider.Metadata()
if meta.Type == "" || meta.Name == "" {
return fmt.Errorf("plugin has invalid metadata")
}
// Register with global registry
if err := dnsprovider.Global().Register(provider); err != nil {
return fmt.Errorf("failed to register plugin: %w", err)
}
s.mu.Lock()
s.loadedPlugins[provider.Type()] = path
s.mu.Unlock()
logger.Log().WithFields(map[string]interface{}{
"type": meta.Type,
"name": meta.Name,
"version": meta.Version,
"author": meta.Author,
}).Info("Loaded DNS provider plugin")
return nil
}
// computeSignature calculates SHA-256 hash of plugin file.
func (s *PluginLoaderService) computeSignature(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
hash := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(hash[:]), nil
}
// ListLoadedPlugins returns information about loaded external plugins.
func (s *PluginLoaderService) ListLoadedPlugins() []dnsprovider.ProviderMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
var plugins []dnsprovider.ProviderMetadata
for providerType := range s.loadedPlugins {
if provider, ok := dnsprovider.Global().Get(providerType); ok {
plugins = append(plugins, provider.Metadata())
}
}
return plugins
}
// IsPluginLoaded checks if a provider type was loaded from an external plugin.
func (s *PluginLoaderService) IsPluginLoaded(providerType string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.loadedPlugins[providerType]
return ok
}
3.5 Plugin Database Model
File: backend/internal/models/plugin.go
package models
import "time"
// Plugin represents an installed DNS provider plugin.
type Plugin struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
Name string `json:"name" gorm:"not null;size:255"`
Type string `json:"type" gorm:"uniqueIndex;not null;size:100"`
FilePath string `json:"file_path" gorm:"not null;size:500"`
Signature string `json:"signature" gorm:"size:100"`
Enabled bool `json:"enabled" gorm:"default:true"`
Status string `json:"status" gorm:"default:'pending';size:50"` // pending, loaded, error
Error string `json:"error,omitempty" gorm:"type:text"`
Version string `json:"version,omitempty" gorm:"size:50"`
Author string `json:"author,omitempty" gorm:"size:255"`
LoadedAt *time.Time `json:"loaded_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Plugin) TableName() string {
return "plugins"
}
3.6 Integration Changes
Modify: backend/internal/services/dns_provider_service.go
Replace hardcoded SupportedProviderTypes with registry query:
// Before (hardcoded):
// var SupportedProviderTypes = []string{"cloudflare", "route53", ...}
// After (registry-based):
func (s *dnsProviderService) GetSupportedProviderTypes() []string {
return dnsprovider.Global().Types()
}
func (s *dnsProviderService) IsProviderTypeSupported(providerType string) bool {
return dnsprovider.Global().IsSupported(providerType)
}
func (s *dnsProviderService) GetProviderCredentialFields(providerType string) ([]dnsprovider.CredentialFieldSpec, error) {
provider, ok := dnsprovider.Global().Get(providerType)
if !ok {
return nil, fmt.Errorf("unsupported provider type: %s", providerType)
}
fields := provider.RequiredCredentialFields()
fields = append(fields, provider.OptionalCredentialFields()...)
return fields, nil
}
Modify: backend/internal/caddy/config.go
Replace buildDNSChallengeIssuer to use registry:
func (b *ConfigBuilder) buildDNSChallengeIssuer(dnsConfig *DNSProviderConfig) map[string]any {
provider, ok := dnsprovider.Global().Get(dnsConfig.ProviderType)
if !ok {
logger.Log().Warnf("Unknown provider type: %s", dnsConfig.ProviderType)
return nil
}
// Build Caddy config using provider interface
providerConfig := provider.BuildCaddyConfig(dnsConfig.Credentials)
return map[string]any{
"module": "acme",
"challenges": map[string]any{
"dns": map[string]any{
"provider": providerConfig,
"propagation_timeout": int(provider.PropagationTimeout().Seconds()),
},
},
}
}
4. Frontend Implementation
4.1 Plugin Management Page
File: frontend/src/pages/Plugins.tsx
Features:
- List all installed plugins (built-in and external)
- Show status (loaded, error, disabled)
- Enable/disable toggle for external plugins
- View plugin metadata and error details
- Refresh button to reload plugins
4.2 Dynamic Credential Fields
Modify: frontend/src/components/DNSProviderForm.tsx
Query provider credential fields from API instead of hardcoded mapping:
// New API endpoint: GET /api/v1/dns-providers/types/:type/fields
const { data: credentialFields } = useProviderCredentialFields(providerType);
// Render fields dynamically
{credentialFields?.map(field => (
<FormField
key={field.name}
name={field.name}
label={field.label}
type={field.type}
placeholder={field.placeholder}
hint={field.hint}
required={/* check if in required fields */}
/>
))}
4.3 API Client
File: frontend/src/api/plugins.ts
export interface PluginInfo {
id: number;
uuid: string;
name: string;
type: string;
enabled: boolean;
status: 'pending' | 'loaded' | 'error';
error?: string;
version?: string;
author?: string;
is_built_in: boolean;
loaded_at?: string;
}
export const pluginsApi = {
list: () => api.get<PluginInfo[]>('/api/v1/admin/plugins'),
enable: (id: number) => api.post(`/api/v1/admin/plugins/${id}/enable`),
disable: (id: number) => api.post(`/api/v1/admin/plugins/${id}/disable`),
reload: () => api.post('/api/v1/admin/plugins/reload'),
};
5. Example Plugin: PowerDNS
File: plugins/powerdns/main.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Plugin is the exported symbol that Charon looks for.
var Plugin dnsprovider.ProviderPlugin = &PowerDNSProvider{}
type PowerDNSProvider struct{}
func (p *PowerDNSProvider) Type() string {
return "powerdns"
}
func (p *PowerDNSProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "powerdns",
Name: "PowerDNS",
Description: "PowerDNS Authoritative Server with HTTP API",
DocumentationURL: "https://doc.powerdns.com/authoritative/http-api/",
Author: "Charon Community",
Version: "1.0.0",
IsBuiltIn: false,
}
}
func (p *PowerDNSProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_url",
Label: "API URL",
Type: "text",
Placeholder: "https://pdns.example.com:8081",
Hint: "PowerDNS HTTP API endpoint",
},
{
Name: "api_key",
Label: "API Key",
Type: "password",
Placeholder: "Your PowerDNS API key",
Hint: "X-API-Key header value",
},
}
}
func (p *PowerDNSProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "server_id",
Label: "Server ID",
Type: "text",
Placeholder: "localhost",
Hint: "PowerDNS server ID (default: localhost)",
},
}
}
func (p *PowerDNSProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_url"] == "" {
return fmt.Errorf("api_url is required")
}
if creds["api_key"] == "" {
return fmt.Errorf("api_key is required")
}
return nil
}
func (p *PowerDNSProvider) TestCredentials(creds map[string]string) error {
if err := p.ValidateCredentials(creds); err != nil {
return err
}
// Test API connectivity
serverID := creds["server_id"]
if serverID == "" {
serverID = "localhost"
}
url := fmt.Sprintf("%s/api/v1/servers/%s", creds["api_url"], serverID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("X-API-Key", creds["api_key"])
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("API connection failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
return nil
}
func (p *PowerDNSProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
serverID := creds["server_id"]
if serverID == "" {
serverID = "localhost"
}
return map[string]any{
"name": "powerdns",
"api_url": creds["api_url"],
"api_key": creds["api_key"],
"server_id": serverID,
}
}
func (p *PowerDNSProvider) PropagationTimeout() time.Duration {
return 60 * time.Second
}
func (p *PowerDNSProvider) PollingInterval() time.Duration {
return 2 * time.Second
}
Build Command:
cd plugins/powerdns
go build -buildmode=plugin -o ../powerdns.so main.go
6. Security
⚠️ SUPERVISOR CORRECTIONS APPLIED: Enhanced security section with directory permissions, TOCTOU mitigation, and critical warnings.
6.1 Critical Security Warnings
🚨 IN-PROCESS EXECUTION: External plugins run in the same process as Charon. A malicious plugin has full access to:
- All process memory (including encryption keys)
- Database connections
- Network capabilities
- Filesystem access
Only load plugins from trusted sources. Code review is mandatory.
🚨 PLUGINS CANNOT BE UNLOADED: Once loaded, plugin code remains in memory until Charon restarts. "Disabling" a plugin only removes it from the registry.
6.2 Signature Verification
Plugins are verified using SHA-256 hash of the plugin binary:
# Generate signature
sha256sum plugins/powerdns.so
# Output: abc123... plugins/powerdns.so
# Configure in config/plugins.yaml
plugins:
powerdns:
enabled: true
signature: "sha256:abc123..."
6.3 Plugin Allowlist
Only plugins listed in config/plugins.yaml are loaded:
# config/plugins.yaml
plugins:
powerdns:
enabled: true
signature: "sha256:abc123def456..."
infoblox:
enabled: false # Disabled
signature: "sha256:789xyz..."
6.4 Directory Permission Requirements
The plugin directory MUST have restricted permissions:
# Set secure permissions (Linux/macOS)
chmod 700 /opt/charon/plugins
chown charon:charon /opt/charon/plugins
# Verify permissions before loading (implemented in plugin_loader.go)
# Loader will refuse to load if permissions are too permissive (e.g., world-readable)
6.5 Security Recommendations
- Code Review: Always review plugin source before deployment
- Isolated Builds: Build plugins in isolated environments
- Regular Updates: Keep plugin signatures updated after rebuilds
- Minimal Permissions: Run Charon with minimal filesystem permissions
- Audit Logging: All plugin load events are logged
- Version Pinning: Pin Go version and dependencies in plugin builds
- Separate Build Environment: Never build plugins on production systems
7. Configuration
7.1 Environment Variables
| Variable | Description | Default |
|---|---|---|
CHARON_PLUGINS_ENABLED |
Enable plugin system | false |
CHARON_PLUGINS_DIR |
Plugin directory path | /app/plugins |
CHARON_PLUGINS_CONFIG |
Plugin allowlist file | /app/config/plugins.yaml |
CHARON_PLUGINS_STRICT_MODE |
Reject unsigned plugins entirely | true |
7.2 Example Configuration
# Enable plugins
export CHARON_PLUGINS_ENABLED=true
export CHARON_PLUGINS_DIR=/opt/charon/plugins
export CHARON_PLUGINS_CONFIG=/opt/charon/config/plugins.yaml
export CHARON_PLUGINS_STRICT_MODE=true
7.3 Build Requirements
⚠️ CGO Required: Go plugins require CGO. Build plugins with:
CGO_ENABLED=1 go build -buildmode=plugin -o plugin.so main.go
8. API Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/admin/plugins |
List all plugins (built-in + external) |
GET |
/api/v1/admin/plugins/:id |
Get plugin details |
POST |
/api/v1/admin/plugins/:id/enable |
Enable a plugin |
POST |
/api/v1/admin/plugins/:id/disable |
Disable a plugin |
POST |
/api/v1/admin/plugins/reload |
Reload all plugins |
GET |
/api/v1/dns-providers/types |
List available provider types |
GET |
/api/v1/dns-providers/types/:type/fields |
Get credential fields for type |
9. Testing Strategy
9.1 Unit Tests
- Plugin interface compliance tests
- Registry add/remove/query tests
- Plugin loader signature verification tests
- Built-in provider tests
9.2 Integration Tests
- Load example plugin and verify registration
- Create DNS provider with plugin type
- Build Caddy config with plugin provider
9.3 Coverage Target
- Backend: ≥85% coverage
- Frontend: ≥85% coverage
10. Files to Create
| File | Description | Est. Lines |
|---|---|---|
backend/pkg/dnsprovider/plugin.go |
Plugin interface | 100 |
backend/pkg/dnsprovider/registry.go |
Provider registry | 120 |
backend/pkg/dnsprovider/errors.go |
Plugin errors | 30 |
backend/pkg/dnsprovider/builtin/cloudflare.go |
Cloudflare provider | 80 |
backend/pkg/dnsprovider/builtin/route53.go |
Route53 provider | 100 |
backend/pkg/dnsprovider/builtin/digitalocean.go |
DigitalOcean provider | 80 |
backend/pkg/dnsprovider/builtin/googleclouddns.go |
Google Cloud DNS | 100 |
backend/pkg/dnsprovider/builtin/azure.go |
Azure DNS | 100 |
backend/pkg/dnsprovider/builtin/namecheap.go |
Namecheap | 80 |
backend/pkg/dnsprovider/builtin/godaddy.go |
GoDaddy | 80 |
backend/pkg/dnsprovider/builtin/hetzner.go |
Hetzner | 80 |
backend/pkg/dnsprovider/builtin/vultr.go |
Vultr | 80 |
backend/pkg/dnsprovider/builtin/dnsimple.go |
DNSimple | 80 |
backend/pkg/dnsprovider/builtin/init.go |
Auto-register built-ins | 20 |
backend/internal/models/plugin.go |
Plugin model | 40 |
backend/internal/services/plugin_loader.go |
Plugin loader | 200 |
backend/internal/api/handlers/plugin_handler.go |
Plugin API | 150 |
plugins/powerdns/main.go |
Example plugin | 150 |
frontend/src/pages/Plugins.tsx |
Plugin admin page | 250 |
frontend/src/api/plugins.ts |
Plugin API client | 60 |
frontend/src/hooks/usePlugins.ts |
Plugin hooks | 50 |
docs/features/custom-plugins.md |
User guide | 300 |
docs/development/plugin-development.md |
Developer guide | 500 |
Total New Code: ~2,830 lines
11. Files to Modify
| File | Changes |
|---|---|
backend/internal/services/dns_provider_service.go |
Use registry instead of hardcoded lists |
backend/internal/caddy/config.go |
Use registry for Caddy config building |
backend/main.go |
Initialize plugin system on startup |
backend/internal/api/routes.go |
Register plugin API routes |
backend/internal/database/database.go |
AutoMigrate Plugin model |
frontend/src/components/DNSProviderForm.tsx |
Dynamic credential fields |
frontend/src/App.tsx |
Add Plugins route |
frontend/src/components/Sidebar.tsx |
Add Plugins link |
12. Implementation Order
| Phase | Task | Est. Hours |
|---|---|---|
| 1 | Plugin interface and registry | 2 |
| 2 | Migrate built-in providers (10 providers) | 5 |
| 3 | Plugin loader with signature verification | 3 |
| 4 | Plugin model and database migration | 1 |
| 5 | Plugin API handlers | 2 |
| 6 | Modify dns_provider_service for registry | 2 |
| 7 | Modify Caddy config for registry | 2 |
| 8 | Frontend plugin management page | 4 |
| 9 | Dynamic credential fields in UI | 3 |
| 10 | PowerDNS example plugin | 2 |
| 11 | Unit tests (85% coverage) | 4 |
| 12 | Documentation | 2 |
Total: 32 hours
13. Rollback Plan
- Plugin Load Failure: Log error, continue without plugin, show error in admin UI
- Registry Failure: Fall back to empty registry, built-in providers still work via init()
- Complete Disable: Set
CHARON_PLUGINS_ENABLED=falseto disable entire plugin system
14. Definition of Done
- Plugin interface defined (
backend/pkg/dnsprovider/plugin.go) - Provider registry implemented (
backend/pkg/dnsprovider/registry.go) - All 10 built-in providers migrated to registry
- Plugin loader with signature verification
- Plugin database model and migration
- Plugin CRUD API endpoints
- DNS provider service uses registry
- Caddy config builder uses registry
- Frontend plugin management page
- Dynamic credential fields in DNSProviderForm
- PowerDNS example plugin compiles and loads
- Backend tests ≥85% coverage
- Frontend tests ≥85% coverage
- User documentation
- Developer guide
- Security scans pass
- Pre-commit hooks pass
15. Revision History
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-06 | Engineering Director | Initial specification |