chore: git cache cleanup

This commit is contained in:
GitHub Actions
2026-03-04 18:34:49 +00:00
parent c32cce2a88
commit 27c252600a
2001 changed files with 683185 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
// Package custom provides custom DNS provider plugins for non-built-in integrations.
package custom
import (
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// init automatically registers all custom DNS provider plugins when the package is imported.
func init() {
providers := []dnsprovider.ProviderPlugin{
NewManualProvider(),
NewRFC2136Provider(),
NewWebhookProvider(),
NewScriptProvider(),
}
for _, provider := range providers {
if err := provider.Init(); err != nil {
logger.Log().WithError(err).Warnf("Failed to initialize custom provider: %s", provider.Type())
continue
}
if err := dnsprovider.Global().Register(provider); err != nil {
logger.Log().WithError(err).Warnf("Failed to register custom provider: %s", provider.Type())
continue
}
logger.Log().Debugf("Registered custom DNS provider: %s", provider.Type())
}
}

View File

@@ -0,0 +1,177 @@
// Package custom provides custom DNS provider plugins for non-built-in integrations.
package custom
import (
"fmt"
"strconv"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Default configuration values for the manual provider.
const (
DefaultTimeoutMinutes = 10
DefaultPollingIntervalSeconds = 30
MinTimeoutMinutes = 1
MaxTimeoutMinutes = 60
MinPollingIntervalSeconds = 5
MaxPollingIntervalSeconds = 120
)
// ManualProvider implements the ProviderPlugin interface for manual DNS challenges.
// Users manually create TXT records at their DNS provider and click verify.
type ManualProvider struct {
timeoutMinutes int
pollingIntervalSeconds int
}
// NewManualProvider creates a new ManualProvider with default settings.
func NewManualProvider() *ManualProvider {
return &ManualProvider{
timeoutMinutes: DefaultTimeoutMinutes,
pollingIntervalSeconds: DefaultPollingIntervalSeconds,
}
}
// Type returns the unique provider type identifier.
func (p *ManualProvider) Type() string {
return "manual"
}
// Metadata returns descriptive information about the provider.
func (p *ManualProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "manual",
Name: "Manual (No Automation)",
Description: "Manually create DNS TXT records for ACME challenges. Suitable for testing or providers without API access.",
DocumentationURL: "https://charon.dev/docs/features/manual-dns-challenge",
IsBuiltIn: false,
Version: "1.0.0",
InterfaceVersion: dnsprovider.InterfaceVersion,
}
}
// Init is called after the plugin is registered.
func (p *ManualProvider) Init() error {
return nil
}
// Cleanup is called before the plugin is unregistered.
func (p *ManualProvider) Cleanup() error {
return nil
}
// RequiredCredentialFields returns credential fields that must be provided.
// Manual provider has no required credentials.
func (p *ManualProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
// OptionalCredentialFields returns credential fields that may be provided.
func (p *ManualProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "timeout_minutes",
Label: "Challenge Timeout (minutes)",
Type: "text",
Placeholder: "10",
Hint: fmt.Sprintf("Time before challenge expires (%d-%d minutes, default: %d)", MinTimeoutMinutes, MaxTimeoutMinutes, DefaultTimeoutMinutes),
},
{
Name: "polling_interval_seconds",
Label: "DNS Check Interval (seconds)",
Type: "text",
Placeholder: "30",
Hint: fmt.Sprintf("How often to check DNS propagation (%d-%d seconds, default: %d)", MinPollingIntervalSeconds, MaxPollingIntervalSeconds, DefaultPollingIntervalSeconds),
},
}
}
// ValidateCredentials checks if the provided credentials are valid.
func (p *ManualProvider) ValidateCredentials(creds map[string]string) error {
// Validate timeout if provided
if timeoutStr := creds["timeout_minutes"]; timeoutStr != "" {
timeout, err := strconv.Atoi(timeoutStr)
if err != nil {
return fmt.Errorf("timeout_minutes must be a number: %w", err)
}
if timeout < MinTimeoutMinutes || timeout > MaxTimeoutMinutes {
return fmt.Errorf("timeout_minutes must be between %d and %d", MinTimeoutMinutes, MaxTimeoutMinutes)
}
}
// Validate polling interval if provided
if intervalStr := creds["polling_interval_seconds"]; intervalStr != "" {
interval, err := strconv.Atoi(intervalStr)
if err != nil {
return fmt.Errorf("polling_interval_seconds must be a number: %w", err)
}
if interval < MinPollingIntervalSeconds || interval > MaxPollingIntervalSeconds {
return fmt.Errorf("polling_interval_seconds must be between %d and %d", MinPollingIntervalSeconds, MaxPollingIntervalSeconds)
}
}
return nil
}
// TestCredentials attempts to verify credentials work.
// For manual provider, this always succeeds since there's no external API.
func (p *ManualProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
func (p *ManualProvider) SupportsMultiCredential() bool {
return false
}
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
// For manual provider, this returns a marker that tells Caddy to use manual mode.
func (p *ManualProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
// Manual provider doesn't integrate with Caddy's DNS challenge directly.
// Instead, Charon handles the challenge flow and signals completion.
return map[string]any{
"name": "manual",
"manual": true,
}
}
// BuildCaddyConfigForZone constructs config for a specific zone.
func (p *ManualProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
// PropagationTimeout returns the recommended DNS propagation wait time.
func (p *ManualProvider) PropagationTimeout() time.Duration {
return time.Duration(p.timeoutMinutes) * time.Minute
}
// PollingInterval returns the recommended polling interval for DNS verification.
func (p *ManualProvider) PollingInterval() time.Duration {
return time.Duration(p.pollingIntervalSeconds) * time.Second
}
// GetTimeoutMinutes returns the configured timeout in minutes.
func (p *ManualProvider) GetTimeoutMinutes(creds map[string]string) int {
if timeoutStr := creds["timeout_minutes"]; timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
if timeout >= MinTimeoutMinutes && timeout <= MaxTimeoutMinutes {
return timeout
}
}
}
return DefaultTimeoutMinutes
}
// GetPollingIntervalSeconds returns the configured polling interval in seconds.
func (p *ManualProvider) GetPollingIntervalSeconds(creds map[string]string) int {
if intervalStr := creds["polling_interval_seconds"]; intervalStr != "" {
if interval, err := strconv.Atoi(intervalStr); err == nil {
if interval >= MinPollingIntervalSeconds && interval <= MaxPollingIntervalSeconds {
return interval
}
}
}
return DefaultPollingIntervalSeconds
}

View File

@@ -0,0 +1,367 @@
package custom
import (
"testing"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewManualProvider(t *testing.T) {
provider := NewManualProvider()
require.NotNil(t, provider)
assert.Equal(t, DefaultTimeoutMinutes, provider.timeoutMinutes)
assert.Equal(t, DefaultPollingIntervalSeconds, provider.pollingIntervalSeconds)
}
func TestManualProvider_Type(t *testing.T) {
provider := NewManualProvider()
assert.Equal(t, "manual", provider.Type())
}
func TestManualProvider_Metadata(t *testing.T) {
provider := NewManualProvider()
metadata := provider.Metadata()
assert.Equal(t, "manual", metadata.Type)
assert.Equal(t, "Manual (No Automation)", metadata.Name)
assert.Contains(t, metadata.Description, "Manually create DNS TXT records")
assert.NotEmpty(t, metadata.DocumentationURL)
assert.False(t, metadata.IsBuiltIn)
assert.Equal(t, "1.0.0", metadata.Version)
assert.Equal(t, dnsprovider.InterfaceVersion, metadata.InterfaceVersion)
}
func TestManualProvider_Init(t *testing.T) {
provider := NewManualProvider()
err := provider.Init()
assert.NoError(t, err)
}
func TestManualProvider_Cleanup(t *testing.T) {
provider := NewManualProvider()
err := provider.Cleanup()
assert.NoError(t, err)
}
func TestManualProvider_RequiredCredentialFields(t *testing.T) {
provider := NewManualProvider()
fields := provider.RequiredCredentialFields()
// Manual provider has no required credentials
assert.Empty(t, fields)
}
func TestManualProvider_OptionalCredentialFields(t *testing.T) {
provider := NewManualProvider()
fields := provider.OptionalCredentialFields()
assert.Len(t, fields, 2)
// Find timeout field
var timeoutField *dnsprovider.CredentialFieldSpec
var intervalField *dnsprovider.CredentialFieldSpec
for i := range fields {
if fields[i].Name == "timeout_minutes" {
timeoutField = &fields[i]
}
if fields[i].Name == "polling_interval_seconds" {
intervalField = &fields[i]
}
}
require.NotNil(t, timeoutField, "timeout_minutes field should exist")
assert.Equal(t, "Challenge Timeout (minutes)", timeoutField.Label)
assert.Equal(t, "text", timeoutField.Type)
assert.Equal(t, "10", timeoutField.Placeholder)
require.NotNil(t, intervalField, "polling_interval_seconds field should exist")
assert.Equal(t, "DNS Check Interval (seconds)", intervalField.Label)
assert.Equal(t, "text", intervalField.Type)
assert.Equal(t, "30", intervalField.Placeholder)
}
func TestManualProvider_ValidateCredentials(t *testing.T) {
provider := NewManualProvider()
tests := []struct {
name string
creds map[string]string
wantErr bool
errMsg string
}{
{
name: "empty credentials valid",
creds: map[string]string{},
wantErr: false,
},
{
name: "valid timeout",
creds: map[string]string{"timeout_minutes": "5"},
wantErr: false,
},
{
name: "valid polling interval",
creds: map[string]string{"polling_interval_seconds": "60"},
wantErr: false,
},
{
name: "valid both values",
creds: map[string]string{"timeout_minutes": "30", "polling_interval_seconds": "15"},
wantErr: false,
},
{
name: "timeout too low",
creds: map[string]string{"timeout_minutes": "0"},
wantErr: true,
errMsg: "timeout_minutes must be between",
},
{
name: "timeout too high",
creds: map[string]string{"timeout_minutes": "100"},
wantErr: true,
errMsg: "timeout_minutes must be between",
},
{
name: "timeout not a number",
creds: map[string]string{"timeout_minutes": "abc"},
wantErr: true,
errMsg: "timeout_minutes must be a number",
},
{
name: "polling interval too low",
creds: map[string]string{"polling_interval_seconds": "2"},
wantErr: true,
errMsg: "polling_interval_seconds must be between",
},
{
name: "polling interval too high",
creds: map[string]string{"polling_interval_seconds": "200"},
wantErr: true,
errMsg: "polling_interval_seconds must be between",
},
{
name: "polling interval not a number",
creds: map[string]string{"polling_interval_seconds": "fast"},
wantErr: true,
errMsg: "polling_interval_seconds must be a number",
},
{
name: "min timeout valid",
creds: map[string]string{"timeout_minutes": "1"},
wantErr: false,
},
{
name: "max timeout valid",
creds: map[string]string{"timeout_minutes": "60"},
wantErr: false,
},
{
name: "min polling interval valid",
creds: map[string]string{"polling_interval_seconds": "5"},
wantErr: false,
},
{
name: "max polling interval valid",
creds: map[string]string{"polling_interval_seconds": "120"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.ValidateCredentials(tt.creds)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestManualProvider_TestCredentials(t *testing.T) {
provider := NewManualProvider()
// TestCredentials should succeed for valid credentials
err := provider.TestCredentials(map[string]string{})
assert.NoError(t, err)
// TestCredentials should fail for invalid credentials
err = provider.TestCredentials(map[string]string{"timeout_minutes": "abc"})
assert.Error(t, err)
}
func TestManualProvider_SupportsMultiCredential(t *testing.T) {
provider := NewManualProvider()
assert.False(t, provider.SupportsMultiCredential())
}
func TestManualProvider_BuildCaddyConfig(t *testing.T) {
provider := NewManualProvider()
config := provider.BuildCaddyConfig(map[string]string{})
assert.Equal(t, "manual", config["name"])
assert.Equal(t, true, config["manual"])
}
func TestManualProvider_BuildCaddyConfigForZone(t *testing.T) {
provider := NewManualProvider()
config := provider.BuildCaddyConfigForZone("example.com", map[string]string{})
// Should return same as BuildCaddyConfig
assert.Equal(t, "manual", config["name"])
assert.Equal(t, true, config["manual"])
}
func TestManualProvider_PropagationTimeout(t *testing.T) {
provider := NewManualProvider()
timeout := provider.PropagationTimeout()
expected := time.Duration(DefaultTimeoutMinutes) * time.Minute
assert.Equal(t, expected, timeout)
}
func TestManualProvider_PollingInterval(t *testing.T) {
provider := NewManualProvider()
interval := provider.PollingInterval()
expected := time.Duration(DefaultPollingIntervalSeconds) * time.Second
assert.Equal(t, expected, interval)
}
func TestManualProvider_GetTimeoutMinutes(t *testing.T) {
provider := NewManualProvider()
tests := []struct {
name string
creds map[string]string
expected int
}{
{
name: "empty creds returns default",
creds: map[string]string{},
expected: DefaultTimeoutMinutes,
},
{
name: "valid timeout returns value",
creds: map[string]string{"timeout_minutes": "30"},
expected: 30,
},
{
name: "invalid number returns default",
creds: map[string]string{"timeout_minutes": "abc"},
expected: DefaultTimeoutMinutes,
},
{
name: "out of range low returns default",
creds: map[string]string{"timeout_minutes": "0"},
expected: DefaultTimeoutMinutes,
},
{
name: "out of range high returns default",
creds: map[string]string{"timeout_minutes": "100"},
expected: DefaultTimeoutMinutes,
},
{
name: "min value returns value",
creds: map[string]string{"timeout_minutes": "1"},
expected: 1,
},
{
name: "max value returns value",
creds: map[string]string{"timeout_minutes": "60"},
expected: 60,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.GetTimeoutMinutes(tt.creds)
assert.Equal(t, tt.expected, result)
})
}
}
func TestManualProvider_GetPollingIntervalSeconds(t *testing.T) {
provider := NewManualProvider()
tests := []struct {
name string
creds map[string]string
expected int
}{
{
name: "empty creds returns default",
creds: map[string]string{},
expected: DefaultPollingIntervalSeconds,
},
{
name: "valid interval returns value",
creds: map[string]string{"polling_interval_seconds": "60"},
expected: 60,
},
{
name: "invalid number returns default",
creds: map[string]string{"polling_interval_seconds": "abc"},
expected: DefaultPollingIntervalSeconds,
},
{
name: "out of range low returns default",
creds: map[string]string{"polling_interval_seconds": "2"},
expected: DefaultPollingIntervalSeconds,
},
{
name: "out of range high returns default",
creds: map[string]string{"polling_interval_seconds": "200"},
expected: DefaultPollingIntervalSeconds,
},
{
name: "min value returns value",
creds: map[string]string{"polling_interval_seconds": "5"},
expected: 5,
},
{
name: "max value returns value",
creds: map[string]string{"polling_interval_seconds": "120"},
expected: 120,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.GetPollingIntervalSeconds(tt.creds)
assert.Equal(t, tt.expected, result)
})
}
}
func TestManualProvider_Constants(t *testing.T) {
// Verify constant values are sensible
assert.Equal(t, 10, DefaultTimeoutMinutes)
assert.Equal(t, 30, DefaultPollingIntervalSeconds)
assert.Equal(t, 1, MinTimeoutMinutes)
assert.Equal(t, 60, MaxTimeoutMinutes)
assert.Equal(t, 5, MinPollingIntervalSeconds)
assert.Equal(t, 120, MaxPollingIntervalSeconds)
// Ensure min < default < max
assert.Less(t, MinTimeoutMinutes, DefaultTimeoutMinutes)
assert.Less(t, DefaultTimeoutMinutes, MaxTimeoutMinutes)
assert.Less(t, MinPollingIntervalSeconds, DefaultPollingIntervalSeconds)
assert.Less(t, DefaultPollingIntervalSeconds, MaxPollingIntervalSeconds)
}
func TestManualProvider_ImplementsInterface(t *testing.T) {
provider := NewManualProvider()
// Compile-time check that ManualProvider implements ProviderPlugin
var _ dnsprovider.ProviderPlugin = provider
}

View File

@@ -0,0 +1,271 @@
// Package custom provides custom DNS provider plugins for non-built-in integrations.
package custom
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// RFC 2136 provider constants.
const (
RFC2136DefaultPort = "53"
RFC2136DefaultAlgorithm = "hmac-sha256"
RFC2136DefaultPropagationTimeout = 60 * time.Second
RFC2136DefaultPollingInterval = 2 * time.Second
RFC2136MinPort = 1
RFC2136MaxPort = 65535
)
// TSIG algorithm constants.
const (
TSIGAlgorithmHMACSHA256 = "hmac-sha256"
TSIGAlgorithmHMACSHA384 = "hmac-sha384"
TSIGAlgorithmHMACSHA512 = "hmac-sha512"
TSIGAlgorithmHMACSHA1 = "hmac-sha1"
TSIGAlgorithmHMACMD5 = "hmac-md5"
)
// ValidTSIGAlgorithms contains all supported TSIG algorithms.
var ValidTSIGAlgorithms = map[string]bool{
TSIGAlgorithmHMACSHA256: true,
TSIGAlgorithmHMACSHA384: true,
TSIGAlgorithmHMACSHA512: true,
TSIGAlgorithmHMACSHA1: true,
TSIGAlgorithmHMACMD5: true,
}
// RFC2136Provider implements the ProviderPlugin interface for RFC 2136 Dynamic DNS Updates.
// RFC 2136 is supported by BIND, PowerDNS, Knot DNS, and many self-hosted DNS servers.
type RFC2136Provider struct {
propagationTimeout time.Duration
pollingInterval time.Duration
}
// NewRFC2136Provider creates a new RFC2136Provider with default settings.
func NewRFC2136Provider() *RFC2136Provider {
return &RFC2136Provider{
propagationTimeout: RFC2136DefaultPropagationTimeout,
pollingInterval: RFC2136DefaultPollingInterval,
}
}
// Type returns the unique provider type identifier.
func (p *RFC2136Provider) Type() string {
return "rfc2136"
}
// Metadata returns descriptive information about the provider.
func (p *RFC2136Provider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "rfc2136",
Name: "RFC 2136 (Dynamic DNS)",
Description: "Dynamic DNS Updates using RFC 2136 protocol with TSIG authentication. Compatible with BIND, PowerDNS, and Knot DNS.",
DocumentationURL: "https://charon.dev/docs/features/rfc2136-dns",
IsBuiltIn: false,
Version: "1.0.0",
InterfaceVersion: dnsprovider.InterfaceVersion,
}
}
// Init is called after the plugin is registered.
func (p *RFC2136Provider) Init() error {
return nil
}
// Cleanup is called before the plugin is unregistered.
func (p *RFC2136Provider) Cleanup() error {
return nil
}
// RequiredCredentialFields returns credential fields that must be provided.
func (p *RFC2136Provider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "nameserver",
Label: "DNS Server",
Type: "text",
Placeholder: "ns1.example.com",
Hint: "Hostname or IP address of the DNS server accepting dynamic updates",
},
{
Name: "tsig_key_name",
Label: "TSIG Key Name",
Type: "text",
Placeholder: "acme-update-key.example.com",
Hint: "The name of the TSIG key configured on your DNS server",
},
{
Name: "tsig_key_secret",
Label: "TSIG Key Secret",
Type: "password",
Placeholder: "",
Hint: "Base64-encoded TSIG secret (from tsig-keygen or dnssec-keygen)",
},
}
}
// OptionalCredentialFields returns credential fields that may be provided.
func (p *RFC2136Provider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "port",
Label: "Port",
Type: "text",
Placeholder: RFC2136DefaultPort,
Hint: fmt.Sprintf("DNS server port (default: %s)", RFC2136DefaultPort),
},
{
Name: "tsig_algorithm",
Label: "TSIG Algorithm",
Type: "select",
Placeholder: "",
Hint: "HMAC algorithm for TSIG authentication (hmac-sha256 recommended)",
Options: []dnsprovider.SelectOption{
{Value: TSIGAlgorithmHMACSHA256, Label: "HMAC-SHA256 (Recommended)"},
{Value: TSIGAlgorithmHMACSHA384, Label: "HMAC-SHA384"},
{Value: TSIGAlgorithmHMACSHA512, Label: "HMAC-SHA512"},
{Value: TSIGAlgorithmHMACSHA1, Label: "HMAC-SHA1 (Legacy)"},
{Value: TSIGAlgorithmHMACMD5, Label: "HMAC-MD5 (Deprecated)"},
},
},
{
Name: "zone",
Label: "Zone",
Type: "text",
Placeholder: "example.com",
Hint: "DNS zone to update (auto-detected from domain if empty)",
},
}
}
// ValidateCredentials checks if the provided credentials are valid.
func (p *RFC2136Provider) ValidateCredentials(creds map[string]string) error {
// Validate required fields
nameserver := strings.TrimSpace(creds["nameserver"])
if nameserver == "" {
return fmt.Errorf("nameserver is required")
}
tsigKeyName := strings.TrimSpace(creds["tsig_key_name"])
if tsigKeyName == "" {
return fmt.Errorf("tsig_key_name is required")
}
tsigKeySecret := strings.TrimSpace(creds["tsig_key_secret"])
if tsigKeySecret == "" {
return fmt.Errorf("tsig_key_secret is required")
}
// Validate base64 encoding of TSIG secret
if _, err := base64.StdEncoding.DecodeString(tsigKeySecret); err != nil {
return fmt.Errorf("tsig_key_secret must be valid base64: %w", err)
}
// Validate port if provided
if portStr := strings.TrimSpace(creds["port"]); portStr != "" {
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("port must be a number: %w", err)
}
if port < RFC2136MinPort || port > RFC2136MaxPort {
return fmt.Errorf("port must be between %d and %d", RFC2136MinPort, RFC2136MaxPort)
}
}
// Validate algorithm if provided
if algorithm := strings.TrimSpace(creds["tsig_algorithm"]); algorithm != "" {
algorithm = strings.ToLower(algorithm)
if !ValidTSIGAlgorithms[algorithm] {
validAlgorithms := make([]string, 0, len(ValidTSIGAlgorithms))
for alg := range ValidTSIGAlgorithms {
validAlgorithms = append(validAlgorithms, alg)
}
return fmt.Errorf("tsig_algorithm must be one of: %s", strings.Join(validAlgorithms, ", "))
}
}
return nil
}
// TestCredentials attempts to verify credentials work.
// For RFC 2136, we validate the format but cannot test without making actual DNS queries.
func (p *RFC2136Provider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
func (p *RFC2136Provider) SupportsMultiCredential() bool {
return true
}
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
func (p *RFC2136Provider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "rfc2136",
"nameserver": strings.TrimSpace(creds["nameserver"]),
"tsig_key_name": strings.TrimSpace(creds["tsig_key_name"]),
"tsig_key_secret": strings.TrimSpace(creds["tsig_key_secret"]),
}
// Add port with default
port := strings.TrimSpace(creds["port"])
if port == "" {
port = RFC2136DefaultPort
}
config["port"] = port
// Add algorithm with default
algorithm := strings.TrimSpace(creds["tsig_algorithm"])
if algorithm == "" {
algorithm = RFC2136DefaultAlgorithm
}
config["tsig_algorithm"] = strings.ToLower(algorithm)
// Add zone if specified (optional - Caddy can auto-detect)
if zone := strings.TrimSpace(creds["zone"]); zone != "" {
config["zone"] = zone
}
return config
}
// BuildCaddyConfigForZone constructs config for a specific zone.
func (p *RFC2136Provider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
config := p.BuildCaddyConfig(creds)
// If zone is not explicitly set, use the base domain
if _, hasZone := config["zone"]; !hasZone {
config["zone"] = baseDomain
}
return config
}
// PropagationTimeout returns the recommended DNS propagation wait time.
func (p *RFC2136Provider) PropagationTimeout() time.Duration {
return p.propagationTimeout
}
// PollingInterval returns the recommended polling interval for DNS verification.
func (p *RFC2136Provider) PollingInterval() time.Duration {
return p.pollingInterval
}
// GetPort returns the configured port or the default.
func (p *RFC2136Provider) GetPort(creds map[string]string) string {
if port := strings.TrimSpace(creds["port"]); port != "" {
return port
}
return RFC2136DefaultPort
}
// GetAlgorithm returns the configured algorithm or the default.
func (p *RFC2136Provider) GetAlgorithm(creds map[string]string) string {
if algorithm := strings.TrimSpace(creds["tsig_algorithm"]); algorithm != "" {
return strings.ToLower(algorithm)
}
return RFC2136DefaultAlgorithm
}

View File

@@ -0,0 +1,716 @@
package custom
import (
"testing"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
func TestNewRFC2136Provider(t *testing.T) {
provider := NewRFC2136Provider()
if provider == nil {
t.Fatal("NewRFC2136Provider() returned nil")
}
if provider.propagationTimeout != RFC2136DefaultPropagationTimeout {
t.Errorf("propagationTimeout = %v, want %v", provider.propagationTimeout, RFC2136DefaultPropagationTimeout)
}
if provider.pollingInterval != RFC2136DefaultPollingInterval {
t.Errorf("pollingInterval = %v, want %v", provider.pollingInterval, RFC2136DefaultPollingInterval)
}
}
func TestRFC2136Provider_Type(t *testing.T) {
provider := NewRFC2136Provider()
if got := provider.Type(); got != "rfc2136" {
t.Errorf("Type() = %q, want %q", got, "rfc2136")
}
}
func TestRFC2136Provider_Metadata(t *testing.T) {
provider := NewRFC2136Provider()
metadata := provider.Metadata()
if metadata.Type != "rfc2136" {
t.Errorf("Metadata().Type = %q, want %q", metadata.Type, "rfc2136")
}
if metadata.Name != "RFC 2136 (Dynamic DNS)" {
t.Errorf("Metadata().Name = %q, want %q", metadata.Name, "RFC 2136 (Dynamic DNS)")
}
if metadata.IsBuiltIn {
t.Error("Metadata().IsBuiltIn = true, want false")
}
if metadata.Version != "1.0.0" {
t.Errorf("Metadata().Version = %q, want %q", metadata.Version, "1.0.0")
}
if metadata.InterfaceVersion != dnsprovider.InterfaceVersion {
t.Errorf("Metadata().InterfaceVersion = %q, want %q", metadata.InterfaceVersion, dnsprovider.InterfaceVersion)
}
if metadata.DocumentationURL == "" {
t.Error("Metadata().DocumentationURL is empty")
}
if metadata.Description == "" {
t.Error("Metadata().Description is empty")
}
}
func TestRFC2136Provider_InitAndCleanup(t *testing.T) {
provider := NewRFC2136Provider()
if err := provider.Init(); err != nil {
t.Errorf("Init() returned error: %v", err)
}
if err := provider.Cleanup(); err != nil {
t.Errorf("Cleanup() returned error: %v", err)
}
}
func TestRFC2136Provider_RequiredCredentialFields(t *testing.T) {
provider := NewRFC2136Provider()
fields := provider.RequiredCredentialFields()
expectedFields := map[string]bool{
"nameserver": false,
"tsig_key_name": false,
"tsig_key_secret": false,
}
if len(fields) != len(expectedFields) {
t.Errorf("RequiredCredentialFields() returned %d fields, want %d", len(fields), len(expectedFields))
}
for _, field := range fields {
if _, ok := expectedFields[field.Name]; !ok {
t.Errorf("Unexpected required field: %q", field.Name)
}
expectedFields[field.Name] = true
if field.Label == "" {
t.Errorf("Field %q has empty label", field.Name)
}
if field.Type == "" {
t.Errorf("Field %q has empty type", field.Name)
}
}
for name, found := range expectedFields {
if !found {
t.Errorf("Missing required field: %q", name)
}
}
}
func TestRFC2136Provider_OptionalCredentialFields(t *testing.T) {
provider := NewRFC2136Provider()
fields := provider.OptionalCredentialFields()
expectedFields := map[string]bool{
"port": false,
"tsig_algorithm": false,
"zone": false,
}
if len(fields) != len(expectedFields) {
t.Errorf("OptionalCredentialFields() returned %d fields, want %d", len(fields), len(expectedFields))
}
for _, field := range fields {
if _, ok := expectedFields[field.Name]; !ok {
t.Errorf("Unexpected optional field: %q", field.Name)
}
expectedFields[field.Name] = true
if field.Label == "" {
t.Errorf("Field %q has empty label", field.Name)
}
// Verify tsig_algorithm has select options
if field.Name == "tsig_algorithm" {
if field.Type != "select" {
t.Errorf("tsig_algorithm type = %q, want %q", field.Type, "select")
}
if len(field.Options) == 0 {
t.Error("tsig_algorithm has no select options")
}
// Verify all valid algorithms are present
optionValues := make(map[string]bool)
for _, opt := range field.Options {
optionValues[opt.Value] = true
}
for alg := range ValidTSIGAlgorithms {
if !optionValues[alg] {
t.Errorf("Missing algorithm option: %q", alg)
}
}
}
}
for name, found := range expectedFields {
if !found {
t.Errorf("Missing optional field: %q", name)
}
}
}
func TestRFC2136Provider_ValidateCredentials(t *testing.T) {
provider := NewRFC2136Provider()
// Valid base64 secret (example)
// #nosec G101 -- Test fixture with non-functional credential for validation testing
validSecret := "c2VjcmV0a2V5MTIzNDU2Nzg5MA==" // "secretkey1234567890" in base64
tests := []struct {
name string
creds map[string]string
wantErr bool
errMsg string
}{
{
name: "valid credentials with defaults",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key.example.com",
"tsig_key_secret": validSecret,
},
wantErr: false,
},
{
name: "valid credentials with all fields",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key.example.com",
"tsig_key_secret": validSecret,
"port": "5353",
"tsig_algorithm": "hmac-sha512",
"zone": "example.com",
},
wantErr: false,
},
{
name: "valid credentials with uppercase algorithm",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key.example.com",
"tsig_key_secret": validSecret,
"tsig_algorithm": "HMAC-SHA256",
},
wantErr: false,
},
{
name: "valid with IP address nameserver",
creds: map[string]string{
"nameserver": "192.168.1.1",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
wantErr: false,
},
{
name: "valid with whitespace trimming",
creds: map[string]string{
"nameserver": " ns1.example.com ",
"tsig_key_name": " acme-key ",
"tsig_key_secret": " " + validSecret + " ",
},
wantErr: false,
},
{
name: "missing nameserver",
creds: map[string]string{
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
wantErr: true,
errMsg: "nameserver is required",
},
{
name: "empty nameserver",
creds: map[string]string{
"nameserver": "",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
wantErr: true,
errMsg: "nameserver is required",
},
{
name: "whitespace-only nameserver",
creds: map[string]string{
"nameserver": " ",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
wantErr: true,
errMsg: "nameserver is required",
},
{
name: "missing tsig_key_name",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_secret": validSecret,
},
wantErr: true,
errMsg: "tsig_key_name is required",
},
{
name: "empty tsig_key_name",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "",
"tsig_key_secret": validSecret,
},
wantErr: true,
errMsg: "tsig_key_name is required",
},
{
name: "missing tsig_key_secret",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
},
wantErr: true,
errMsg: "tsig_key_secret is required",
},
{
name: "empty tsig_key_secret",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": "",
},
wantErr: true,
errMsg: "tsig_key_secret is required",
},
{
name: "invalid base64 secret",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": "not-valid-base64!!!",
},
wantErr: true,
errMsg: "tsig_key_secret must be valid base64",
},
{
name: "invalid port - not a number",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "abc",
},
wantErr: true,
errMsg: "port must be a number",
},
{
name: "invalid port - too low",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "0",
},
wantErr: true,
errMsg: "port must be between",
},
{
name: "invalid port - too high",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "65536",
},
wantErr: true,
errMsg: "port must be between",
},
{
name: "invalid algorithm",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"tsig_algorithm": "invalid-algorithm",
},
wantErr: true,
errMsg: "tsig_algorithm must be one of",
},
{
name: "all empty credentials",
creds: map[string]string{},
wantErr: true,
errMsg: "nameserver is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.ValidateCredentials(tt.creds)
if tt.wantErr {
if err == nil {
t.Error("ValidateCredentials() expected error but got nil")
return
}
if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateCredentials() error = %q, want to contain %q", err.Error(), tt.errMsg)
}
} else if err != nil {
t.Errorf("ValidateCredentials() unexpected error: %v", err)
}
})
}
}
func TestRFC2136Provider_TestCredentials(t *testing.T) {
provider := NewRFC2136Provider()
// #nosec G101 -- Test fixture with non-functional credential for validation testing
validSecret := "c2VjcmV0a2V5MTIzNDU2Nzg5MA=="
// TestCredentials should behave the same as ValidateCredentials
validCreds := map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
}
if err := provider.TestCredentials(validCreds); err != nil {
t.Errorf("TestCredentials() with valid creds returned error: %v", err)
}
invalidCreds := map[string]string{
"nameserver": "ns1.example.com",
}
if err := provider.TestCredentials(invalidCreds); err == nil {
t.Error("TestCredentials() with invalid creds expected error but got nil")
}
}
func TestRFC2136Provider_SupportsMultiCredential(t *testing.T) {
provider := NewRFC2136Provider()
if !provider.SupportsMultiCredential() {
t.Error("SupportsMultiCredential() = false, want true")
}
}
func TestRFC2136Provider_BuildCaddyConfig(t *testing.T) {
provider := NewRFC2136Provider()
// #nosec G101 -- Test fixture with non-functional credential for validation testing
validSecret := "c2VjcmV0a2V5MTIzNDU2Nzg5MA=="
tests := []struct {
name string
creds map[string]string
expected map[string]any
}{
{
name: "minimal config with defaults",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
expected: map[string]any{
"name": "rfc2136",
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "53",
"tsig_algorithm": "hmac-sha256",
},
},
{
name: "full config with all options",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "5353",
"tsig_algorithm": "hmac-sha512",
"zone": "example.com",
},
expected: map[string]any{
"name": "rfc2136",
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "5353",
"tsig_algorithm": "hmac-sha512",
"zone": "example.com",
},
},
{
name: "algorithm normalization to lowercase",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"tsig_algorithm": "HMAC-SHA384",
},
expected: map[string]any{
"name": "rfc2136",
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "53",
"tsig_algorithm": "hmac-sha384",
},
},
{
name: "whitespace trimming",
creds: map[string]string{
"nameserver": " ns1.example.com ",
"tsig_key_name": " acme-key ",
"tsig_key_secret": " " + validSecret + " ",
"port": " 5353 ",
"zone": " example.com ",
},
expected: map[string]any{
"name": "rfc2136",
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"port": "5353",
"tsig_algorithm": "hmac-sha256",
"zone": "example.com",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := provider.BuildCaddyConfig(tt.creds)
for key, expectedValue := range tt.expected {
actualValue, ok := config[key]
if !ok {
t.Errorf("BuildCaddyConfig() missing key %q", key)
continue
}
if actualValue != expectedValue {
t.Errorf("BuildCaddyConfig()[%q] = %v, want %v", key, actualValue, expectedValue)
}
}
// Check no unexpected keys (except for zone which is optional)
for key := range config {
if _, ok := tt.expected[key]; !ok {
t.Errorf("BuildCaddyConfig() unexpected key %q", key)
}
}
})
}
}
func TestRFC2136Provider_BuildCaddyConfigForZone(t *testing.T) {
provider := NewRFC2136Provider()
// #nosec G101 -- Test fixture for RFC2136 provider testing, not a real credential
validSecret := "c2VjcmV0a2V5MTIzNDU2Nzg5MA=="
tests := []struct {
name string
baseDomain string
creds map[string]string
expectedZone string
}{
{
name: "zone auto-set from baseDomain",
baseDomain: "example.org",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
},
expectedZone: "example.org",
},
{
name: "explicit zone takes precedence",
baseDomain: "example.org",
creds: map[string]string{
"nameserver": "ns1.example.com",
"tsig_key_name": "acme-key",
"tsig_key_secret": validSecret,
"zone": "custom.zone.com",
},
expectedZone: "custom.zone.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := provider.BuildCaddyConfigForZone(tt.baseDomain, tt.creds)
zone, ok := config["zone"]
if !ok {
t.Error("BuildCaddyConfigForZone() missing 'zone' key")
return
}
if zone != tt.expectedZone {
t.Errorf("BuildCaddyConfigForZone() zone = %v, want %v", zone, tt.expectedZone)
}
})
}
}
func TestRFC2136Provider_PropagationTimeout(t *testing.T) {
provider := NewRFC2136Provider()
timeout := provider.PropagationTimeout()
if timeout != 60*time.Second {
t.Errorf("PropagationTimeout() = %v, want %v", timeout, 60*time.Second)
}
}
func TestRFC2136Provider_PollingInterval(t *testing.T) {
provider := NewRFC2136Provider()
interval := provider.PollingInterval()
if interval != 2*time.Second {
t.Errorf("PollingInterval() = %v, want %v", interval, 2*time.Second)
}
}
func TestRFC2136Provider_GetPort(t *testing.T) {
provider := NewRFC2136Provider()
tests := []struct {
name string
creds map[string]string
expected string
}{
{
name: "default port when not set",
creds: map[string]string{},
expected: "53",
},
{
name: "default port when empty",
creds: map[string]string{"port": ""},
expected: "53",
},
{
name: "custom port",
creds: map[string]string{"port": "5353"},
expected: "5353",
},
{
name: "custom port with whitespace",
creds: map[string]string{"port": " 5353 "},
expected: "5353",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
port := provider.GetPort(tt.creds)
if port != tt.expected {
t.Errorf("GetPort() = %q, want %q", port, tt.expected)
}
})
}
}
func TestRFC2136Provider_GetAlgorithm(t *testing.T) {
provider := NewRFC2136Provider()
tests := []struct {
name string
creds map[string]string
expected string
}{
{
name: "default algorithm when not set",
creds: map[string]string{},
expected: "hmac-sha256",
},
{
name: "default algorithm when empty",
creds: map[string]string{"tsig_algorithm": ""},
expected: "hmac-sha256",
},
{
name: "custom algorithm",
creds: map[string]string{"tsig_algorithm": "hmac-sha512"},
expected: "hmac-sha512",
},
{
name: "uppercase algorithm normalized",
creds: map[string]string{"tsig_algorithm": "HMAC-SHA384"},
expected: "hmac-sha384",
},
{
name: "algorithm with whitespace",
creds: map[string]string{"tsig_algorithm": " hmac-sha1 "},
expected: "hmac-sha1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
algorithm := provider.GetAlgorithm(tt.creds)
if algorithm != tt.expected {
t.Errorf("GetAlgorithm() = %q, want %q", algorithm, tt.expected)
}
})
}
}
func TestRFC2136Provider_ValidTSIGAlgorithms(t *testing.T) {
expectedAlgorithms := []string{
"hmac-sha256",
"hmac-sha384",
"hmac-sha512",
"hmac-sha1",
"hmac-md5",
}
for _, alg := range expectedAlgorithms {
if !ValidTSIGAlgorithms[alg] {
t.Errorf("ValidTSIGAlgorithms missing %q", alg)
}
}
if len(ValidTSIGAlgorithms) != len(expectedAlgorithms) {
t.Errorf("ValidTSIGAlgorithms has %d entries, want %d", len(ValidTSIGAlgorithms), len(expectedAlgorithms))
}
}
func TestRFC2136Provider_ImplementsInterface(t *testing.T) {
provider := NewRFC2136Provider()
// Compile-time interface check
var _ dnsprovider.ProviderPlugin = provider
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -0,0 +1,311 @@
// Package custom provides custom DNS provider plugins for non-built-in integrations.
package custom
import (
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Script provider constants.
const (
ScriptDefaultTimeoutSeconds = 60
ScriptDefaultPropagationTimeout = 120 * time.Second
ScriptDefaultPollingInterval = 5 * time.Second
ScriptMinTimeoutSeconds = 5
ScriptMaxTimeoutSeconds = 300
ScriptAllowedDirectory = "/scripts/"
)
// scriptArgPattern validates script arguments to prevent injection attacks.
// Only allows alphanumeric characters, dots, underscores, equals, and hyphens.
var scriptArgPattern = regexp.MustCompile(`^[a-zA-Z0-9._=-]+$`)
// envVarLinePattern validates environment variable format (KEY=VALUE).
var envVarLinePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=.*$`)
// ScriptProvider implements the ProviderPlugin interface for shell script DNS challenges.
// This provider executes local scripts to create/delete DNS TXT records.
//
// SECURITY WARNING: This is a HIGH-RISK feature. Scripts are executed on the server
// with the same privileges as the Charon process. Only administrators should configure
// this provider, and scripts must be carefully reviewed before deployment.
type ScriptProvider struct {
propagationTimeout time.Duration
pollingInterval time.Duration
}
// NewScriptProvider creates a new ScriptProvider with default settings.
func NewScriptProvider() *ScriptProvider {
return &ScriptProvider{
propagationTimeout: ScriptDefaultPropagationTimeout,
pollingInterval: ScriptDefaultPollingInterval,
}
}
// Type returns the unique provider type identifier.
func (p *ScriptProvider) Type() string {
return "script"
}
// Metadata returns descriptive information about the provider.
func (p *ScriptProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "script",
Name: "Script (Shell)",
Description: "⚠️ ADVANCED: Execute shell scripts for DNS challenges. Scripts must be located in /scripts/. HIGH-RISK feature - scripts run with server privileges. Only for administrators with custom DNS infrastructure.",
DocumentationURL: "https://charon.dev/docs/features/script-dns",
IsBuiltIn: false,
Version: "1.0.0",
InterfaceVersion: dnsprovider.InterfaceVersion,
}
}
// Init is called after the plugin is registered.
func (p *ScriptProvider) Init() error {
return nil
}
// Cleanup is called before the plugin is unregistered.
func (p *ScriptProvider) Cleanup() error {
return nil
}
// RequiredCredentialFields returns credential fields that must be provided.
func (p *ScriptProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "script_path",
Label: "Script Path",
Type: "text",
Placeholder: "/scripts/dns-challenge.sh",
Hint: "Path to the DNS challenge script. Must be located in /scripts/ directory. Script receives: action (create/delete), domain, token, and key_auth as arguments.",
},
}
}
// OptionalCredentialFields returns credential fields that may be provided.
func (p *ScriptProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "timeout_seconds",
Label: "Script Timeout (seconds)",
Type: "text",
Placeholder: strconv.Itoa(ScriptDefaultTimeoutSeconds),
Hint: fmt.Sprintf("Maximum execution time for the script (%d-%d seconds, default: %d)", ScriptMinTimeoutSeconds, ScriptMaxTimeoutSeconds, ScriptDefaultTimeoutSeconds),
},
{
Name: "env_vars",
Label: "Environment Variables",
Type: "textarea",
Placeholder: "DNS_API_KEY=your-key\nDNS_API_URL=https://api.example.com",
Hint: "Optional environment variables passed to the script. One KEY=VALUE pair per line. Keys must start with a letter or underscore.",
},
}
}
// ValidateCredentials checks if the provided credentials are valid.
func (p *ScriptProvider) ValidateCredentials(creds map[string]string) error {
// Validate required script path
scriptPath := strings.TrimSpace(creds["script_path"])
if scriptPath == "" {
return fmt.Errorf("script_path is required")
}
// Validate script path for security
if err := validateScriptPath(scriptPath); err != nil {
return fmt.Errorf("script_path validation failed: %w", err)
}
// Validate timeout if provided
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
timeout, err := strconv.Atoi(timeoutStr)
if err != nil {
return fmt.Errorf("timeout_seconds must be a number: %w", err)
}
if timeout < ScriptMinTimeoutSeconds || timeout > ScriptMaxTimeoutSeconds {
return fmt.Errorf("timeout_seconds must be between %d and %d", ScriptMinTimeoutSeconds, ScriptMaxTimeoutSeconds)
}
}
// Validate environment variables if provided
if envVars := strings.TrimSpace(creds["env_vars"]); envVars != "" {
if err := validateEnvVars(envVars); err != nil {
return fmt.Errorf("env_vars validation failed: %w", err)
}
}
return nil
}
// validateScriptPath validates a script path for security.
// SECURITY: This function is critical for preventing path traversal attacks.
func validateScriptPath(scriptPath string) error {
// Clean the path first to normalize it
// SECURITY: filepath.Clean resolves ".." sequences, so "/scripts/../etc/passwd"
// becomes "/etc/passwd" - the directory check below will then reject it.
cleaned := filepath.Clean(scriptPath)
// SECURITY: Must start with the allowed directory
// This check catches path traversal because filepath.Clean already resolved ".."
if !strings.HasPrefix(cleaned, ScriptAllowedDirectory) {
return fmt.Errorf("script must be in %s directory, got: %s", ScriptAllowedDirectory, cleaned)
}
// SECURITY: Validate the path doesn't contain null bytes (common injection vector)
if strings.ContainsRune(scriptPath, '\x00') {
return fmt.Errorf("path contains invalid characters")
}
// SECURITY: Validate the filename portion doesn't start with a hyphen
// (to prevent argument injection in shell commands)
base := filepath.Base(cleaned)
if strings.HasPrefix(base, "-") {
return fmt.Errorf("script filename cannot start with hyphen")
}
// SECURITY: Validate the script name matches safe pattern
if !scriptArgPattern.MatchString(base) {
return fmt.Errorf("script filename contains invalid characters: only alphanumeric, dots, underscores, equals, and hyphens allowed")
}
return nil
}
// validateEnvVars validates environment variable format.
func validateEnvVars(envVars string) error {
lines := strings.Split(envVars, "\n")
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue // Skip empty lines
}
// Validate format: KEY=VALUE
if !strings.Contains(line, "=") {
return fmt.Errorf("line %d: invalid format, expected KEY=VALUE", i+1)
}
// Validate the line matches the pattern
if !envVarLinePattern.MatchString(line) {
return fmt.Errorf("line %d: invalid environment variable format, key must start with letter or underscore", i+1)
}
// Extract and validate key
parts := strings.SplitN(line, "=", 2)
key := parts[0]
// SECURITY: Prevent overriding critical environment variables
criticalVars := []string{"PATH", "LD_PRELOAD", "LD_LIBRARY_PATH", "HOME", "USER", "SHELL"}
for _, critical := range criticalVars {
if strings.EqualFold(key, critical) {
return fmt.Errorf("line %d: cannot override critical environment variable %q", i+1, key)
}
}
}
return nil
}
// parseEnvVars parses environment variable string into a map.
func parseEnvVars(envVars string) map[string]string {
result := make(map[string]string)
if envVars == "" {
return result
}
lines := strings.Split(envVars, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
}
}
return result
}
// TestCredentials attempts to verify credentials work.
// For script provider, we validate the format but cannot test without executing the script.
func (p *ScriptProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
func (p *ScriptProvider) SupportsMultiCredential() bool {
return false
}
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
// For script, this returns a config that Charon's internal script handler will use.
func (p *ScriptProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
scriptPath := strings.TrimSpace(creds["script_path"])
config := map[string]any{
"name": "script",
"script_path": filepath.Clean(scriptPath),
}
// Add timeout with default
timeoutSeconds := ScriptDefaultTimeoutSeconds
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
if t, err := strconv.Atoi(timeoutStr); err == nil && t >= ScriptMinTimeoutSeconds && t <= ScriptMaxTimeoutSeconds {
timeoutSeconds = t
}
}
config["timeout_seconds"] = timeoutSeconds
// Add environment variables if provided
if envVars := strings.TrimSpace(creds["env_vars"]); envVars != "" {
config["env_vars"] = parseEnvVars(envVars)
} else {
config["env_vars"] = map[string]string{}
}
return config
}
// BuildCaddyConfigForZone constructs config for a specific zone.
func (p *ScriptProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
// PropagationTimeout returns the recommended DNS propagation wait time.
func (p *ScriptProvider) PropagationTimeout() time.Duration {
return p.propagationTimeout
}
// PollingInterval returns the recommended polling interval for DNS verification.
func (p *ScriptProvider) PollingInterval() time.Duration {
return p.pollingInterval
}
// GetTimeoutSeconds returns the configured timeout in seconds or the default.
func (p *ScriptProvider) GetTimeoutSeconds(creds map[string]string) int {
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
if timeout >= ScriptMinTimeoutSeconds && timeout <= ScriptMaxTimeoutSeconds {
return timeout
}
}
}
return ScriptDefaultTimeoutSeconds
}
// GetEnvVars returns the parsed environment variables from credentials.
func (p *ScriptProvider) GetEnvVars(creds map[string]string) map[string]string {
envVars := strings.TrimSpace(creds["env_vars"])
return parseEnvVars(envVars)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
// Package custom provides custom DNS provider plugins for non-built-in integrations.
package custom
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Webhook provider constants.
const (
WebhookDefaultTimeoutSeconds = 30
WebhookDefaultRetryCount = 3
WebhookDefaultPropagationTimeout = 120 * time.Second
WebhookDefaultPollingInterval = 5 * time.Second
WebhookMinTimeoutSeconds = 5
WebhookMaxTimeoutSeconds = 300
WebhookMinRetryCount = 0
WebhookMaxRetryCount = 10
)
// WebhookProvider implements the ProviderPlugin interface for generic HTTP webhook DNS challenges.
// This provider calls external HTTP endpoints to create/delete DNS TXT records,
// enabling integration with custom or proprietary DNS systems.
type WebhookProvider struct {
propagationTimeout time.Duration
pollingInterval time.Duration
}
// NewWebhookProvider creates a new WebhookProvider with default settings.
func NewWebhookProvider() *WebhookProvider {
return &WebhookProvider{
propagationTimeout: WebhookDefaultPropagationTimeout,
pollingInterval: WebhookDefaultPollingInterval,
}
}
// Type returns the unique provider type identifier.
func (p *WebhookProvider) Type() string {
return "webhook"
}
// Metadata returns descriptive information about the provider.
func (p *WebhookProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "webhook",
Name: "Webhook (HTTP)",
Description: "Generic HTTP webhook for DNS challenges. Calls external endpoints to create and delete TXT records. Useful for custom DNS APIs or proprietary systems.",
DocumentationURL: "https://charon.dev/docs/features/webhook-dns",
IsBuiltIn: false,
Version: "1.0.0",
InterfaceVersion: dnsprovider.InterfaceVersion,
}
}
// Init is called after the plugin is registered.
func (p *WebhookProvider) Init() error {
return nil
}
// Cleanup is called before the plugin is unregistered.
func (p *WebhookProvider) Cleanup() error {
return nil
}
// RequiredCredentialFields returns credential fields that must be provided.
func (p *WebhookProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "create_url",
Label: "Create URL",
Type: "text",
Placeholder: "https://dns-api.example.com/txt/create",
Hint: "POST endpoint for creating DNS TXT records. Must be HTTPS (HTTP allowed for localhost in development).",
},
{
Name: "delete_url",
Label: "Delete URL",
Type: "text",
Placeholder: "https://dns-api.example.com/txt/delete",
Hint: "POST/DELETE endpoint for removing DNS TXT records. Must be HTTPS (HTTP allowed for localhost in development).",
},
}
}
// OptionalCredentialFields returns credential fields that may be provided.
func (p *WebhookProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "auth_header",
Label: "Authorization Header Name",
Type: "text",
Placeholder: "Authorization",
Hint: "Custom header name for authentication (e.g., Authorization, X-API-Key)",
},
{
Name: "auth_value",
Label: "Authorization Header Value",
Type: "password",
Placeholder: "",
Hint: "Value for the authorization header (e.g., Bearer token, API key)",
},
{
Name: "timeout_seconds",
Label: "Request Timeout (seconds)",
Type: "text",
Placeholder: strconv.Itoa(WebhookDefaultTimeoutSeconds),
Hint: fmt.Sprintf("HTTP request timeout (%d-%d seconds, default: %d)", WebhookMinTimeoutSeconds, WebhookMaxTimeoutSeconds, WebhookDefaultTimeoutSeconds),
},
{
Name: "retry_count",
Label: "Retry Count",
Type: "text",
Placeholder: strconv.Itoa(WebhookDefaultRetryCount),
Hint: fmt.Sprintf("Number of retries on failure (%d-%d, default: %d)", WebhookMinRetryCount, WebhookMaxRetryCount, WebhookDefaultRetryCount),
},
{
Name: "insecure_skip_verify",
Label: "Skip TLS Verification",
Type: "select",
Placeholder: "",
Hint: "⚠️ DEVELOPMENT ONLY: Skip TLS certificate verification. Never enable in production!",
Options: []dnsprovider.SelectOption{
{Value: "false", Label: "No (Recommended)"},
{Value: "true", Label: "Yes (Insecure - Dev Only)"},
},
},
}
}
// ValidateCredentials checks if the provided credentials are valid.
func (p *WebhookProvider) ValidateCredentials(creds map[string]string) error {
// Validate required fields
createURL := strings.TrimSpace(creds["create_url"])
if createURL == "" {
return fmt.Errorf("create_url is required")
}
deleteURL := strings.TrimSpace(creds["delete_url"])
if deleteURL == "" {
return fmt.Errorf("delete_url is required")
}
// Validate create URL format and security
if err := p.validateWebhookURL(createURL, "create_url"); err != nil {
return err
}
// Validate delete URL format and security
if err := p.validateWebhookURL(deleteURL, "delete_url"); err != nil {
return err
}
// Validate timeout if provided
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
timeout, err := strconv.Atoi(timeoutStr)
if err != nil {
return fmt.Errorf("timeout_seconds must be a number: %w", err)
}
if timeout < WebhookMinTimeoutSeconds || timeout > WebhookMaxTimeoutSeconds {
return fmt.Errorf("timeout_seconds must be between %d and %d", WebhookMinTimeoutSeconds, WebhookMaxTimeoutSeconds)
}
}
// Validate retry count if provided
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
retry, err := strconv.Atoi(retryStr)
if err != nil {
return fmt.Errorf("retry_count must be a number: %w", err)
}
if retry < WebhookMinRetryCount || retry > WebhookMaxRetryCount {
return fmt.Errorf("retry_count must be between %d and %d", WebhookMinRetryCount, WebhookMaxRetryCount)
}
}
// Validate insecure_skip_verify if provided
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
insecureStr = strings.ToLower(insecureStr)
if insecureStr != "true" && insecureStr != "false" {
return fmt.Errorf("insecure_skip_verify must be 'true' or 'false'")
}
}
// Validate auth header/value consistency
authHeader := strings.TrimSpace(creds["auth_header"])
authValue := strings.TrimSpace(creds["auth_value"])
if (authHeader != "" && authValue == "") || (authHeader == "" && authValue != "") {
return fmt.Errorf("both auth_header and auth_value must be provided together, or neither")
}
return nil
}
// validateWebhookURL validates a webhook URL for format and SSRF protection.
// Note: During validation, we only check format and basic security constraints.
// Full SSRF validation with DNS resolution happens at runtime when the webhook is called.
func (p *WebhookProvider) validateWebhookURL(rawURL, fieldName string) error {
// Parse URL first for basic validation
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("%s has invalid URL format: %w", fieldName, err)
}
// Validate scheme
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("%s must use http or https scheme", fieldName)
}
// Validate hostname exists
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("%s is missing hostname", fieldName)
}
// Check if this is a localhost URL (allowed for development)
isLocalhost := host == "localhost" || host == "127.0.0.1" || host == "::1"
// Require HTTPS for non-localhost URLs
if !isLocalhost && parsed.Scheme != "https" {
return fmt.Errorf("%s must use HTTPS for non-localhost URLs (security requirement)", fieldName)
}
// For external URLs (non-localhost), we skip DNS-based SSRF validation during
// credential validation as the target might not be reachable from the validation
// environment. Runtime SSRF protection will be enforced when actually calling the webhook.
// This matches the pattern used by RFC2136Provider which also validates format only.
return nil
}
// TestCredentials attempts to verify credentials work.
// For webhook, we validate the format but cannot test without making actual HTTP calls.
func (p *WebhookProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
func (p *WebhookProvider) SupportsMultiCredential() bool {
return false
}
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
// For webhook, this returns a config that Charon's internal webhook handler will use.
func (p *WebhookProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "webhook",
"create_url": strings.TrimSpace(creds["create_url"]),
"delete_url": strings.TrimSpace(creds["delete_url"]),
}
// Add auth header if provided
if authHeader := strings.TrimSpace(creds["auth_header"]); authHeader != "" {
config["auth_header"] = authHeader
}
// Add auth value if provided
if authValue := strings.TrimSpace(creds["auth_value"]); authValue != "" {
config["auth_value"] = authValue
}
// Add timeout with default
timeoutSeconds := WebhookDefaultTimeoutSeconds
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
if t, err := strconv.Atoi(timeoutStr); err == nil && t >= WebhookMinTimeoutSeconds && t <= WebhookMaxTimeoutSeconds {
timeoutSeconds = t
}
}
config["timeout_seconds"] = timeoutSeconds
// Add retry count with default
retryCount := WebhookDefaultRetryCount
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
if r, err := strconv.Atoi(retryStr); err == nil && r >= WebhookMinRetryCount && r <= WebhookMaxRetryCount {
retryCount = r
}
}
config["retry_count"] = retryCount
// Add insecure skip verify with default (false)
insecureSkipVerify := false
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
insecureSkipVerify = strings.ToLower(insecureStr) == "true"
}
config["insecure_skip_verify"] = insecureSkipVerify
return config
}
// BuildCaddyConfigForZone constructs config for a specific zone.
func (p *WebhookProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
// PropagationTimeout returns the recommended DNS propagation wait time.
func (p *WebhookProvider) PropagationTimeout() time.Duration {
return p.propagationTimeout
}
// PollingInterval returns the recommended polling interval for DNS verification.
func (p *WebhookProvider) PollingInterval() time.Duration {
return p.pollingInterval
}
// GetTimeoutSeconds returns the configured timeout in seconds or the default.
func (p *WebhookProvider) GetTimeoutSeconds(creds map[string]string) int {
if timeoutStr := strings.TrimSpace(creds["timeout_seconds"]); timeoutStr != "" {
if timeout, err := strconv.Atoi(timeoutStr); err == nil {
if timeout >= WebhookMinTimeoutSeconds && timeout <= WebhookMaxTimeoutSeconds {
return timeout
}
}
}
return WebhookDefaultTimeoutSeconds
}
// GetRetryCount returns the configured retry count or the default.
func (p *WebhookProvider) GetRetryCount(creds map[string]string) int {
if retryStr := strings.TrimSpace(creds["retry_count"]); retryStr != "" {
if retry, err := strconv.Atoi(retryStr); err == nil {
if retry >= WebhookMinRetryCount && retry <= WebhookMaxRetryCount {
return retry
}
}
}
return WebhookDefaultRetryCount
}
// IsInsecureSkipVerify returns whether TLS verification should be skipped.
func (p *WebhookProvider) IsInsecureSkipVerify(creds map[string]string) bool {
if insecureStr := strings.TrimSpace(creds["insecure_skip_verify"]); insecureStr != "" {
return strings.ToLower(insecureStr) == "true"
}
return false
}

View File

@@ -0,0 +1,856 @@
package custom
import (
"testing"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewWebhookProvider(t *testing.T) {
provider := NewWebhookProvider()
require.NotNil(t, provider)
assert.Equal(t, WebhookDefaultPropagationTimeout, provider.propagationTimeout)
assert.Equal(t, WebhookDefaultPollingInterval, provider.pollingInterval)
}
func TestWebhookProvider_Type(t *testing.T) {
provider := NewWebhookProvider()
assert.Equal(t, "webhook", provider.Type())
}
func TestWebhookProvider_Metadata(t *testing.T) {
provider := NewWebhookProvider()
metadata := provider.Metadata()
assert.Equal(t, "webhook", metadata.Type)
assert.Equal(t, "Webhook (HTTP)", metadata.Name)
assert.Contains(t, metadata.Description, "HTTP webhook")
assert.NotEmpty(t, metadata.DocumentationURL)
assert.False(t, metadata.IsBuiltIn)
assert.Equal(t, "1.0.0", metadata.Version)
assert.Equal(t, dnsprovider.InterfaceVersion, metadata.InterfaceVersion)
}
func TestWebhookProvider_Init(t *testing.T) {
provider := NewWebhookProvider()
err := provider.Init()
assert.NoError(t, err)
}
func TestWebhookProvider_Cleanup(t *testing.T) {
provider := NewWebhookProvider()
err := provider.Cleanup()
assert.NoError(t, err)
}
func TestWebhookProvider_RequiredCredentialFields(t *testing.T) {
provider := NewWebhookProvider()
fields := provider.RequiredCredentialFields()
expectedFields := map[string]bool{
"create_url": false,
"delete_url": false,
}
assert.Len(t, fields, len(expectedFields))
for _, field := range fields {
if _, ok := expectedFields[field.Name]; !ok {
t.Errorf("Unexpected required field: %q", field.Name)
}
expectedFields[field.Name] = true
assert.NotEmpty(t, field.Label, "Field %q has empty label", field.Name)
assert.NotEmpty(t, field.Type, "Field %q has empty type", field.Name)
assert.NotEmpty(t, field.Hint, "Field %q has empty hint", field.Name)
}
for name, found := range expectedFields {
if !found {
t.Errorf("Missing required field: %q", name)
}
}
}
func TestWebhookProvider_OptionalCredentialFields(t *testing.T) {
provider := NewWebhookProvider()
fields := provider.OptionalCredentialFields()
expectedFields := map[string]bool{
"auth_header": false,
"auth_value": false,
"timeout_seconds": false,
"retry_count": false,
"insecure_skip_verify": false,
}
assert.Len(t, fields, len(expectedFields))
for _, field := range fields {
if _, ok := expectedFields[field.Name]; !ok {
t.Errorf("Unexpected optional field: %q", field.Name)
}
expectedFields[field.Name] = true
assert.NotEmpty(t, field.Label, "Field %q has empty label", field.Name)
// Verify auth_value is password type
if field.Name == "auth_value" {
assert.Equal(t, "password", field.Type, "auth_value should be password type")
}
// Verify insecure_skip_verify has select options
if field.Name == "insecure_skip_verify" {
assert.Equal(t, "select", field.Type, "insecure_skip_verify should be select type")
assert.Len(t, field.Options, 2, "insecure_skip_verify should have 2 options")
assert.Contains(t, field.Hint, "DEVELOPMENT ONLY", "insecure_skip_verify should warn about dev-only usage")
}
}
for name, found := range expectedFields {
if !found {
t.Errorf("Missing optional field: %q", name)
}
}
}
func TestWebhookProvider_ValidateCredentials(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
creds map[string]string
wantErr bool
errMsg string
}{
{
name: "valid credentials with HTTPS URLs",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: false,
},
{
name: "valid credentials with localhost HTTP",
creds: map[string]string{
"create_url": "http://localhost:8080/create",
"delete_url": "http://localhost:8080/delete",
},
wantErr: false,
},
{
name: "valid credentials with 127.0.0.1 HTTP",
creds: map[string]string{
"create_url": "http://127.0.0.1:8080/create",
"delete_url": "http://127.0.0.1:8080/delete",
},
wantErr: false,
},
{
name: "valid credentials with all optional fields",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_header": "Authorization",
"auth_value": "Bearer token123",
"timeout_seconds": "60",
"retry_count": "5",
"insecure_skip_verify": "false",
},
wantErr: false,
},
{
name: "valid credentials with whitespace trimming",
creds: map[string]string{
"create_url": " https://dns-api.example.com/create ",
"delete_url": " https://dns-api.example.com/delete ",
},
wantErr: false,
},
{
name: "missing create_url",
creds: map[string]string{
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "create_url is required",
},
{
name: "empty create_url",
creds: map[string]string{
"create_url": "",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "create_url is required",
},
{
name: "whitespace-only create_url",
creds: map[string]string{
"create_url": " ",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "create_url is required",
},
{
name: "missing delete_url",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
},
wantErr: true,
errMsg: "delete_url is required",
},
{
name: "empty delete_url",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "",
},
wantErr: true,
errMsg: "delete_url is required",
},
{
name: "invalid create_url format",
creds: map[string]string{
"create_url": "not-a-valid-url",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "create_url",
},
{
name: "create_url with ftp scheme",
creds: map[string]string{
"create_url": "ftp://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "must use http or https scheme",
},
{
name: "HTTP scheme for non-localhost",
creds: map[string]string{
"create_url": "http://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
},
wantErr: true,
errMsg: "must use HTTPS for non-localhost",
},
{
name: "timeout_seconds not a number",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "abc",
},
wantErr: true,
errMsg: "timeout_seconds must be a number",
},
{
name: "timeout_seconds too low",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "1",
},
wantErr: true,
errMsg: "timeout_seconds must be between",
},
{
name: "timeout_seconds too high",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "500",
},
wantErr: true,
errMsg: "timeout_seconds must be between",
},
{
name: "retry_count not a number",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "abc",
},
wantErr: true,
errMsg: "retry_count must be a number",
},
{
name: "retry_count negative",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "-1",
},
wantErr: true,
errMsg: "retry_count must be between",
},
{
name: "retry_count too high",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "20",
},
wantErr: true,
errMsg: "retry_count must be between",
},
{
name: "insecure_skip_verify invalid value",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"insecure_skip_verify": "maybe",
},
wantErr: true,
errMsg: "insecure_skip_verify must be 'true' or 'false'",
},
{
name: "auth_header without auth_value",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_header": "Authorization",
},
wantErr: true,
errMsg: "both auth_header and auth_value must be provided together",
},
{
name: "auth_value without auth_header",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_value": "Bearer token",
},
wantErr: true,
errMsg: "both auth_header and auth_value must be provided together",
},
{
name: "valid min timeout",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "5",
},
wantErr: false,
},
{
name: "valid max timeout",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "300",
},
wantErr: false,
},
{
name: "valid min retry count",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "0",
},
wantErr: false,
},
{
name: "valid max retry count",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "10",
},
wantErr: false,
},
{
name: "insecure_skip_verify true",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"insecure_skip_verify": "true",
},
wantErr: false,
},
{
name: "insecure_skip_verify TRUE (case insensitive)",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"insecure_skip_verify": "TRUE",
},
wantErr: false,
},
{
name: "all empty credentials",
creds: map[string]string{},
wantErr: true,
errMsg: "create_url is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.ValidateCredentials(tt.creds)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestWebhookProvider_ValidateWebhookURL(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
url string
fieldName string
wantErr bool
errMsg string
}{
{
name: "valid HTTPS URL",
url: "https://api.example.com/webhook",
fieldName: "test_url",
wantErr: false,
},
{
name: "valid localhost HTTP",
url: "http://localhost:8080/webhook",
fieldName: "test_url",
wantErr: false,
},
{
name: "valid 127.0.0.1 HTTP",
url: "http://127.0.0.1:8080/webhook",
fieldName: "test_url",
wantErr: false,
},
{
name: "invalid scheme ftp",
url: "ftp://example.com/webhook",
fieldName: "test_url",
wantErr: true,
errMsg: "must use http or https scheme",
},
{
name: "HTTP for non-localhost rejected",
url: "http://api.example.com/webhook",
fieldName: "test_url",
wantErr: true,
errMsg: "must use HTTPS for non-localhost",
},
{
name: "missing hostname",
url: "https:///path",
fieldName: "test_url",
wantErr: true,
errMsg: "is missing hostname",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.validateWebhookURL(tt.url, tt.fieldName)
if tt.wantErr {
assert.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestWebhookProvider_TestCredentials(t *testing.T) {
provider := NewWebhookProvider()
// TestCredentials should behave the same as ValidateCredentials
validCreds := map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
}
err := provider.TestCredentials(validCreds)
assert.NoError(t, err)
invalidCreds := map[string]string{
"create_url": "https://dns-api.example.com/create",
}
err = provider.TestCredentials(invalidCreds)
assert.Error(t, err)
}
func TestWebhookProvider_SupportsMultiCredential(t *testing.T) {
provider := NewWebhookProvider()
assert.False(t, provider.SupportsMultiCredential())
}
func TestWebhookProvider_BuildCaddyConfig(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
creds map[string]string
expected map[string]any
}{
{
name: "minimal config with defaults",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": WebhookDefaultTimeoutSeconds,
"retry_count": WebhookDefaultRetryCount,
"insecure_skip_verify": false,
},
},
{
name: "full config with all options",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_header": "X-API-Key",
"auth_value": "secret123",
"timeout_seconds": "60",
"retry_count": "5",
"insecure_skip_verify": "true",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_header": "X-API-Key",
"auth_value": "secret123",
"timeout_seconds": 60,
"retry_count": 5,
"insecure_skip_verify": true,
},
},
{
name: "whitespace trimming",
creds: map[string]string{
"create_url": " https://dns-api.example.com/create ",
"delete_url": " https://dns-api.example.com/delete ",
"auth_header": " Authorization ",
"auth_value": " Bearer token ",
"timeout_seconds": " 45 ",
"retry_count": " 2 ",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"auth_header": "Authorization",
"auth_value": "Bearer token",
"timeout_seconds": 45,
"retry_count": 2,
"insecure_skip_verify": false,
},
},
{
name: "invalid timeout falls back to default",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "invalid",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": WebhookDefaultTimeoutSeconds,
"retry_count": WebhookDefaultRetryCount,
"insecure_skip_verify": false,
},
},
{
name: "out-of-range timeout falls back to default",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": "1000",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": WebhookDefaultTimeoutSeconds,
"retry_count": WebhookDefaultRetryCount,
"insecure_skip_verify": false,
},
},
{
name: "out-of-range retry falls back to default",
creds: map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"retry_count": "100",
},
expected: map[string]any{
"name": "webhook",
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
"timeout_seconds": WebhookDefaultTimeoutSeconds,
"retry_count": WebhookDefaultRetryCount,
"insecure_skip_verify": false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
config := provider.BuildCaddyConfig(tt.creds)
for key, expectedValue := range tt.expected {
actualValue, ok := config[key]
if !ok {
t.Errorf("BuildCaddyConfig() missing key %q", key)
continue
}
assert.Equal(t, expectedValue, actualValue, "BuildCaddyConfig()[%q] mismatch", key)
}
// Check no unexpected keys
for key := range config {
if _, ok := tt.expected[key]; !ok {
t.Errorf("BuildCaddyConfig() unexpected key %q", key)
}
}
})
}
}
func TestWebhookProvider_BuildCaddyConfigForZone(t *testing.T) {
provider := NewWebhookProvider()
creds := map[string]string{
"create_url": "https://dns-api.example.com/create",
"delete_url": "https://dns-api.example.com/delete",
}
config := provider.BuildCaddyConfigForZone("example.org", creds)
// Should return same as BuildCaddyConfig since multi-credential is not supported
assert.Equal(t, "webhook", config["name"])
assert.Equal(t, "https://dns-api.example.com/create", config["create_url"])
assert.Equal(t, "https://dns-api.example.com/delete", config["delete_url"])
}
func TestWebhookProvider_PropagationTimeout(t *testing.T) {
provider := NewWebhookProvider()
timeout := provider.PropagationTimeout()
assert.Equal(t, 120*time.Second, timeout)
}
func TestWebhookProvider_PollingInterval(t *testing.T) {
provider := NewWebhookProvider()
interval := provider.PollingInterval()
assert.Equal(t, 5*time.Second, interval)
}
func TestWebhookProvider_GetTimeoutSeconds(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
creds map[string]string
expected int
}{
{
name: "empty creds returns default",
creds: map[string]string{},
expected: WebhookDefaultTimeoutSeconds,
},
{
name: "valid timeout returns value",
creds: map[string]string{"timeout_seconds": "60"},
expected: 60,
},
{
name: "invalid number returns default",
creds: map[string]string{"timeout_seconds": "abc"},
expected: WebhookDefaultTimeoutSeconds,
},
{
name: "out of range low returns default",
creds: map[string]string{"timeout_seconds": "1"},
expected: WebhookDefaultTimeoutSeconds,
},
{
name: "out of range high returns default",
creds: map[string]string{"timeout_seconds": "500"},
expected: WebhookDefaultTimeoutSeconds,
},
{
name: "min value returns value",
creds: map[string]string{"timeout_seconds": "5"},
expected: 5,
},
{
name: "max value returns value",
creds: map[string]string{"timeout_seconds": "300"},
expected: 300,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.GetTimeoutSeconds(tt.creds)
assert.Equal(t, tt.expected, result)
})
}
}
func TestWebhookProvider_GetRetryCount(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
creds map[string]string
expected int
}{
{
name: "empty creds returns default",
creds: map[string]string{},
expected: WebhookDefaultRetryCount,
},
{
name: "valid retry count returns value",
creds: map[string]string{"retry_count": "5"},
expected: 5,
},
{
name: "invalid number returns default",
creds: map[string]string{"retry_count": "abc"},
expected: WebhookDefaultRetryCount,
},
{
name: "negative returns default",
creds: map[string]string{"retry_count": "-1"},
expected: WebhookDefaultRetryCount,
},
{
name: "out of range high returns default",
creds: map[string]string{"retry_count": "100"},
expected: WebhookDefaultRetryCount,
},
{
name: "min value returns value",
creds: map[string]string{"retry_count": "0"},
expected: 0,
},
{
name: "max value returns value",
creds: map[string]string{"retry_count": "10"},
expected: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.GetRetryCount(tt.creds)
assert.Equal(t, tt.expected, result)
})
}
}
func TestWebhookProvider_IsInsecureSkipVerify(t *testing.T) {
provider := NewWebhookProvider()
tests := []struct {
name string
creds map[string]string
expected bool
}{
{
name: "empty creds returns false",
creds: map[string]string{},
expected: false,
},
{
name: "false returns false",
creds: map[string]string{"insecure_skip_verify": "false"},
expected: false,
},
{
name: "true returns true",
creds: map[string]string{"insecure_skip_verify": "true"},
expected: true,
},
{
name: "TRUE returns true",
creds: map[string]string{"insecure_skip_verify": "TRUE"},
expected: true,
},
{
name: "False returns false",
creds: map[string]string{"insecure_skip_verify": "False"},
expected: false,
},
{
name: "invalid value returns false",
creds: map[string]string{"insecure_skip_verify": "invalid"},
expected: false,
},
{
name: "with whitespace",
creds: map[string]string{"insecure_skip_verify": " true "},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := provider.IsInsecureSkipVerify(tt.creds)
assert.Equal(t, tt.expected, result)
})
}
}
func TestWebhookProvider_Constants(t *testing.T) {
// Verify constant values are sensible
assert.Equal(t, 30, WebhookDefaultTimeoutSeconds)
assert.Equal(t, 3, WebhookDefaultRetryCount)
assert.Equal(t, 120*time.Second, WebhookDefaultPropagationTimeout)
assert.Equal(t, 5*time.Second, WebhookDefaultPollingInterval)
assert.Equal(t, 5, WebhookMinTimeoutSeconds)
assert.Equal(t, 300, WebhookMaxTimeoutSeconds)
assert.Equal(t, 0, WebhookMinRetryCount)
assert.Equal(t, 10, WebhookMaxRetryCount)
// Ensure min < default < max for timeout
assert.Less(t, WebhookMinTimeoutSeconds, WebhookDefaultTimeoutSeconds)
assert.Less(t, WebhookDefaultTimeoutSeconds, WebhookMaxTimeoutSeconds)
// Ensure min <= default <= max for retry
assert.LessOrEqual(t, WebhookMinRetryCount, WebhookDefaultRetryCount)
assert.LessOrEqual(t, WebhookDefaultRetryCount, WebhookMaxRetryCount)
}
func TestWebhookProvider_ImplementsInterface(t *testing.T) {
provider := NewWebhookProvider()
// Compile-time check that WebhookProvider implements ProviderPlugin
var _ dnsprovider.ProviderPlugin = provider
}