# 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`: ```go const InterfaceVersion = "v1" ``` ### Core Interface All plugins must implement `dnsprovider.ProviderPlugin`: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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: ```go 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](https://github.com/caddy-dns) #### `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()`: ```go 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) ```go 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 ```go func (p *PowerDNSProvider) PollingInterval() time.Duration { return 2 * time.Second } ``` ## Plugin Structure ### Minimal Plugin Template ```go 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 ```go 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: ```go replace github.com/Wikid82/charon => /path/to/charon ``` ## Building Plugins ### Build Command ```bash CGO_ENABLED=1 go build -buildmode=plugin -o myprovider.so main.go ``` ### Build Requirements 1. **CGO must be enabled:** ```bash export CGO_ENABLED=1 ``` 2. **Go version must match Charon:** ```bash go version # Must match Charon's build Go version ``` 3. **Architecture must match:** ```bash # For cross-compilation GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -buildmode=plugin ``` ### Makefile Example ```makefile .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 ```bash #!/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 ```bash # 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 ```bash # 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: ```yaml log: level: debug ``` Check for errors: ```bash journalctl -u charon -n 100 | grep -i plugin ``` ### 5. Test Credential Validation ```bash 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: ```bash docker logs charon-caddy -f | grep dns ``` ## Best Practices ### Security 1. **Validate All Inputs:** Never trust credential data 2. **Use HTTPS:** Always use TLS for API connections 3. **Timeout Requests:** Set reasonable timeouts on all HTTP calls 4. **Sanitize Errors:** Don't leak credentials in error messages 5. **Log Safely:** Redact sensitive data from logs ### Performance 1. **Minimize Init() Work:** Fast startup is critical 2. **Connection Pooling:** Reuse HTTP clients and connections 3. **Efficient Polling:** Use appropriate polling intervals 4. **Cache When Possible:** Cache provider metadata 5. **Fail Fast:** Return errors quickly for invalid credentials ### Reliability 1. **Handle Nil Gracefully:** Check for nil maps and slices 2. **Provide Defaults:** Use sensible defaults for optional fields 3. **Retry Transient Errors:** Implement exponential backoff 4. **Graceful Degradation:** Continue working if non-critical features fail ### Maintainability 1. **Document Public APIs:** Use godoc comments 2. **Version Your Plugin:** Include semantic versioning 3. **Test Thoroughly:** Unit tests for all methods 4. **Provide Examples:** Include configuration examples ## Testing ### Unit Tests ```go 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 ```go 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: ```bash go test -v ./... -count=1 ``` ## Troubleshooting ### Common Build Errors #### `plugin was built with a different version of package` **Cause:** Dependency version mismatch **Solution:** ```bash go clean -cache go mod tidy go build -buildmode=plugin ``` #### `cannot use -buildmode=plugin` **Cause:** CGO not enabled **Solution:** ```bash export CGO_ENABLED=1 ``` #### `undefined: dnsprovider.ProviderPlugin` **Cause:** Missing or incorrect import **Solution:** ```go 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:** ```go // 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 1. **GitHub Releases:** ```bash # 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 ``` 2. **Signature File:** ```bash sha256sum *.so > SHA256SUMS gpg --sign SHA256SUMS ``` 3. **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`](../../plugins/powerdns/main.go) - **Built-in Providers:** [`backend/pkg/dnsprovider/builtin/`](../../backend/pkg/dnsprovider/builtin/) - **Plugin Interface:** [`backend/pkg/dnsprovider/plugin.go`](../../backend/pkg/dnsprovider/plugin.go) ### External Documentation - [Go Plugin Package](https://pkg.go.dev/plugin) - [Caddy DNS Providers](https://github.com/caddy-dns) - [ACME DNS-01 Challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) ### Community - **GitHub Discussions:** - **Plugin Registry:** - **Issue Tracker:** ## See Also - [Custom Plugin Installation Guide](../features/custom-plugins.md) - [DNS Provider Configuration](../features/dns-providers.md) - [Contributing Guidelines](../../CONTRIBUTING.md)