19 KiB
Executable File
DNS Provider Plugin Development
This guide covers the technical details of developing custom DNS provider plugins for Charon.
Overview
Charon uses Go's plugin system to dynamically load DNS provider implementations. Plugins implement the ProviderPlugin interface and are compiled as shared libraries (.so files).
Architecture
┌─────────────────────────────────────────┐
│ Charon Core Process │
│ ┌───────────────────────────────────┐ │
│ │ Global Provider Registry │ │
│ ├───────────────────────────────────┤ │
│ │ Built-in Providers │ │
│ │ - Cloudflare │ │
│ │ - DNSimple │ │
│ │ - Route53 │ │
│ ├───────────────────────────────────┤ │
│ │ External Plugins (*.so) │ │
│ │ - PowerDNS [loaded] │ │
│ │ - Custom [loaded] │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Platform Requirements
Supported Platforms
- Linux: x86_64, ARM64 (primary target)
- macOS: x86_64, ARM64 (development/testing)
- Windows: Not supported (Go plugin limitation)
Build Requirements
- CGO: Must be enabled (
CGO_ENABLED=1) - Go Version: Must match Charon's Go version exactly (currently 1.25.6+)
- Compiler: GCC/Clang for Linux, Xcode tools for macOS
- Build Mode: Must use
-buildmode=plugin
Interface Specification
Interface Version
Current interface version: v1
The interface version is defined in backend/pkg/dnsprovider/plugin.go:
const InterfaceVersion = "v1"
Core Interface
All plugins must implement dnsprovider.ProviderPlugin:
type ProviderPlugin interface {
Type() string
Metadata() ProviderMetadata
Init() error
Cleanup() error
RequiredCredentialFields() []CredentialFieldSpec
OptionalCredentialFields() []CredentialFieldSpec
ValidateCredentials(creds map[string]string) error
TestCredentials(creds map[string]string) error
SupportsMultiCredential() bool
BuildCaddyConfig(creds map[string]string) map[string]any
BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any
PropagationTimeout() time.Duration
PollingInterval() time.Duration
}
Method Reference
Type() string
Returns the unique provider identifier.
- Must be lowercase, alphanumeric with optional underscores
- Used as the key for registration and lookup
- Examples:
"powerdns","custom_dns","acme_dns"
Metadata() ProviderMetadata
Returns descriptive information for UI display:
type ProviderMetadata struct {
Type string `json:"type"` // Same as Type()
Name string `json:"name"` // Display name
Description string `json:"description"` // Brief description
DocumentationURL string `json:"documentation_url"` // Help link
Author string `json:"author"` // Plugin author
Version string `json:"version"` // Plugin version
IsBuiltIn bool `json:"is_built_in"` // Always false for plugins
GoVersion string `json:"go_version"` // Build Go version
InterfaceVersion string `json:"interface_version"` // Plugin interface version
}
Required fields: Type, Name, Description, IsBuiltIn (false), GoVersion, InterfaceVersion
Init() error
Called after the plugin is loaded, before registration.
Use for:
- Loading configuration files
- Validating environment
- Establishing persistent connections
- Resource allocation
Return an error to prevent registration.
Cleanup() error
Called before the plugin is unregistered (graceful shutdown).
Use for:
- Closing connections
- Flushing caches
- Releasing resources
Note: Due to Go runtime limitations, plugin code remains in memory after Cleanup().
RequiredCredentialFields() []CredentialFieldSpec
Returns credential fields that must be provided.
Example:
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your API token",
Hint: "Found in your account settings",
},
}
OptionalCredentialFields() []CredentialFieldSpec
Returns credential fields that may be provided.
Example:
return []dnsprovider.CredentialFieldSpec{
{
Name: "timeout",
Label: "Timeout (seconds)",
Type: "text",
Placeholder: "30",
Hint: "API request timeout",
},
}
ValidateCredentials(creds map[string]string) error
Validates credential format and presence (no network calls).
Example:
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
}
TestCredentials(creds map[string]string) error
Verifies credentials work with the provider API (may make network calls).
Example:
func (p *PowerDNSProvider) TestCredentials(creds map[string]string) error {
if err := p.ValidateCredentials(creds); err != nil {
return err
}
// Test API connectivity
url := creds["api_url"] + "/api/v1/servers"
req, _ := http.NewRequest("GET", url, nil)
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
}
SupportsMultiCredential() bool
Indicates if the provider supports zone-specific credentials (Phase 3 feature).
Return false for most implementations:
func (p *PowerDNSProvider) SupportsMultiCredential() bool {
return false
}
BuildCaddyConfig(creds map[string]string) map[string]any
Constructs Caddy DNS challenge configuration.
The returned map is embedded into Caddy's TLS automation policy for ACME DNS-01 challenges.
Example:
func (p *PowerDNSProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "powerdns",
"api_url": creds["api_url"],
"api_key": creds["api_key"],
"server_id": creds["server_id"],
}
}
Caddy Configuration Reference: See Caddy DNS Providers
BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any
Constructs zone-specific configuration (multi-credential mode).
Only called if SupportsMultiCredential() returns true.
Most plugins can simply delegate to BuildCaddyConfig():
func (p *PowerDNSProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
PropagationTimeout() time.Duration
Returns the recommended DNS propagation wait time.
Typical values:
- Fast providers: 30-60 seconds (Cloudflare, PowerDNS)
- Standard providers: 60-120 seconds (DNSimple, Route53)
- Slow providers: 120-300 seconds (traditional DNS)
func (p *PowerDNSProvider) PropagationTimeout() time.Duration {
return 60 * time.Second
}
PollingInterval() time.Duration
Returns the recommended polling interval for DNS verification.
Typical values: 2-10 seconds
func (p *PowerDNSProvider) PollingInterval() time.Duration {
return 2 * time.Second
}
Plugin Structure
Minimal Plugin Template
package main
import (
"fmt"
"runtime"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Plugin is the exported symbol that Charon looks for
var Plugin dnsprovider.ProviderPlugin = &MyProvider{}
type MyProvider struct{}
func (p *MyProvider) Type() string {
return "myprovider"
}
func (p *MyProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "myprovider",
Name: "My DNS Provider",
Description: "Custom DNS provider implementation",
DocumentationURL: "https://example.com/docs",
Author: "Your Name",
Version: "1.0.0",
IsBuiltIn: false,
GoVersion: runtime.Version(),
InterfaceVersion: dnsprovider.InterfaceVersion,
}
}
func (p *MyProvider) Init() error {
return nil
}
func (p *MyProvider) Cleanup() error {
return nil
}
func (p *MyProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_key",
Label: "API Key",
Type: "password",
Placeholder: "Enter your API key",
Hint: "Found in your account settings",
},
}
}
func (p *MyProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *MyProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_key"] == "" {
return fmt.Errorf("api_key is required")
}
return nil
}
func (p *MyProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *MyProvider) SupportsMultiCredential() bool {
return false
}
func (p *MyProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "myprovider",
"api_key": creds["api_key"],
}
}
func (p *MyProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *MyProvider) PropagationTimeout() time.Duration {
return 60 * time.Second
}
func (p *MyProvider) PollingInterval() time.Duration {
return 5 * time.Second
}
func main() {}
Project Layout
my-provider-plugin/
├── go.mod
├── go.sum
├── main.go
├── Makefile
└── README.md
go.mod Requirements
module github.com/yourname/charon-plugin-myprovider
go 1.25
require (
github.com/Wikid82/charon v0.0.0-20240101000000-abcdef123456
)
Important: Use replace directive for local development:
replace github.com/Wikid82/charon => /path/to/charon
Building Plugins
Build Command
CGO_ENABLED=1 go build -buildmode=plugin -o myprovider.so main.go
Build Requirements
-
CGO must be enabled:
export CGO_ENABLED=1 -
Go version must match Charon:
go version # Must match Charon's build Go version -
Architecture must match:
# For cross-compilation GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=plugin
Makefile Example
.PHONY: build clean install
PLUGIN_NAME = myprovider
OUTPUT = $(PLUGIN_NAME).so
INSTALL_DIR = /etc/charon/plugins
build:
CGO_ENABLED=1 go build -buildmode=plugin -o $(OUTPUT) main.go
clean:
rm -f $(OUTPUT)
install: build
install -m 755 $(OUTPUT) $(INSTALL_DIR)/
test:
go test -v ./...
lint:
golangci-lint run
signature:
@echo "SHA-256 Signature:"
@sha256sum $(OUTPUT)
Build Script
#!/bin/bash
set -e
PLUGIN_NAME="myprovider"
GO_VERSION=$(go version | awk '{print $3}')
CHARON_GO_VERSION="go1.25.6"
# Verify Go version
if [ "$GO_VERSION" != "$CHARON_GO_VERSION" ]; then
echo "Warning: Go version mismatch"
echo " Plugin: $GO_VERSION"
echo " Charon: $CHARON_GO_VERSION"
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# Build plugin
echo "Building $PLUGIN_NAME.so..."
CGO_ENABLED=1 go build -buildmode=plugin -o "${PLUGIN_NAME}.so" main.go
# Generate signature
echo "Generating signature..."
sha256sum "${PLUGIN_NAME}.so" | tee "${PLUGIN_NAME}.so.sha256"
echo "Build complete!"
Development Workflow
1. Set Up Development Environment
# Clone plugin template
git clone https://github.com/yourname/charon-plugin-template my-provider
cd my-provider
# Install dependencies
go mod download
# Set up local Charon dependency
echo 'replace github.com/Wikid82/charon => /path/to/charon' >> go.mod
go mod tidy
2. Implement Provider Interface
Edit main.go to implement all required methods.
3. Test Locally
# Build plugin
make build
# Copy to Charon plugin directory
cp myprovider.so /etc/charon/plugins/
# Restart Charon
systemctl restart charon
# Check logs
journalctl -u charon -f | grep plugin
4. Debug Plugin Loading
Enable debug logging in Charon:
log:
level: debug
Check for errors:
journalctl -u charon -n 100 | grep -i plugin
5. Test Credential Validation
curl -X POST http://localhost:8080/api/admin/dns-providers/test \
-H "Content-Type: application/json" \
-d '{
"type": "myprovider",
"credentials": {
"api_key": "test-key"
}
}'
6. Test DNS Challenge
Configure a test domain to use your provider and request a certificate.
Monitor Caddy logs for DNS challenge execution:
docker logs charon-caddy -f | grep dns
Best Practices
Security
- Validate All Inputs: Never trust credential data
- Use HTTPS: Always use TLS for API connections
- Timeout Requests: Set reasonable timeouts on all HTTP calls
- Sanitize Errors: Don't leak credentials in error messages
- Log Safely: Redact sensitive data from logs
Performance
- Minimize Init() Work: Fast startup is critical
- Connection Pooling: Reuse HTTP clients and connections
- Efficient Polling: Use appropriate polling intervals
- Cache When Possible: Cache provider metadata
- Fail Fast: Return errors quickly for invalid credentials
Reliability
- Handle Nil Gracefully: Check for nil maps and slices
- Provide Defaults: Use sensible defaults for optional fields
- Retry Transient Errors: Implement exponential backoff
- Graceful Degradation: Continue working if non-critical features fail
Maintainability
- Document Public APIs: Use godoc comments
- Version Your Plugin: Include semantic versioning
- Test Thoroughly: Unit tests for all methods
- Provide Examples: Include configuration examples
Testing
Unit Tests
package main
import (
"testing"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"github.com/stretchr/testify/assert"
)
func TestValidateCredentials(t *testing.T) {
provider := &MyProvider{}
tests := []struct {
name string
creds map[string]string
expectErr bool
}{
{
name: "valid credentials",
creds: map[string]string{"api_key": "test-key"},
expectErr: false,
},
{
name: "missing api_key",
creds: map[string]string{},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.ValidateCredentials(tt.creds)
if tt.expectErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestMetadata(t *testing.T) {
provider := &MyProvider{}
meta := provider.Metadata()
assert.Equal(t, "myprovider", meta.Type)
assert.NotEmpty(t, meta.Name)
assert.False(t, meta.IsBuiltIn)
assert.Equal(t, dnsprovider.InterfaceVersion, meta.InterfaceVersion)
}
Integration Tests
func TestRealAPIConnection(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
provider := &MyProvider{}
creds := map[string]string{
"api_key": os.Getenv("TEST_API_KEY"),
}
err := provider.TestCredentials(creds)
assert.NoError(t, err)
}
Run integration tests:
go test -v ./... -count=1
Troubleshooting
Common Build Errors
plugin was built with a different version of package
Cause: Dependency version mismatch
Solution:
go clean -cache
go mod tidy
go build -buildmode=plugin
cannot use -buildmode=plugin
Cause: CGO not enabled
Solution:
export CGO_ENABLED=1
undefined: dnsprovider.ProviderPlugin
Cause: Missing or incorrect import
Solution:
import "github.com/Wikid82/charon/backend/pkg/dnsprovider"
Runtime Errors
plugin was built with a different version of Go
Cause: Go version mismatch between plugin and Charon
Solution: Rebuild plugin with matching Go version
symbol not found: Plugin
Cause: Plugin variable not exported
Solution:
// Must be exported (capitalized)
var Plugin dnsprovider.ProviderPlugin = &MyProvider{}
interface version mismatch
Cause: Plugin built against incompatible interface
Solution: Update plugin to match Charon's interface version
Publishing Plugins
Release Checklist
- All methods implemented and tested
- Go version matches current Charon release
- Interface version set correctly
- Documentation includes usage examples
- README includes installation instructions
- LICENSE file included
- Changelog maintained
- GitHub releases with binaries for all platforms
Distribution
-
GitHub Releases:
# Tag release git tag -a v1.0.0 -m "Release v1.0.0" git push origin v1.0.0 # Build for multiple platforms make build-all # Create GitHub release and attach binaries -
Signature File:
sha256sum *.so > SHA256SUMS gpg --sign SHA256SUMS -
Documentation:
- Include README with installation instructions
- Provide configuration examples
- List required Charon version
- Include troubleshooting section
Resources
Reference Implementation
- PowerDNS Plugin:
plugins/powerdns/main.go - Built-in Providers:
backend/pkg/dnsprovider/builtin/ - Plugin Interface:
backend/pkg/dnsprovider/plugin.go
External Documentation
Community
- GitHub Discussions: https://github.com/Wikid82/charon/discussions
- Plugin Registry: https://github.com/Wikid82/charon-plugins
- Issue Tracker: https://github.com/Wikid82/charon/issues