feat: registry-driven DNS provider type discovery

Phase 1 of Custom DNS Provider Plugin Support: the /api/v1/dns-providers/types
endpoint now returns types dynamically from the dnsprovider.Global() registry
instead of a hardcoded list.

Backend handler queries registry for all provider types, metadata, and fields
Response includes is_built_in flag to distinguish plugins from built-ins
Frontend types updated with DNSProviderField interface and new response shape
Fixed flaky WAF exclusion test (isolated file-based SQLite DB)
Updated operator docs for registry-driven discovery and plugin installation
Refs: #461
This commit is contained in:
GitHub Actions
2026-01-14 18:05:46 +00:00
parent 73bf0ea78b
commit 77a020b4db
10 changed files with 645 additions and 1181 deletions

View File

@@ -205,14 +205,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -2,9 +2,11 @@ package handlers
import (
"net/http"
"sort"
"strconv"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"github.com/gin-gonic/gin"
)
@@ -214,211 +216,81 @@ func (h *DNSProviderHandler) TestCredentials(c *gin.Context) {
// GetTypes handles GET /api/v1/dns-providers/types
// Returns the list of supported DNS provider types with their required fields.
// Types are sourced from the provider registry (built-in, custom, and external plugins).
func (h *DNSProviderHandler) GetTypes(c *gin.Context) {
types := []gin.H{
{
"type": "cloudflare",
"name": "Cloudflare",
"fields": []gin.H{
{
"name": "api_token",
"label": "API Token",
"type": "password",
"required": true,
"hint": "Token with Zone:DNS:Edit permissions",
},
},
"documentation_url": "https://developers.cloudflare.com/api/tokens/",
},
{
"type": "route53",
"name": "Amazon Route 53",
"fields": []gin.H{
{
"name": "access_key_id",
"label": "Access Key ID",
"type": "text",
"required": true,
},
{
"name": "secret_access_key",
"label": "Secret Access Key",
"type": "password",
"required": true,
},
{
"name": "region",
"label": "AWS Region",
"type": "text",
"required": true,
"default": "us-east-1",
},
},
"documentation_url": "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-routing-traffic.html",
},
{
"type": "digitalocean",
"name": "DigitalOcean",
"fields": []gin.H{
{
"name": "auth_token",
"label": "API Token",
"type": "password",
"required": true,
"hint": "Personal Access Token with read/write scope",
},
},
"documentation_url": "https://docs.digitalocean.com/reference/api/api-reference/",
},
{
"type": "googleclouddns",
"name": "Google Cloud DNS",
"fields": []gin.H{
{
"name": "service_account_json",
"label": "Service Account JSON",
"type": "textarea",
"required": true,
"hint": "JSON key file for service account with DNS Administrator role",
},
{
"name": "project",
"label": "Project ID",
"type": "text",
"required": true,
},
},
"documentation_url": "https://cloud.google.com/dns/docs/",
},
{
"type": "namecheap",
"name": "Namecheap",
"fields": []gin.H{
{
"name": "api_user",
"label": "API Username",
"type": "text",
"required": true,
},
{
"name": "api_key",
"label": "API Key",
"type": "password",
"required": true,
},
{
"name": "client_ip",
"label": "Client IP Address",
"type": "text",
"required": true,
"hint": "Your server's public IP address (whitelisted in Namecheap)",
},
},
"documentation_url": "https://www.namecheap.com/support/api/intro/",
},
{
"type": "godaddy",
"name": "GoDaddy",
"fields": []gin.H{
{
"name": "api_key",
"label": "API Key",
"type": "text",
"required": true,
},
{
"name": "api_secret",
"label": "API Secret",
"type": "password",
"required": true,
},
},
"documentation_url": "https://developer.godaddy.com/",
},
{
"type": "azure",
"name": "Azure DNS",
"fields": []gin.H{
{
"name": "tenant_id",
"label": "Tenant ID",
"type": "text",
"required": true,
},
{
"name": "client_id",
"label": "Client ID",
"type": "text",
"required": true,
},
{
"name": "client_secret",
"label": "Client Secret",
"type": "password",
"required": true,
},
{
"name": "subscription_id",
"label": "Subscription ID",
"type": "text",
"required": true,
},
{
"name": "resource_group",
"label": "Resource Group",
"type": "text",
"required": true,
},
},
"documentation_url": "https://docs.microsoft.com/en-us/azure/dns/",
},
{
"type": "hetzner",
"name": "Hetzner",
"fields": []gin.H{
{
"name": "api_key",
"label": "API Key",
"type": "password",
"required": true,
},
},
"documentation_url": "https://docs.hetzner.com/dns-console/dns/general/dns-overview/",
},
{
"type": "vultr",
"name": "Vultr",
"fields": []gin.H{
{
"name": "api_key",
"label": "API Key",
"type": "password",
"required": true,
},
},
"documentation_url": "https://www.vultr.com/api/",
},
{
"type": "dnsimple",
"name": "DNSimple",
"fields": []gin.H{
{
"name": "oauth_token",
"label": "OAuth Token",
"type": "password",
"required": true,
},
{
"name": "account_id",
"label": "Account ID",
"type": "text",
"required": true,
},
},
"documentation_url": "https://developer.dnsimple.com/",
},
// Get all registered providers from the global registry
providers := dnsprovider.Global().List()
// Build response with provider metadata and fields
types := make([]gin.H, 0, len(providers))
for _, provider := range providers {
metadata := provider.Metadata()
// Combine required and optional fields
requiredFields := provider.RequiredCredentialFields()
optionalFields := provider.OptionalCredentialFields()
// Convert fields to response format with required flag
fields := make([]gin.H, 0, len(requiredFields)+len(optionalFields))
for _, f := range requiredFields {
field := gin.H{
"name": f.Name,
"label": f.Label,
"type": f.Type,
"required": true,
}
if f.Placeholder != "" {
field["placeholder"] = f.Placeholder
}
if f.Hint != "" {
field["hint"] = f.Hint
}
if len(f.Options) > 0 {
field["options"] = f.Options
}
fields = append(fields, field)
}
for _, f := range optionalFields {
field := gin.H{
"name": f.Name,
"label": f.Label,
"type": f.Type,
"required": false,
}
if f.Placeholder != "" {
field["placeholder"] = f.Placeholder
}
if f.Hint != "" {
field["hint"] = f.Hint
}
if len(f.Options) > 0 {
field["options"] = f.Options
}
fields = append(fields, field)
}
providerType := gin.H{
"type": metadata.Type,
"name": metadata.Name,
"description": metadata.Description,
"is_built_in": metadata.IsBuiltIn,
"fields": fields,
}
if metadata.DocumentationURL != "" {
providerType["documentation_url"] = metadata.DocumentationURL
}
types = append(types, providerType)
}
// Sort by type for stable, predictable output (registry.List already sorts, but explicit for safety)
sort.Slice(types, func(i, j int) bool {
return types[i]["type"].(string) < types[j]["type"].(string)
})
c.JSON(http.StatusOK, gin.H{
"types": types,
})

View File

@@ -7,12 +7,14 @@ import (
"errors"
"net/http"
"net/http/httptest"
"sort"
"testing"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/builtin" // Auto-register DNS providers
_ "github.com/Wikid82/charon/backend/pkg/dnsprovider/custom" // Auto-register custom providers (manual)
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -546,41 +548,175 @@ func TestDNSProviderHandler_TestCredentials(t *testing.T) {
func TestDNSProviderHandler_GetTypes(t *testing.T) {
router, _ := setupDNSProviderTestRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
router.ServeHTTP(w, req)
t.Run("returns registry-driven types", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
types := response["types"].([]interface{})
assert.NotEmpty(t, types)
types := response["types"].([]interface{})
assert.NotEmpty(t, types)
// Verify structure of first type
cloudflare := types[0].(map[string]interface{})
assert.Equal(t, "cloudflare", cloudflare["type"])
assert.Equal(t, "Cloudflare", cloudflare["name"])
assert.NotEmpty(t, cloudflare["fields"])
assert.NotEmpty(t, cloudflare["documentation_url"])
// Build a map for easier lookup
typeMap := make(map[string]map[string]interface{})
for _, providerData := range types {
typeData := providerData.(map[string]interface{})
typeMap[typeData["type"].(string)] = typeData
}
// Verify all expected provider types are present
providerTypes := make(map[string]bool)
for _, t := range types {
typeMap := t.(map[string]interface{})
providerTypes[typeMap["type"].(string)] = true
}
// Verify cloudflare (built-in) is present with correct metadata
cloudflare, exists := typeMap["cloudflare"]
assert.True(t, exists, "cloudflare provider should exist")
if exists {
assert.Equal(t, "Cloudflare", cloudflare["name"])
assert.Equal(t, true, cloudflare["is_built_in"])
assert.NotEmpty(t, cloudflare["description"])
assert.NotEmpty(t, cloudflare["documentation_url"])
assert.NotEmpty(t, cloudflare["fields"])
expectedTypes := []string{
"cloudflare", "route53", "digitalocean", "googleclouddns",
"namecheap", "godaddy", "azure", "hetzner", "vultr", "dnsimple",
}
// Verify field structure
fields := cloudflare["fields"].([]interface{})
assert.NotEmpty(t, fields)
firstField := fields[0].(map[string]interface{})
assert.NotEmpty(t, firstField["name"])
assert.NotEmpty(t, firstField["label"])
assert.NotEmpty(t, firstField["type"])
_, hasRequired := firstField["required"]
assert.True(t, hasRequired, "field should have required attribute")
}
for _, expected := range expectedTypes {
assert.True(t, providerTypes[expected], "Missing provider type: "+expected)
}
// Verify manual (custom, non-built-in) is present
manual, exists := typeMap["manual"]
assert.True(t, exists, "manual provider should exist")
if exists {
assert.Equal(t, "Manual (No Automation)", manual["name"])
assert.Equal(t, false, manual["is_built_in"])
assert.NotEmpty(t, manual["description"])
}
// Verify types are sorted alphabetically
var typeNames []string
for _, providerData := range types {
typeData := providerData.(map[string]interface{})
typeNames = append(typeNames, typeData["type"].(string))
}
sortedNames := make([]string, len(typeNames))
copy(sortedNames, typeNames)
sort.Strings(sortedNames)
assert.Equal(t, sortedNames, typeNames, "Types should be sorted alphabetically")
})
t.Run("includes all expected built-in providers", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
types := response["types"].([]interface{})
// Build type map
providerTypes := make(map[string]bool)
for _, providerData := range types {
typeData := providerData.(map[string]interface{})
providerTypes[typeData["type"].(string)] = true
}
// Expected built-in types
expectedTypes := []string{
"cloudflare", "route53", "digitalocean", "googleclouddns",
"namecheap", "godaddy", "azure", "hetzner", "vultr", "dnsimple",
}
for _, expected := range expectedTypes {
assert.True(t, providerTypes[expected], "Missing provider type: "+expected)
}
// Verify manual provider is included (custom, not built-in)
assert.True(t, providerTypes["manual"], "Missing manual provider type")
})
t.Run("fields include required flag", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
types := response["types"].([]interface{})
// Find cloudflare to test required fields
for _, providerData := range types {
typeData := providerData.(map[string]interface{})
if typeData["type"] == "cloudflare" {
fields := typeData["fields"].([]interface{})
// Cloudflare has at least one required field (api_token)
foundRequired := false
for _, f := range fields {
field := f.(map[string]interface{})
if field["name"] == "api_token" {
assert.Equal(t, true, field["required"])
foundRequired = true
}
}
assert.True(t, foundRequired, "Cloudflare should have required api_token field")
}
// Manual provider has optional fields
if typeData["type"] == "manual" {
fields := typeData["fields"].([]interface{})
for _, f := range fields {
field := f.(map[string]interface{})
// Manual provider's fields are optional
assert.Equal(t, false, field["required"])
}
}
}
})
t.Run("optional field attributes are included when present", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/dns-providers/types", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
types := response["types"].([]interface{})
// Find a provider with hint/placeholder to verify optional fields
for _, providerData := range types {
typeData := providerData.(map[string]interface{})
if typeData["type"] == "cloudflare" {
fields := typeData["fields"].([]interface{})
for _, f := range fields {
field := f.(map[string]interface{})
if field["name"] == "api_token" {
// Cloudflare api_token should have hint and/or placeholder
_, hasHint := field["hint"]
_, hasPlaceholder := field["placeholder"]
assert.True(t, hasHint || hasPlaceholder, "api_token field should have hint or placeholder")
}
}
}
}
})
}
func TestDNSProviderHandler_CredentialsNeverExposed(t *testing.T) {

View File

@@ -3,14 +3,21 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
@@ -499,7 +506,29 @@ func TestSecurityHandler_DeleteWAFExclusion_NegativeRuleID(t *testing.T) {
// Integration test: Full WAF exclusion workflow
func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
gin.SetMode(gin.TestMode)
db := setupTestDB(t)
// Create a temporary file-based SQLite database for complete isolation
// This avoids all the shared memory locking issues with in-memory databases
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, fmt.Sprintf("waf_test_%d.db", time.Now().UnixNano()))
dsn := fmt.Sprintf("%s?_journal_mode=WAL&_busy_timeout=10000", dbPath)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err, "failed to open test database")
// Ensure cleanup
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
os.Remove(dbPath)
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
})
// Migrate the required models
require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityAudit{}))
handler := NewSecurityHandler(config.SecurityConfig{}, db, nil)
@@ -512,10 +541,12 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/security/waf/exclusions", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.Equal(t, http.StatusOK, w.Code, "Step 1: GET should return 200")
var resp map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &resp)
assert.Len(t, resp["exclusions"].([]any), 0)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "Step 1: response should be valid JSON")
exclusions, ok := resp["exclusions"].([]any)
require.True(t, ok, "Step 1: exclusions should be an array")
require.Len(t, exclusions, 0, "Step 1: should start with 0 exclusions")
// Step 2: Add first exclusion (full rule removal)
payload := map[string]any{
@@ -527,7 +558,7 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
req, _ = http.NewRequest("POST", "/security/waf/exclusions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.Equal(t, http.StatusOK, w.Code, "Step 2: POST first exclusion should return 200, got: %s", w.Body.String())
// Step 3: Add second exclusion (targeted)
payload = map[string]any{
@@ -540,28 +571,33 @@ func TestSecurityHandler_WAFExclusion_FullWorkflow(t *testing.T) {
req, _ = http.NewRequest("POST", "/security/waf/exclusions", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.Equal(t, http.StatusOK, w.Code, "Step 3: POST second exclusion should return 200, got: %s", w.Body.String())
// Step 4: Verify both exclusions exist
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/security/waf/exclusions", http.NoBody)
router.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &resp)
assert.Len(t, resp["exclusions"].([]any), 2)
require.Equal(t, http.StatusOK, w.Code, "Step 4: GET should return 200")
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "Step 4: response should be valid JSON")
exclusions, ok = resp["exclusions"].([]any)
require.True(t, ok, "Step 4: exclusions should be an array")
require.Len(t, exclusions, 2, "Step 4: should have 2 exclusions after adding 2")
// Step 5: Delete first exclusion
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/security/waf/exclusions/942100", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
require.Equal(t, http.StatusOK, w.Code, "Step 5: DELETE should return 200, got: %s", w.Body.String())
// Step 6: Verify only second exclusion remains
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/security/waf/exclusions", http.NoBody)
router.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &resp)
exclusions := resp["exclusions"].([]any)
assert.Len(t, exclusions, 1)
require.Equal(t, http.StatusOK, w.Code, "Step 6: GET should return 200")
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp), "Step 6: response should be valid JSON")
exclusions, ok = resp["exclusions"].([]any)
require.True(t, ok, "Step 6: exclusions should be an array")
require.Len(t, exclusions, 1, "Step 6: should have 1 exclusion after deleting 1")
first := exclusions[0].(map[string]any)
assert.Equal(t, float64(941100), first["rule_id"])
assert.Equal(t, "ARGS:content", first["target"])

View File

@@ -151,6 +151,47 @@ When running Charon in Docker:
Once a plugin is installed and loaded, it appears in the DNS provider list alongside built-in providers.
### Discovering Loaded Plugins via API
Query available provider types to see all registered providers (built-in and plugins):
```bash
curl https://charon.example.com/api/v1/dns-providers/types \
-H "Authorization: Bearer YOUR-TOKEN"
```
**Response:**
```json
{
"types": [
{
"type": "cloudflare",
"name": "Cloudflare",
"description": "Cloudflare DNS provider",
"documentation_url": "https://developers.cloudflare.com/api/",
"is_built_in": true,
"fields": [...]
},
{
"type": "powerdns",
"name": "PowerDNS",
"description": "PowerDNS Authoritative Server with HTTP API",
"documentation_url": "https://doc.powerdns.com/authoritative/http-api/",
"is_built_in": false,
"fields": [...]
}
]
}
```
**Key fields:**
| Field | Description |
|-------|-------------|
| `is_built_in` | `true` = compiled into Charon, `false` = external plugin |
| `fields` | Credential field specifications for the UI form |
### Via Web UI
1. Navigate to **Settings** → **DNS Providers**
@@ -224,6 +265,19 @@ The plugin automatically configures Caddy's DNS challenge for Let's Encrypt:
### Listing Loaded Plugins
**Via Types Endpoint (Recommended):**
Filter for plugins using `is_built_in: false`:
```bash
curl https://charon.example.com/api/v1/dns-providers/types \
-H "Authorization: Bearer YOUR-TOKEN" | jq '.types[] | select(.is_built_in == false)'
```
**Via Plugins Endpoint:**
Get detailed plugin metadata including version and author:
```bash
curl https://charon.example.com/api/admin/plugins \
-H "Authorization: Bearer YOUR-TOKEN"

View File

@@ -12,7 +12,13 @@ DNS providers enable Charon to obtain SSL/TLS certificates for wildcard domains
## Supported DNS Providers
Charon supports the following DNS providers through Caddy's libdns modules:
Charon dynamically discovers available DNS provider types from an internal registry. This registry includes:
- **Built-in providers** — Compiled into Charon (Cloudflare, Route 53, etc.)
- **Custom providers** — Special-purpose providers like `manual` for unsupported DNS services
- **External plugins** — Third-party `.so` plugin files loaded at runtime
### Built-in Providers
| Provider | Type | Setup Guide |
|----------|------|-------------|
@@ -27,6 +33,84 @@ Charon supports the following DNS providers through Caddy's libdns modules:
| Vultr | `vultr` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.vultr) |
| DNSimple | `dnsimple` | [Documentation](https://caddyserver.com/docs/modules/dns.providers.dnsimple) |
### Custom Providers
| Provider | Type | Description |
|----------|------|-------------|
| Manual DNS | `manual` | For DNS providers without API support. Displays TXT record for manual creation. |
### Discovering Available Provider Types
Query available provider types programmatically via the API:
```bash
curl https://your-charon-instance/api/v1/dns-providers/types \
-H "Authorization: Bearer YOUR_TOKEN"
```
**Example Response:**
```json
{
"types": [
{
"type": "cloudflare",
"name": "Cloudflare",
"description": "Cloudflare DNS provider",
"documentation_url": "https://developers.cloudflare.com/api/",
"is_built_in": true,
"fields": [...]
},
{
"type": "manual",
"name": "Manual DNS",
"description": "Manually create DNS TXT records",
"documentation_url": "",
"is_built_in": false,
"fields": []
}
]
}
```
**Response fields:**
| Field | Description |
|-------|-------------|
| `type` | Unique identifier used in API requests |
| `name` | Human-readable display name |
| `description` | Brief description of the provider |
| `documentation_url` | Link to provider's API documentation |
| `is_built_in` | `true` for compiled providers, `false` for plugins/custom |
| `fields` | Required credential fields and their specifications |
> **Tip:** Use `is_built_in` to distinguish official providers from external plugins in your automation workflows.
## Adding External Plugins
Extend Charon with third-party DNS provider plugins by placing `.so` files in the plugin directory.
### Installation
1. Set the plugin directory environment variable:
```bash
export CHARON_PLUGINS_DIR=/etc/charon/plugins
```
2. Copy plugin files:
```bash
cp powerdns.so /etc/charon/plugins/
chmod 755 /etc/charon/plugins/powerdns.so
```
3. Restart Charon — plugins load automatically at startup.
4. Verify the plugin appears in `GET /api/v1/dns-providers/types` with `is_built_in: false`.
For detailed plugin installation and security guidance, see [Custom Plugins](../features/custom-plugins.md).
## General Setup Workflow
### 1. Prerequisites

File diff suppressed because it is too large Load Diff

View File

@@ -167,7 +167,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -526,7 +525,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -573,7 +571,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3284,7 +3281,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -3361,7 +3359,6 @@
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3372,7 +3369,6 @@
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3383,7 +3379,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3423,7 +3418,6 @@
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.53.0",
"@typescript-eslint/types": "8.53.0",
@@ -3802,7 +3796,6 @@
"integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "4.0.17",
"fflate": "^0.8.2",
@@ -3838,7 +3831,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4070,7 +4062,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4277,8 +4268,7 @@
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"peer": true
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/data-urls": {
"version": "6.0.0",
@@ -4367,7 +4357,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4531,7 +4522,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5255,7 +5245,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5449,7 +5438,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -5896,6 +5884,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6309,7 +6298,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6339,6 +6327,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6353,6 +6342,7 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6362,6 +6352,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@@ -6409,7 +6400,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6419,7 +6409,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6491,7 +6480,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@@ -7041,7 +7031,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7179,7 +7168,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -7255,7 +7243,6 @@
"integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.17",
"@vitest/mocker": "4.0.17",
@@ -7490,7 +7477,6 @@
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -56,24 +56,29 @@ export interface DNSTestResult {
propagation_time_ms?: number
}
/** Field definition for DNS provider credentials */
export interface DNSProviderField {
name: string
label: string
type: 'text' | 'password' | 'textarea' | 'select'
required: boolean
default?: string
hint?: string
placeholder?: string
options?: Array<{
value: string
label: string
}>
}
/** DNS provider type information with field definitions */
export interface DNSProviderTypeInfo {
type: DNSProviderType
name: string
fields: Array<{
name: string
label: string
type: 'text' | 'password' | 'textarea' | 'select'
required: boolean
default?: string
hint?: string
placeholder?: string
options?: Array<{
value: string
label: string
}>
}>
documentation_url: string
description?: string
documentation_url?: string
is_built_in?: boolean
fields: DNSProviderField[]
}
/** Response for list endpoint */

View File

@@ -11,6 +11,7 @@ import {
type DNSProvider,
type DNSProviderRequest,
type DNSProviderTypeInfo,
type DNSProviderField,
type DNSTestResult,
} from '../api/dnsProviders'
@@ -111,5 +112,6 @@ export type {
DNSProvider,
DNSProviderRequest,
DNSProviderTypeInfo,
DNSProviderField,
DNSTestResult,
}