Files
Charon/docs/development/plugin-development.md
akanealw eec8c28fb3
Some checks are pending
Go Benchmark / Performance Regression Check (push) Waiting to run
Cerberus Integration / Cerberus Security Stack Integration (push) Waiting to run
Upload Coverage to Codecov / Backend Codecov Upload (push) Waiting to run
Upload Coverage to Codecov / Frontend Codecov Upload (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (go) (push) Waiting to run
CodeQL - Analyze / CodeQL analysis (javascript-typescript) (push) Waiting to run
CrowdSec Integration / CrowdSec Bouncer Integration (push) Waiting to run
Docker Build, Publish & Test / build-and-push (push) Waiting to run
Docker Build, Publish & Test / Security Scan PR Image (push) Blocked by required conditions
Quality Checks / Auth Route Protection Contract (push) Waiting to run
Quality Checks / Codecov Trigger/Comment Parity Guard (push) Waiting to run
Quality Checks / Backend (Go) (push) Waiting to run
Quality Checks / Frontend (React) (push) Waiting to run
Rate Limit integration / Rate Limiting Integration (push) Waiting to run
Security Scan (PR) / Trivy Binary Scan (push) Waiting to run
Supply Chain Verification (PR) / Verify Supply Chain (push) Waiting to run
WAF integration / Coraza WAF Integration (push) Waiting to run
changed perms
2026-04-22 18:19:14 +00:00

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

  1. CGO must be enabled:

    export CGO_ENABLED=1
    
  2. Go version must match Charon:

    go version
    # Must match Charon's build Go version
    
  3. 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

  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

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

  1. 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
    
  2. Signature File:

    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

External Documentation

Community

See Also