diff --git a/.github/agents/QA_Security.agent.md b/.github/agents/QA_Security.agent.md index 156e98a5..1f788e48 100644 --- a/.github/agents/QA_Security.agent.md +++ b/.github/agents/QA_Security.agent.md @@ -76,7 +76,7 @@ The task is not complete until ALL of the following pass with zero issues: - **Scope**: Run tests relevant to modified features (e.g., `tests/manual-dns-provider.spec.ts`) - **On Failure**: Trace root cause through frontend → backend flow, report to Management or Dev subagent - **Base URL**: Uses `PLAYWRIGHT_BASE_URL` or default `http://100.98.12.109:8080` - - All E2E tests must pass before proceeding + - **MANDATORY**: All E2E tests must pass before proceeding 2. **Security Scans**: - CodeQL: Run VS Code task "Security: CodeQL All (CI-Aligned)" or individual Go/JS tasks diff --git a/backend/pkg/dnsprovider/custom/init.go b/backend/pkg/dnsprovider/custom/init.go index a2e8b395..f79fb333 100644 --- a/backend/pkg/dnsprovider/custom/init.go +++ b/backend/pkg/dnsprovider/custom/init.go @@ -10,6 +10,9 @@ import ( func init() { providers := []dnsprovider.ProviderPlugin{ NewManualProvider(), + NewRFC2136Provider(), + NewWebhookProvider(), + NewScriptProvider(), } for _, provider := range providers { diff --git a/backend/pkg/dnsprovider/custom/rfc2136_provider.go b/backend/pkg/dnsprovider/custom/rfc2136_provider.go new file mode 100644 index 00000000..655a91d2 --- /dev/null +++ b/backend/pkg/dnsprovider/custom/rfc2136_provider.go @@ -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 +} diff --git a/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go new file mode 100644 index 00000000..963652d0 --- /dev/null +++ b/backend/pkg/dnsprovider/custom/rfc2136_provider_test.go @@ -0,0 +1,714 @@ +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) + 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() + + 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() + + 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() + + 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 +} diff --git a/backend/pkg/dnsprovider/custom/script_provider.go b/backend/pkg/dnsprovider/custom/script_provider.go new file mode 100644 index 00000000..2b94c17b --- /dev/null +++ b/backend/pkg/dnsprovider/custom/script_provider.go @@ -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) +} diff --git a/backend/pkg/dnsprovider/custom/script_provider_test.go b/backend/pkg/dnsprovider/custom/script_provider_test.go new file mode 100644 index 00000000..793624d4 --- /dev/null +++ b/backend/pkg/dnsprovider/custom/script_provider_test.go @@ -0,0 +1,1000 @@ +package custom + +import ( + "testing" + "time" + + "github.com/Wikid82/charon/backend/pkg/dnsprovider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewScriptProvider(t *testing.T) { + provider := NewScriptProvider() + + require.NotNil(t, provider) + assert.Equal(t, ScriptDefaultPropagationTimeout, provider.propagationTimeout) + assert.Equal(t, ScriptDefaultPollingInterval, provider.pollingInterval) +} + +func TestScriptProvider_Type(t *testing.T) { + provider := NewScriptProvider() + assert.Equal(t, "script", provider.Type()) +} + +func TestScriptProvider_Metadata(t *testing.T) { + provider := NewScriptProvider() + metadata := provider.Metadata() + + assert.Equal(t, "script", metadata.Type) + assert.Equal(t, "Script (Shell)", metadata.Name) + assert.Contains(t, metadata.Description, "ADVANCED") + assert.Contains(t, metadata.Description, "HIGH-RISK") + assert.Contains(t, metadata.Description, "/scripts/") + 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 TestScriptProvider_Init(t *testing.T) { + provider := NewScriptProvider() + err := provider.Init() + assert.NoError(t, err) +} + +func TestScriptProvider_Cleanup(t *testing.T) { + provider := NewScriptProvider() + err := provider.Cleanup() + assert.NoError(t, err) +} + +func TestScriptProvider_RequiredCredentialFields(t *testing.T) { + provider := NewScriptProvider() + fields := provider.RequiredCredentialFields() + + require.Len(t, fields, 1) + + field := fields[0] + assert.Equal(t, "script_path", field.Name) + assert.Equal(t, "Script Path", field.Label) + assert.Equal(t, "text", field.Type) + assert.NotEmpty(t, field.Placeholder) + assert.Contains(t, field.Hint, "/scripts/") +} + +func TestScriptProvider_OptionalCredentialFields(t *testing.T) { + provider := NewScriptProvider() + fields := provider.OptionalCredentialFields() + + expectedFields := map[string]bool{ + "timeout_seconds": false, + "env_vars": 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) + assert.NotEmpty(t, field.Type, "Field %q has empty type", field.Name) + + // Verify env_vars is textarea type + if field.Name == "env_vars" { + assert.Equal(t, "textarea", field.Type, "env_vars should be textarea type") + } + } + + for name, found := range expectedFields { + if !found { + t.Errorf("Missing optional field: %q", name) + } + } +} + +func TestScriptProvider_ValidateCredentials(t *testing.T) { + provider := NewScriptProvider() + + tests := []struct { + name string + creds map[string]string + wantErr bool + errMsg string + }{ + { + name: "valid credentials minimal", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + }, + wantErr: false, + }, + { + name: "valid credentials with all options", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "120", + "env_vars": "API_KEY=secret123\nAPI_URL=https://api.example.com", + }, + wantErr: false, + }, + { + name: "valid credentials with nested directory", + creds: map[string]string{ + "script_path": "/scripts/dns/acme/challenge.sh", + }, + wantErr: false, + }, + { + name: "valid credentials with whitespace trimming", + creds: map[string]string{ + "script_path": " /scripts/dns-challenge.sh ", + }, + wantErr: false, + }, + { + name: "missing script_path", + creds: map[string]string{}, + wantErr: true, + errMsg: "script_path is required", + }, + { + name: "empty script_path", + creds: map[string]string{ + "script_path": "", + }, + wantErr: true, + errMsg: "script_path is required", + }, + { + name: "whitespace-only script_path", + creds: map[string]string{ + "script_path": " ", + }, + wantErr: true, + errMsg: "script_path is required", + }, + // Path traversal attacks - caught via directory prefix check after path normalization + { + name: "path traversal with ..", + creds: map[string]string{ + "script_path": "/scripts/../etc/passwd", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "path traversal at end", + creds: map[string]string{ + "script_path": "/scripts/dns/../../../etc/passwd", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "script outside allowed directory", + creds: map[string]string{ + "script_path": "/etc/passwd", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "script in home directory", + creds: map[string]string{ + "script_path": "/home/user/script.sh", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "script at root", + creds: map[string]string{ + "script_path": "/script.sh", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "relative path", + creds: map[string]string{ + "script_path": "scripts/dns-challenge.sh", + }, + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "script filename with hyphen prefix", + creds: map[string]string{ + "script_path": "/scripts/-rf", + }, + wantErr: true, + errMsg: "script filename cannot start with hyphen", + }, + { + name: "script filename with special characters", + creds: map[string]string{ + "script_path": "/scripts/script;rm -rf /", + }, + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + { + name: "script filename with spaces", + creds: map[string]string{ + "script_path": "/scripts/my script.sh", + }, + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + // Timeout validation + { + name: "timeout_seconds not a number", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "abc", + }, + wantErr: true, + errMsg: "timeout_seconds must be a number", + }, + { + name: "timeout_seconds too low", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "1", + }, + wantErr: true, + errMsg: "timeout_seconds must be between", + }, + { + name: "timeout_seconds too high", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "500", + }, + wantErr: true, + errMsg: "timeout_seconds must be between", + }, + { + name: "valid min timeout", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "5", + }, + wantErr: false, + }, + { + name: "valid max timeout", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "300", + }, + wantErr: false, + }, + // Environment variable validation + { + name: "env_vars invalid format no equals", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "INVALID_VAR", + }, + wantErr: true, + errMsg: "invalid format, expected KEY=VALUE", + }, + { + name: "env_vars key starting with number", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "1INVALID=value", + }, + wantErr: true, + errMsg: "invalid environment variable format", + }, + { + name: "env_vars override PATH", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "PATH=/malicious/path", + }, + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "env_vars override LD_PRELOAD", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "LD_PRELOAD=/malicious/lib.so", + }, + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "env_vars override HOME (case insensitive)", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "home=/tmp", + }, + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "env_vars with empty lines", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "API_KEY=secret\n\nAPI_URL=https://example.com\n", + }, + wantErr: false, + }, + { + name: "env_vars with underscore prefix", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "env_vars": "_PRIVATE_VAR=value", + }, + 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 TestScriptProvider_ValidateScriptPath(t *testing.T) { + tests := []struct { + name string + scriptPath string + wantErr bool + errMsg string + }{ + { + name: "valid path", + scriptPath: "/scripts/dns-challenge.sh", + wantErr: false, + }, + { + name: "valid nested path", + scriptPath: "/scripts/acme/dns/challenge.sh", + wantErr: false, + }, + { + name: "valid path with dots in filename", + scriptPath: "/scripts/dns.challenge.v1.sh", + wantErr: false, + }, + { + name: "valid path with underscore", + scriptPath: "/scripts/dns_challenge.sh", + wantErr: false, + }, + { + name: "valid path with hyphen", + scriptPath: "/scripts/dns-challenge.sh", + wantErr: false, + }, + { + name: "path traversal basic", + scriptPath: "/scripts/../etc/passwd", + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "path traversal complex", + scriptPath: "/scripts/subdir/../../../etc/passwd", + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "outside allowed directory", + scriptPath: "/etc/passwd", + wantErr: true, + errMsg: "script must be in /scripts/ directory", + }, + { + name: "script with hyphen prefix filename", + scriptPath: "/scripts/-help", + wantErr: true, + errMsg: "script filename cannot start with hyphen", + }, + { + name: "script with shell metacharacters", + scriptPath: "/scripts/script$(whoami).sh", + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + { + name: "script with backtick", + scriptPath: "/scripts/script`id`.sh", + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + { + name: "script with pipe", + scriptPath: "/scripts/script|cat.sh", + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + { + name: "script with semicolon", + scriptPath: "/scripts/script;ls.sh", + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + { + name: "script with ampersand", + scriptPath: "/scripts/script&bg.sh", + wantErr: true, + errMsg: "script filename contains invalid characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateScriptPath(tt.scriptPath) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestScriptProvider_ValidateEnvVars(t *testing.T) { + tests := []struct { + name string + envVars string + wantErr bool + errMsg string + }{ + { + name: "empty string", + envVars: "", + wantErr: false, + }, + { + name: "single valid variable", + envVars: "API_KEY=secret123", + wantErr: false, + }, + { + name: "multiple valid variables", + envVars: "API_KEY=secret123\nAPI_URL=https://api.example.com", + wantErr: false, + }, + { + name: "variable with empty value", + envVars: "EMPTY_VAR=", + wantErr: false, + }, + { + name: "variable starting with underscore", + envVars: "_PRIVATE=value", + wantErr: false, + }, + { + name: "empty lines are ignored", + envVars: "VAR1=val1\n\n\nVAR2=val2", + wantErr: false, + }, + { + name: "whitespace lines are ignored", + envVars: "VAR1=val1\n \nVAR2=val2", + wantErr: false, + }, + { + name: "missing equals sign", + envVars: "INVALID_VAR", + wantErr: true, + errMsg: "invalid format, expected KEY=VALUE", + }, + { + name: "key starting with number", + envVars: "1INVALID=value", + wantErr: true, + errMsg: "invalid environment variable format", + }, + { + name: "key with special characters", + envVars: "INVALID-KEY=value", + wantErr: true, + errMsg: "invalid environment variable format", + }, + { + name: "override PATH", + envVars: "PATH=/malicious", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "override LD_PRELOAD", + envVars: "LD_PRELOAD=/lib.so", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "override LD_LIBRARY_PATH", + envVars: "LD_LIBRARY_PATH=/lib", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "override HOME", + envVars: "HOME=/tmp", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "override USER", + envVars: "USER=root", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "override SHELL", + envVars: "SHELL=/bin/bash", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + { + name: "case insensitive critical var check", + envVars: "path=/malicious", + wantErr: true, + errMsg: "cannot override critical environment variable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateEnvVars(tt.envVars) + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestScriptProvider_ParseEnvVars(t *testing.T) { + tests := []struct { + name string + envVars string + expected map[string]string + }{ + { + name: "empty string", + envVars: "", + expected: map[string]string{}, + }, + { + name: "single variable", + envVars: "API_KEY=secret123", + expected: map[string]string{ + "API_KEY": "secret123", + }, + }, + { + name: "multiple variables", + envVars: "API_KEY=secret123\nAPI_URL=https://api.example.com", + expected: map[string]string{ + "API_KEY": "secret123", + "API_URL": "https://api.example.com", + }, + }, + { + name: "variable with empty value", + envVars: "EMPTY_VAR=", + expected: map[string]string{ + "EMPTY_VAR": "", + }, + }, + { + name: "variable with equals in value", + envVars: "CONNECTION=host=localhost;port=5432", + expected: map[string]string{ + "CONNECTION": "host=localhost;port=5432", + }, + }, + { + name: "skip empty lines", + envVars: "VAR1=val1\n\nVAR2=val2", + expected: map[string]string{ + "VAR1": "val1", + "VAR2": "val2", + }, + }, + { + name: "trim whitespace", + envVars: " VAR1=val1 \n VAR2=val2 ", + expected: map[string]string{ + "VAR1": "val1", + "VAR2": "val2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseEnvVars(tt.envVars) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScriptProvider_TestCredentials(t *testing.T) { + provider := NewScriptProvider() + + // TestCredentials should behave the same as ValidateCredentials + validCreds := map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + } + + err := provider.TestCredentials(validCreds) + assert.NoError(t, err) + + invalidCreds := map[string]string{ + "script_path": "/etc/passwd", + } + + err = provider.TestCredentials(invalidCreds) + assert.Error(t, err) +} + +func TestScriptProvider_SupportsMultiCredential(t *testing.T) { + provider := NewScriptProvider() + assert.False(t, provider.SupportsMultiCredential()) +} + +func TestScriptProvider_BuildCaddyConfig(t *testing.T) { + provider := NewScriptProvider() + + tests := []struct { + name string + creds map[string]string + expected map[string]any + }{ + { + name: "minimal config with defaults", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": ScriptDefaultTimeoutSeconds, + "env_vars": map[string]string{}, + }, + }, + { + name: "full config with all options", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "120", + "env_vars": "API_KEY=secret\nAPI_URL=https://example.com", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": 120, + "env_vars": map[string]string{ + "API_KEY": "secret", + "API_URL": "https://example.com", + }, + }, + }, + { + name: "whitespace trimming", + creds: map[string]string{ + "script_path": " /scripts/dns-challenge.sh ", + "timeout_seconds": " 90 ", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": 90, + "env_vars": map[string]string{}, + }, + }, + { + name: "invalid timeout falls back to default", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "invalid", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": ScriptDefaultTimeoutSeconds, + "env_vars": map[string]string{}, + }, + }, + { + name: "out-of-range timeout falls back to default", + creds: map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": "1000", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": ScriptDefaultTimeoutSeconds, + "env_vars": map[string]string{}, + }, + }, + { + name: "path normalization", + creds: map[string]string{ + "script_path": "/scripts/./subdir/../dns-challenge.sh", + }, + expected: map[string]any{ + "name": "script", + "script_path": "/scripts/dns-challenge.sh", + "timeout_seconds": ScriptDefaultTimeoutSeconds, + "env_vars": map[string]string{}, + }, + }, + } + + 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 TestScriptProvider_BuildCaddyConfigForZone(t *testing.T) { + provider := NewScriptProvider() + + creds := map[string]string{ + "script_path": "/scripts/dns-challenge.sh", + } + + config := provider.BuildCaddyConfigForZone("example.org", creds) + + // Should return same as BuildCaddyConfig since multi-credential is not supported + assert.Equal(t, "script", config["name"]) + assert.Equal(t, "/scripts/dns-challenge.sh", config["script_path"]) +} + +func TestScriptProvider_PropagationTimeout(t *testing.T) { + provider := NewScriptProvider() + timeout := provider.PropagationTimeout() + + assert.Equal(t, 120*time.Second, timeout) +} + +func TestScriptProvider_PollingInterval(t *testing.T) { + provider := NewScriptProvider() + interval := provider.PollingInterval() + + assert.Equal(t, 5*time.Second, interval) +} + +func TestScriptProvider_GetTimeoutSeconds(t *testing.T) { + provider := NewScriptProvider() + + tests := []struct { + name string + creds map[string]string + expected int + }{ + { + name: "empty creds returns default", + creds: map[string]string{}, + expected: ScriptDefaultTimeoutSeconds, + }, + { + name: "valid timeout returns value", + creds: map[string]string{"timeout_seconds": "90"}, + expected: 90, + }, + { + name: "invalid number returns default", + creds: map[string]string{"timeout_seconds": "abc"}, + expected: ScriptDefaultTimeoutSeconds, + }, + { + name: "out of range low returns default", + creds: map[string]string{"timeout_seconds": "1"}, + expected: ScriptDefaultTimeoutSeconds, + }, + { + name: "out of range high returns default", + creds: map[string]string{"timeout_seconds": "500"}, + expected: ScriptDefaultTimeoutSeconds, + }, + { + 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, + }, + { + name: "with whitespace", + creds: map[string]string{"timeout_seconds": " 60 "}, + expected: 60, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.GetTimeoutSeconds(tt.creds) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScriptProvider_GetEnvVars(t *testing.T) { + provider := NewScriptProvider() + + tests := []struct { + name string + creds map[string]string + expected map[string]string + }{ + { + name: "empty creds returns empty map", + creds: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "empty env_vars returns empty map", + creds: map[string]string{"env_vars": ""}, + expected: map[string]string{}, + }, + { + name: "single variable", + creds: map[string]string{"env_vars": "API_KEY=secret"}, + expected: map[string]string{ + "API_KEY": "secret", + }, + }, + { + name: "multiple variables", + creds: map[string]string{"env_vars": "KEY1=val1\nKEY2=val2"}, + expected: map[string]string{ + "KEY1": "val1", + "KEY2": "val2", + }, + }, + { + name: "whitespace only returns empty map", + creds: map[string]string{"env_vars": " \n "}, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := provider.GetEnvVars(tt.creds) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScriptProvider_Constants(t *testing.T) { + // Verify constant values are sensible + assert.Equal(t, 60, ScriptDefaultTimeoutSeconds) + assert.Equal(t, 120*time.Second, ScriptDefaultPropagationTimeout) + assert.Equal(t, 5*time.Second, ScriptDefaultPollingInterval) + assert.Equal(t, 5, ScriptMinTimeoutSeconds) + assert.Equal(t, 300, ScriptMaxTimeoutSeconds) + assert.Equal(t, "/scripts/", ScriptAllowedDirectory) + + // Ensure min < default < max for timeout + assert.Less(t, ScriptMinTimeoutSeconds, ScriptDefaultTimeoutSeconds) + assert.Less(t, ScriptDefaultTimeoutSeconds, ScriptMaxTimeoutSeconds) +} + +func TestScriptProvider_ImplementsInterface(t *testing.T) { + provider := NewScriptProvider() + + // Compile-time check that ScriptProvider implements ProviderPlugin + var _ dnsprovider.ProviderPlugin = provider +} + +func TestScriptProvider_SecurityPatterns(t *testing.T) { + // Test the regex patterns used for security validation + + t.Run("scriptArgPattern", func(t *testing.T) { + validNames := []string{ + "script.sh", + "dns-challenge.sh", + "dns_challenge_v1.sh", + "CHALLENGE.SH", + "script123.sh", + "a.b.c.d.sh", + } + + for _, name := range validNames { + assert.True(t, scriptArgPattern.MatchString(name), "Expected %q to match", name) + } + + invalidNames := []string{ + "script$(id).sh", + "script`id`.sh", + "script;rm.sh", + "script|cat.sh", + "script&bg.sh", + "script>out.sh", + "script 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 +} diff --git a/backend/pkg/dnsprovider/custom/webhook_provider_test.go b/backend/pkg/dnsprovider/custom/webhook_provider_test.go new file mode 100644 index 00000000..0961418c --- /dev/null +++ b/backend/pkg/dnsprovider/custom/webhook_provider_test.go @@ -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 +} diff --git a/docs/features/dns-providers.md b/docs/features/dns-providers.md new file mode 100644 index 00000000..3d56079b --- /dev/null +++ b/docs/features/dns-providers.md @@ -0,0 +1,307 @@ +# DNS Provider Types + +This document describes the DNS provider types available in Charon for DNS-01 challenge validation during SSL certificate issuance. + +## Overview + +Charon supports multiple DNS provider types to accommodate different deployment scenarios: + +| Provider Type | Use Case | Security Level | +|---------------|----------|----------------| +| **API-Based** | Cloudflare, Route53, DigitalOcean, etc. | ✅ Recommended | +| **RFC 2136** | Self-hosted BIND9, PowerDNS, Knot DNS | ✅ Recommended | +| **Webhook** | Custom DNS APIs, automation platforms | ⚠️ Moderate | +| **Script** | Legacy tools, custom integrations | ⚠️ High Risk | + +--- + +## RFC 2136 (Dynamic DNS) + +RFC 2136 Dynamic DNS Update allows Charon to directly update DNS records on authoritative DNS servers that support the protocol, using TSIG authentication for security. + +### Use Cases + +- Self-hosted BIND9, PowerDNS, or Knot DNS servers +- Enterprise environments with existing DNS infrastructure +- Air-gapped networks without external API access +- ISP or hosting provider managed DNS with RFC 2136 support + +### Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `nameserver` | ✅ | — | DNS server hostname or IP address | +| `tsig_key_name` | ✅ | — | TSIG key name (e.g., `acme-update.`) | +| `tsig_key_secret` | ✅ | — | Base64-encoded TSIG key secret | +| `port` | ❌ | `53` | DNS server port | +| `tsig_algorithm` | ❌ | `hmac-sha256` | TSIG algorithm (see below) | +| `zone` | ❌ | — | DNS zone override (auto-detected if not set) | + +### TSIG Algorithms + +| Algorithm | Recommendation | +|-----------|----------------| +| `hmac-sha256` | ✅ **Recommended** — Good balance of security and compatibility | +| `hmac-sha384` | ✅ Secure — Higher security, wider key | +| `hmac-sha512` | ✅ Secure — Maximum security | +| `hmac-sha1` | ⚠️ Legacy — Use only if required by older systems | +| `hmac-md5` | ❌ **Deprecated** — Avoid; cryptographically weak | + +### Example Configuration + +```json +{ + "type": "rfc2136", + "nameserver": "ns1.example.com", + "port": 53, + "tsig_key_name": "acme-update.", + "tsig_key_secret": "base64EncodedSecretKey==", + "tsig_algorithm": "hmac-sha256", + "zone": "example.com" +} +``` + +### Generating a TSIG Key (BIND9) + +```bash +# Generate a new TSIG key +tsig-keygen -a hmac-sha256 acme-update > /etc/bind/acme-update.key + +# Contents of generated key file: +# key "acme-update" { +# algorithm hmac-sha256; +# secret "base64EncodedSecretKey=="; +# }; +``` + +### Security Notes + +- **Network Security**: Ensure the DNS server is reachable from Charon (firewall rules, VPN) +- **Key Permissions**: TSIG keys should have minimal permissions (only `_acme-challenge` records) +- **Key Rotation**: Rotate TSIG keys periodically (recommended: every 90 days) +- **TLS Not Supported**: RFC 2136 uses UDP/TCP without encryption; use network-level security + +--- + +## Webhook Provider + +The Webhook provider enables integration with custom DNS APIs or automation platforms by sending HTTP requests to user-defined endpoints. + +### Use Cases + +- Custom internal DNS management APIs +- Integration with automation platforms (Ansible AWX, Rundeck, etc.) +- DNS providers without native Charon support +- Multi-system orchestration workflows + +### Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `create_url` | ✅ | — | URL to call when creating TXT records | +| `delete_url` | ✅ | — | URL to call when deleting TXT records | +| `auth_header` | ❌ | — | HTTP header name for authentication (e.g., `Authorization`) | +| `auth_value` | ❌ | — | HTTP header value (e.g., `Bearer token123`) | +| `timeout_seconds` | ❌ | `30` | Request timeout in seconds | +| `retry_count` | ❌ | `3` | Number of retry attempts on failure | +| `insecure_skip_verify` | ❌ | `false` | Skip TLS verification (⚠️ dev only) | + +### URL Template Variables + +The following variables are available in `create_url` and `delete_url`: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{{fqdn}}` | Full record name | `_acme-challenge.example.com` | +| `{{domain}}` | Base domain | `example.com` | +| `{{value}}` | TXT record value | `dGVzdC12YWx1ZQ==` | +| `{{ttl}}` | Record TTL | `120` | + +### Example Configuration + +```json +{ + "type": "webhook", + "create_url": "https://dns-api.example.com/records?action=create&fqdn={{fqdn}}&value={{value}}", + "delete_url": "https://dns-api.example.com/records?action=delete&fqdn={{fqdn}}", + "auth_header": "Authorization", + "auth_value": "Bearer your-api-token", + "timeout_seconds": 30, + "retry_count": 3 +} +``` + +### Webhook Request Format + +**Create Request:** + +```http +POST {{create_url}} +Content-Type: application/json +{{auth_header}}: {{auth_value}} + +{ + "fqdn": "_acme-challenge.example.com", + "domain": "example.com", + "value": "challenge-token-value", + "ttl": 120 +} +``` + +**Delete Request:** + +```http +POST {{delete_url}} +Content-Type: application/json +{{auth_header}}: {{auth_value}} + +{ + "fqdn": "_acme-challenge.example.com", + "domain": "example.com" +} +``` + +### Expected Responses + +| Status Code | Meaning | +|-------------|---------| +| `200`, `201`, `204` | Success | +| `4xx` | Client error — check configuration | +| `5xx` | Server error — will retry based on `retry_count` | + +### Security Notes + +- **HTTPS Required**: Non-localhost URLs must use HTTPS +- **Authentication**: Always use `auth_header` and `auth_value` for production +- **Timeouts**: Set appropriate timeouts to avoid blocking certificate issuance +- **`insecure_skip_verify`**: Never enable in production; only for local development with self-signed certs + +--- + +## Script Provider + +The Script provider executes shell scripts to manage DNS records, enabling integration with legacy systems or tools without API access. + +### ⚠️ HIGH-RISK PROVIDER + +> **Warning**: Scripts execute with container privileges. Only use when no other option is available. Thoroughly audit all scripts before deployment. + +### Use Cases + +- Legacy DNS management tools (nsupdate wrappers, custom CLIs) +- Systems requiring SSH-based updates +- Complex multi-step DNS workflows +- Air-gapped environments with local tooling + +### Configuration + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `script_path` | ✅ | — | Path to script (must be in `/scripts/`) | +| `timeout_seconds` | ❌ | `60` | Maximum script execution time | +| `env_vars` | ❌ | — | Environment variables (`KEY=VALUE` format) | + +### Script Interface + +Scripts receive DNS operation details via environment variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `DNS_ACTION` | Operation type | `create` or `delete` | +| `DNS_FQDN` | Full record name | `_acme-challenge.example.com` | +| `DNS_DOMAIN` | Base domain | `example.com` | +| `DNS_VALUE` | TXT record value (create only) | `challenge-token` | +| `DNS_TTL` | Record TTL (create only) | `120` | + +**Exit Codes:** + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Failure (generic) | +| `2` | Configuration error | + +### Example Configuration + +```json +{ + "type": "script", + "script_path": "/scripts/dns-update.sh", + "timeout_seconds": 60, + "env_vars": "DNS_SERVER=ns1.example.com,SSH_KEY_PATH=/secrets/dns-key" +} +``` + +### Example Script + +```bash +#!/bin/bash +# /scripts/dns-update.sh +set -euo pipefail + +case "$DNS_ACTION" in + create) + echo "Creating TXT record: $DNS_FQDN = $DNS_VALUE" + nsupdate -k /etc/bind/keys/update.key <&2 + exit 1 + ;; +esac +``` + +### Security Requirements + +| Requirement | Details | +|-------------|---------| +| **Script Location** | Must be in `/scripts/` directory (enforced) | +| **Permissions** | Script must be executable (`chmod +x`) | +| **Audit** | Review all scripts before deployment | +| **Secrets** | Use mounted secrets, never hardcode credentials | +| **Timeouts** | Set appropriate timeouts to prevent hanging | + +### Security Notes + +- **Container Privileges**: Scripts run with full container privileges +- **Path Restriction**: Scripts must reside in `/scripts/` to prevent arbitrary execution +- **No User Input**: Script path cannot contain user-supplied data +- **Logging**: All script executions are logged to audit trail +- **Resource Limits**: Use `timeout_seconds` to prevent runaway scripts +- **Testing**: Test scripts thoroughly in non-production before deployment + +--- + +## Provider Comparison + +| Feature | RFC 2136 | Webhook | Script | +|---------|----------|---------|--------| +| **Setup Complexity** | Medium | Low | High | +| **Security** | High (TSIG) | Medium (HTTPS) | Low (shell) | +| **Flexibility** | DNS servers only | HTTP APIs | Unlimited | +| **Debugging** | DNS tools | HTTP logs | Script logs | +| **Recommended For** | Self-hosted DNS | Custom APIs | Legacy only | + +## Related Documentation + +- [DNS Provider Auto-Detection](./dns-auto-detection.md) — Automatic provider identification +- [Multi-Credential DNS Support](./multi-credential.md) — Managing multiple credentials per provider +- [Key Rotation](./key-rotation.md) — Credential rotation best practices +- [Audit Logging](./audit-logging.md) — Tracking DNS operations + +--- + +_Last Updated: January 2026_ +_Version: 1.4.0_ diff --git a/frontend/src/components/DNSProviderForm.tsx b/frontend/src/components/DNSProviderForm.tsx index 0c70374e..0497c404 100644 --- a/frontend/src/components/DNSProviderForm.tsx +++ b/frontend/src/components/DNSProviderForm.tsx @@ -17,6 +17,7 @@ import { SelectValue, Checkbox, Alert, + Textarea, } from './ui' import { useDNSProviderTypes, useDNSProviderMutations, type DNSProvider } from '../hooks/useDNSProviders' import type { DNSProviderRequest, DNSProviderTypeInfo } from '../api/dnsProviders' @@ -233,18 +234,68 @@ export default function DNSProviderForm({ )} - {selectedProviderInfo.fields?.map((field) => ( - handleCredentialChange(field.name, e.target.value)} - placeholder={field.default} - helperText={field.hint} - required={field.required && !provider} // Don't require when editing (preserve existing) - /> - ))} + {selectedProviderInfo.fields?.map((field) => { + // Handle select field type + if (field.type === 'select' && field.options) { + return ( +
+ + + {field.hint && ( +

{field.hint}

+ )} +
+ ) + } + + // Handle textarea field type + if (field.type === 'textarea') { + return ( +
+ +