# 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 `plugin` package 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:
1. Install Charon's PowerDNS plugin (this feature) - handles UI/API/credentials
2. Use Caddy built with [caddy-dns/powerdns](https://github.com/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.
```go
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`**
```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`**
```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`**
```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`**
```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:
```go
// 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:
```go
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:
```typescript
// New API endpoint: GET /api/v1/dns-providers/types/:type/fields
const { data: credentialFields } = useProviderCredentialFields(providerType);
// Render fields dynamically
{credentialFields?.map(field => (
))}
```
### 4.3 API Client
**File: `frontend/src/api/plugins.ts`**
```typescript
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('/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`**
```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:**
```bash
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:
```bash
# 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:
```yaml
# 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:
```bash
# 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
1. **Code Review:** Always review plugin source before deployment
2. **Isolated Builds:** Build plugins in isolated environments
3. **Regular Updates:** Keep plugin signatures updated after rebuilds
4. **Minimal Permissions:** Run Charon with minimal filesystem permissions
5. **Audit Logging:** All plugin load events are logged
6. **Version Pinning:** Pin Go version and dependencies in plugin builds
7. **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
```bash
# 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:
>
> ```bash
> 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
1. **Plugin Load Failure:** Log error, continue without plugin, show error in admin UI
2. **Registry Failure:** Fall back to empty registry, built-in providers still work via init()
3. **Complete Disable:** Set `CHARON_PLUGINS_ENABLED=false` to 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 |