chore: git cache cleanup
This commit is contained in:
31
backend/pkg/dnsprovider/custom/init.go
Normal file
31
backend/pkg/dnsprovider/custom/init.go
Normal 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())
|
||||
}
|
||||
}
|
||||
177
backend/pkg/dnsprovider/custom/manual_provider.go
Normal file
177
backend/pkg/dnsprovider/custom/manual_provider.go
Normal 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
|
||||
}
|
||||
367
backend/pkg/dnsprovider/custom/manual_provider_test.go
Normal file
367
backend/pkg/dnsprovider/custom/manual_provider_test.go
Normal 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
|
||||
}
|
||||
271
backend/pkg/dnsprovider/custom/rfc2136_provider.go
Normal file
271
backend/pkg/dnsprovider/custom/rfc2136_provider.go
Normal 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
|
||||
}
|
||||
716
backend/pkg/dnsprovider/custom/rfc2136_provider_test.go
Normal file
716
backend/pkg/dnsprovider/custom/rfc2136_provider_test.go
Normal 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
|
||||
}
|
||||
311
backend/pkg/dnsprovider/custom/script_provider.go
Normal file
311
backend/pkg/dnsprovider/custom/script_provider.go
Normal 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)
|
||||
}
|
||||
1000
backend/pkg/dnsprovider/custom/script_provider_test.go
Normal file
1000
backend/pkg/dnsprovider/custom/script_provider_test.go
Normal file
File diff suppressed because it is too large
Load Diff
338
backend/pkg/dnsprovider/custom/webhook_provider.go
Normal file
338
backend/pkg/dnsprovider/custom/webhook_provider.go
Normal 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
|
||||
}
|
||||
856
backend/pkg/dnsprovider/custom/webhook_provider_test.go
Normal file
856
backend/pkg/dnsprovider/custom/webhook_provider_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user