chore: clean .gitignore cache

This commit is contained in:
GitHub Actions
2026-01-26 19:21:33 +00:00
parent 1b1b3a70b1
commit e5f0fec5db
1483 changed files with 0 additions and 472793 deletions

View File

@@ -1,119 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// AzureProvider implements the ProviderPlugin interface for Azure DNS.
type AzureProvider struct{}
func (p *AzureProvider) Type() string {
return "azure"
}
func (p *AzureProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "azure",
Name: "Azure DNS",
Description: "Microsoft Azure DNS with service principal authentication",
DocumentationURL: "https://learn.microsoft.com/en-us/azure/dns/",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *AzureProvider) Init() error {
return nil
}
func (p *AzureProvider) Cleanup() error {
return nil
}
func (p *AzureProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "tenant_id",
Label: "Tenant ID",
Type: "text",
Placeholder: "Enter your Azure AD tenant ID",
Hint: "Azure Active Directory tenant ID",
},
{
Name: "client_id",
Label: "Client ID",
Type: "text",
Placeholder: "Enter your service principal client ID",
Hint: "Service principal (app registration) client ID",
},
{
Name: "client_secret",
Label: "Client Secret",
Type: "password",
Placeholder: "Enter your client secret",
Hint: "Service principal client secret",
},
{
Name: "subscription_id",
Label: "Subscription ID",
Type: "text",
Placeholder: "Enter your Azure subscription ID",
Hint: "Azure subscription containing DNS zone",
},
{
Name: "resource_group",
Label: "Resource Group",
Type: "text",
Placeholder: "Enter resource group name",
Hint: "Resource group containing the DNS zone",
},
}
}
func (p *AzureProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *AzureProvider) ValidateCredentials(creds map[string]string) error {
requiredFields := []string{"tenant_id", "client_id", "client_secret", "subscription_id", "resource_group"}
for _, field := range requiredFields {
if creds[field] == "" {
return fmt.Errorf("%s is required", field)
}
}
return nil
}
func (p *AzureProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *AzureProvider) SupportsMultiCredential() bool {
return false
}
func (p *AzureProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "azure",
"tenant_id": creds["tenant_id"],
"client_id": creds["client_id"],
"client_secret": creds["client_secret"],
"subscription_id": creds["subscription_id"],
"resource_group": creds["resource_group"],
}
}
func (p *AzureProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *AzureProvider) PropagationTimeout() time.Duration {
return 180 * time.Second
}
func (p *AzureProvider) PollingInterval() time.Duration {
return 10 * time.Second
}

View File

@@ -1,268 +0,0 @@
package builtin
import (
"testing"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
func TestCloudflareProvider(t *testing.T) {
p := &CloudflareProvider{}
if p.Type() != "cloudflare" {
t.Errorf("expected type cloudflare, got %s", p.Type())
}
meta := p.Metadata()
if meta.Name != "Cloudflare" {
t.Errorf("expected name Cloudflare, got %s", meta.Name)
}
if !meta.IsBuiltIn {
t.Error("expected IsBuiltIn to be true")
}
if err := p.Init(); err != nil {
t.Errorf("Init failed: %v", err)
}
if err := p.Cleanup(); err != nil {
t.Errorf("Cleanup failed: %v", err)
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
if required[0].Name != "api_token" {
t.Errorf("expected api_token field, got %s", required[0].Name)
}
optional := p.OptionalCredentialFields()
if len(optional) != 1 {
t.Errorf("expected 1 optional field, got %d", len(optional))
}
if optional[0].Name != "zone_id" {
t.Errorf("expected zone_id field, got %s", optional[0].Name)
}
// Test credential validation
err := p.ValidateCredentials(map[string]string{})
if err == nil {
t.Error("expected validation error for empty credentials")
}
err = p.ValidateCredentials(map[string]string{"api_token": "test"})
if err != nil {
t.Errorf("validation failed: %v", err)
}
if p.SupportsMultiCredential() {
t.Error("expected SupportsMultiCredential to be false")
}
config := p.BuildCaddyConfig(map[string]string{"api_token": "test"})
if config["name"] != "cloudflare" {
t.Error("expected caddy config name to be cloudflare")
}
if config["api_token"] != "test" {
t.Error("expected api_token in caddy config")
}
timeout := p.PropagationTimeout()
if timeout.Seconds() == 0 {
t.Error("expected non-zero propagation timeout")
}
interval := p.PollingInterval()
if interval.Seconds() == 0 {
t.Error("expected non-zero polling interval")
}
}
func TestRoute53Provider(t *testing.T) {
p := &Route53Provider{}
if p.Type() != "route53" {
t.Errorf("expected type route53, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 2 {
t.Errorf("expected 2 required fields, got %d", len(required))
}
err := p.ValidateCredentials(map[string]string{})
if err == nil {
t.Error("expected validation error for empty credentials")
}
err = p.ValidateCredentials(map[string]string{
"access_key_id": "test",
"secret_access_key": "test",
})
if err != nil {
t.Errorf("validation failed: %v", err)
}
}
func TestDigitalOceanProvider(t *testing.T) {
p := &DigitalOceanProvider{}
if p.Type() != "digitalocean" {
t.Errorf("expected type digitalocean, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
err := p.ValidateCredentials(map[string]string{})
if err == nil {
t.Error("expected validation error for empty credentials")
}
err = p.ValidateCredentials(map[string]string{"api_token": "test"})
if err != nil {
t.Errorf("validation failed: %v", err)
}
}
func TestGoogleCloudDNSProvider(t *testing.T) {
p := &GoogleCloudDNSProvider{}
if p.Type() != "googleclouddns" {
t.Errorf("expected type googleclouddns, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
err := p.ValidateCredentials(map[string]string{})
if err == nil {
t.Error("expected validation error for empty credentials")
}
}
func TestAzureProvider(t *testing.T) {
p := &AzureProvider{}
if p.Type() != "azure" {
t.Errorf("expected type azure, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 5 {
t.Errorf("expected 5 required fields, got %d", len(required))
}
}
func TestNamecheapProvider(t *testing.T) {
p := &NamecheapProvider{}
if p.Type() != "namecheap" {
t.Errorf("expected type namecheap, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 2 {
t.Errorf("expected 2 required fields, got %d", len(required))
}
}
func TestGoDaddyProvider(t *testing.T) {
p := &GoDaddyProvider{}
if p.Type() != "godaddy" {
t.Errorf("expected type godaddy, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 2 {
t.Errorf("expected 2 required fields, got %d", len(required))
}
}
func TestHetznerProvider(t *testing.T) {
p := &HetznerProvider{}
if p.Type() != "hetzner" {
t.Errorf("expected type hetzner, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
}
func TestVultrProvider(t *testing.T) {
p := &VultrProvider{}
if p.Type() != "vultr" {
t.Errorf("expected type vultr, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
}
func TestDNSimpleProvider(t *testing.T) {
p := &DNSimpleProvider{}
if p.Type() != "dnsimple" {
t.Errorf("expected type dnsimple, got %s", p.Type())
}
required := p.RequiredCredentialFields()
if len(required) != 1 {
t.Errorf("expected 1 required field, got %d", len(required))
}
}
func TestProviderRegistration(t *testing.T) {
// Test that all providers are registered after init
providers := []string{
"cloudflare",
"route53",
"digitalocean",
"googleclouddns",
"azure",
"namecheap",
"godaddy",
"hetzner",
"vultr",
"dnsimple",
}
for _, providerType := range providers {
provider, ok := dnsprovider.Global().Get(providerType)
if !ok {
t.Errorf("provider %s not registered", providerType)
}
if provider == nil {
t.Errorf("provider %s is nil", providerType)
}
}
// Test GetTypes
types := dnsprovider.Global().Types()
if len(types) < len(providers) {
t.Errorf("expected at least %d types, got %d", len(providers), len(types))
}
// Test IsSupported
for _, providerType := range providers {
if !dnsprovider.Global().IsSupported(providerType) {
t.Errorf("provider %s should be supported", providerType)
}
}
if dnsprovider.Global().IsSupported("invalid-provider") {
t.Error("invalid provider should not be supported")
}
}

View File

@@ -1,96 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// CloudflareProvider implements the ProviderPlugin interface for Cloudflare DNS.
type CloudflareProvider struct{}
func (p *CloudflareProvider) Type() string {
return "cloudflare"
}
func (p *CloudflareProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "cloudflare",
Name: "Cloudflare",
Description: "Cloudflare DNS with API Token authentication",
DocumentationURL: "https://developers.cloudflare.com/api/tokens/create/",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *CloudflareProvider) Init() error {
return nil
}
func (p *CloudflareProvider) Cleanup() error {
return nil
}
func (p *CloudflareProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your Cloudflare API token",
Hint: "Token requires Zone:DNS:Edit permission",
},
}
}
func (p *CloudflareProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "zone_id",
Label: "Zone ID",
Type: "text",
Placeholder: "Optional: Specific zone ID",
Hint: "Leave empty to auto-detect zone",
},
}
}
func (p *CloudflareProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_token"] == "" {
return fmt.Errorf("api_token is required")
}
return nil
}
func (p *CloudflareProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *CloudflareProvider) SupportsMultiCredential() bool {
return false
}
func (p *CloudflareProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "cloudflare",
"api_token": creds["api_token"],
}
if zoneID := creds["zone_id"]; zoneID != "" {
config["zone_id"] = zoneID
}
return config
}
func (p *CloudflareProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *CloudflareProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *CloudflareProvider) PollingInterval() time.Duration {
return 5 * time.Second
}

View File

@@ -1,84 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// DigitalOceanProvider implements the ProviderPlugin interface for DigitalOcean DNS.
type DigitalOceanProvider struct{}
func (p *DigitalOceanProvider) Type() string {
return "digitalocean"
}
func (p *DigitalOceanProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "digitalocean",
Name: "DigitalOcean",
Description: "DigitalOcean DNS with API token authentication",
DocumentationURL: "https://docs.digitalocean.com/reference/api/api-reference/#tag/Domains",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *DigitalOceanProvider) Init() error {
return nil
}
func (p *DigitalOceanProvider) Cleanup() error {
return nil
}
func (p *DigitalOceanProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your DigitalOcean API token",
Hint: "Generate from API settings in your DigitalOcean account",
},
}
}
func (p *DigitalOceanProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *DigitalOceanProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_token"] == "" {
return fmt.Errorf("api_token is required")
}
return nil
}
func (p *DigitalOceanProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *DigitalOceanProvider) SupportsMultiCredential() bool {
return false
}
func (p *DigitalOceanProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "digitalocean",
"api_token": creds["api_token"],
}
}
func (p *DigitalOceanProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *DigitalOceanProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *DigitalOceanProvider) PollingInterval() time.Duration {
return 5 * time.Second
}

View File

@@ -1,96 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// DNSimpleProvider implements the ProviderPlugin interface for DNSimple.
type DNSimpleProvider struct{}
func (p *DNSimpleProvider) Type() string {
return "dnsimple"
}
func (p *DNSimpleProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "dnsimple",
Name: "DNSimple",
Description: "DNSimple DNS with API token authentication",
DocumentationURL: "https://developer.dnsimple.com/",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *DNSimpleProvider) Init() error {
return nil
}
func (p *DNSimpleProvider) Cleanup() error {
return nil
}
func (p *DNSimpleProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your DNSimple API token",
Hint: "OAuth token or Account API token",
},
}
}
func (p *DNSimpleProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "account_id",
Label: "Account ID",
Type: "text",
Placeholder: "12345",
Hint: "Optional: Your DNSimple account ID",
},
}
}
func (p *DNSimpleProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_token"] == "" {
return fmt.Errorf("api_token is required")
}
return nil
}
func (p *DNSimpleProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *DNSimpleProvider) SupportsMultiCredential() bool {
return false
}
func (p *DNSimpleProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "dnsimple",
"api_token": creds["api_token"],
}
if accountID := creds["account_id"]; accountID != "" {
config["account_id"] = accountID
}
return config
}
func (p *DNSimpleProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *DNSimpleProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *DNSimpleProvider) PollingInterval() time.Duration {
return 5 * time.Second
}

View File

@@ -1,95 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// GoDaddyProvider implements the ProviderPlugin interface for GoDaddy DNS.
type GoDaddyProvider struct{}
func (p *GoDaddyProvider) Type() string {
return "godaddy"
}
func (p *GoDaddyProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "godaddy",
Name: "GoDaddy",
Description: "GoDaddy DNS with API key and secret",
DocumentationURL: "https://developer.godaddy.com/doc",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *GoDaddyProvider) Init() error {
return nil
}
func (p *GoDaddyProvider) Cleanup() error {
return nil
}
func (p *GoDaddyProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_key",
Label: "API Key",
Type: "text",
Placeholder: "Enter your GoDaddy API key",
Hint: "Production API key from GoDaddy developer portal",
},
{
Name: "api_secret",
Label: "API Secret",
Type: "password",
Placeholder: "Enter your GoDaddy API secret",
Hint: "Production API secret (stored encrypted)",
},
}
}
func (p *GoDaddyProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *GoDaddyProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_key"] == "" {
return fmt.Errorf("api_key is required")
}
if creds["api_secret"] == "" {
return fmt.Errorf("api_secret is required")
}
return nil
}
func (p *GoDaddyProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *GoDaddyProvider) SupportsMultiCredential() bool {
return false
}
func (p *GoDaddyProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "godaddy",
"api_key": creds["api_key"],
"api_secret": creds["api_secret"],
}
}
func (p *GoDaddyProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *GoDaddyProvider) PropagationTimeout() time.Duration {
return 180 * time.Second
}
func (p *GoDaddyProvider) PollingInterval() time.Duration {
return 10 * time.Second
}

View File

@@ -1,96 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// GoogleCloudDNSProvider implements the ProviderPlugin interface for Google Cloud DNS.
type GoogleCloudDNSProvider struct{}
func (p *GoogleCloudDNSProvider) Type() string {
return "googleclouddns"
}
func (p *GoogleCloudDNSProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "googleclouddns",
Name: "Google Cloud DNS",
Description: "Google Cloud DNS with service account credentials",
DocumentationURL: "https://cloud.google.com/dns/docs",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *GoogleCloudDNSProvider) Init() error {
return nil
}
func (p *GoogleCloudDNSProvider) Cleanup() error {
return nil
}
func (p *GoogleCloudDNSProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "service_account_json",
Label: "Service Account JSON",
Type: "textarea",
Placeholder: "Paste your service account JSON key",
Hint: "JSON key file content for service account with DNS admin role",
},
}
}
func (p *GoogleCloudDNSProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "project_id",
Label: "Project ID",
Type: "text",
Placeholder: "my-gcp-project",
Hint: "Optional: GCP project ID (auto-detected from service account if not provided)",
},
}
}
func (p *GoogleCloudDNSProvider) ValidateCredentials(creds map[string]string) error {
if creds["service_account_json"] == "" {
return fmt.Errorf("service_account_json is required")
}
return nil
}
func (p *GoogleCloudDNSProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *GoogleCloudDNSProvider) SupportsMultiCredential() bool {
return false
}
func (p *GoogleCloudDNSProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "googleclouddns",
"service_account_json": creds["service_account_json"],
}
if projectID := creds["project_id"]; projectID != "" {
config["project_id"] = projectID
}
return config
}
func (p *GoogleCloudDNSProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *GoogleCloudDNSProvider) PropagationTimeout() time.Duration {
return 180 * time.Second
}
func (p *GoogleCloudDNSProvider) PollingInterval() time.Duration {
return 10 * time.Second
}

View File

@@ -1,84 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// HetznerProvider implements the ProviderPlugin interface for Hetzner DNS.
type HetznerProvider struct{}
func (p *HetznerProvider) Type() string {
return "hetzner"
}
func (p *HetznerProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "hetzner",
Name: "Hetzner",
Description: "Hetzner DNS with API token authentication",
DocumentationURL: "https://dns.hetzner.com/api-docs",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *HetznerProvider) Init() error {
return nil
}
func (p *HetznerProvider) Cleanup() error {
return nil
}
func (p *HetznerProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_token",
Label: "API Token",
Type: "password",
Placeholder: "Enter your Hetzner DNS API token",
Hint: "Generate from Hetzner DNS Console",
},
}
}
func (p *HetznerProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *HetznerProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_token"] == "" {
return fmt.Errorf("api_token is required")
}
return nil
}
func (p *HetznerProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *HetznerProvider) SupportsMultiCredential() bool {
return false
}
func (p *HetznerProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "hetzner",
"api_token": creds["api_token"],
}
}
func (p *HetznerProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *HetznerProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *HetznerProvider) PollingInterval() time.Duration {
return 5 * time.Second
}

View File

@@ -1,36 +0,0 @@
package builtin
import (
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// init automatically registers all built-in DNS provider plugins when the package is imported.
func init() {
providers := []dnsprovider.ProviderPlugin{
&CloudflareProvider{},
&Route53Provider{},
&DigitalOceanProvider{},
&GoogleCloudDNSProvider{},
&AzureProvider{},
&NamecheapProvider{},
&GoDaddyProvider{},
&HetznerProvider{},
&VultrProvider{},
&DNSimpleProvider{},
}
for _, provider := range providers {
if err := provider.Init(); err != nil {
logger.Log().WithError(err).Warnf("Failed to initialize built-in provider: %s", provider.Type())
continue
}
if err := dnsprovider.Global().Register(provider); err != nil {
logger.Log().WithError(err).Warnf("Failed to register built-in provider: %s", provider.Type())
continue
}
logger.Log().Debugf("Registered built-in DNS provider: %s", provider.Type())
}
}

View File

@@ -1,107 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// NamecheapProvider implements the ProviderPlugin interface for Namecheap DNS.
type NamecheapProvider struct{}
func (p *NamecheapProvider) Type() string {
return "namecheap"
}
func (p *NamecheapProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "namecheap",
Name: "Namecheap",
Description: "Namecheap DNS with API credentials",
DocumentationURL: "https://www.namecheap.com/support/api/intro/",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *NamecheapProvider) Init() error {
return nil
}
func (p *NamecheapProvider) Cleanup() error {
return nil
}
func (p *NamecheapProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_user",
Label: "API User",
Type: "text",
Placeholder: "Enter your Namecheap API username",
Hint: "Your Namecheap account username",
},
{
Name: "api_key",
Label: "API Key",
Type: "password",
Placeholder: "Enter your Namecheap API key",
Hint: "Enable API access in your Namecheap account",
},
}
}
func (p *NamecheapProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "client_ip",
Label: "Client IP",
Type: "text",
Placeholder: "1.2.3.4",
Hint: "Optional: Whitelisted IP address for API access",
},
}
}
func (p *NamecheapProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_user"] == "" {
return fmt.Errorf("api_user is required")
}
if creds["api_key"] == "" {
return fmt.Errorf("api_key is required")
}
return nil
}
func (p *NamecheapProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *NamecheapProvider) SupportsMultiCredential() bool {
return false
}
func (p *NamecheapProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "namecheap",
"api_user": creds["api_user"],
"api_key": creds["api_key"],
}
if clientIP := creds["client_ip"]; clientIP != "" {
config["client_ip"] = clientIP
}
return config
}
func (p *NamecheapProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *NamecheapProvider) PropagationTimeout() time.Duration {
return 300 * time.Second
}
func (p *NamecheapProvider) PollingInterval() time.Duration {
return 15 * time.Second
}

View File

@@ -1,117 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// Route53Provider implements the ProviderPlugin interface for AWS Route53.
type Route53Provider struct{}
func (p *Route53Provider) Type() string {
return "route53"
}
func (p *Route53Provider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "route53",
Name: "AWS Route53",
Description: "Amazon Route53 DNS with IAM credentials",
DocumentationURL: "https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *Route53Provider) Init() error {
return nil
}
func (p *Route53Provider) Cleanup() error {
return nil
}
func (p *Route53Provider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "access_key_id",
Label: "Access Key ID",
Type: "text",
Placeholder: "Enter your AWS Access Key ID",
Hint: "IAM user with Route53 permissions",
},
{
Name: "secret_access_key",
Label: "Secret Access Key",
Type: "password",
Placeholder: "Enter your AWS Secret Access Key",
Hint: "Stored encrypted",
},
}
}
func (p *Route53Provider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "region",
Label: "AWS Region",
Type: "text",
Placeholder: "us-east-1",
Hint: "AWS region (default: us-east-1)",
},
{
Name: "hosted_zone_id",
Label: "Hosted Zone ID",
Type: "text",
Placeholder: "Z1234567890ABC",
Hint: "Optional: Specific hosted zone ID",
},
}
}
func (p *Route53Provider) ValidateCredentials(creds map[string]string) error {
if creds["access_key_id"] == "" {
return fmt.Errorf("access_key_id is required")
}
if creds["secret_access_key"] == "" {
return fmt.Errorf("secret_access_key is required")
}
return nil
}
func (p *Route53Provider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *Route53Provider) SupportsMultiCredential() bool {
return false
}
func (p *Route53Provider) BuildCaddyConfig(creds map[string]string) map[string]any {
config := map[string]any{
"name": "route53",
"access_key_id": creds["access_key_id"],
"secret_access_key": creds["secret_access_key"],
}
if region := creds["region"]; region != "" {
config["region"] = region
}
if zoneID := creds["hosted_zone_id"]; zoneID != "" {
config["hosted_zone_id"] = zoneID
}
return config
}
func (p *Route53Provider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *Route53Provider) PropagationTimeout() time.Duration {
return 180 * time.Second
}
func (p *Route53Provider) PollingInterval() time.Duration {
return 10 * time.Second
}

View File

@@ -1,84 +0,0 @@
package builtin
import (
"fmt"
"time"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
)
// VultrProvider implements the ProviderPlugin interface for Vultr DNS.
type VultrProvider struct{}
func (p *VultrProvider) Type() string {
return "vultr"
}
func (p *VultrProvider) Metadata() dnsprovider.ProviderMetadata {
return dnsprovider.ProviderMetadata{
Type: "vultr",
Name: "Vultr",
Description: "Vultr DNS with API key authentication",
DocumentationURL: "https://www.vultr.com/api/#tag/dns",
IsBuiltIn: true,
Version: "1.0.0",
}
}
func (p *VultrProvider) Init() error {
return nil
}
func (p *VultrProvider) Cleanup() error {
return nil
}
func (p *VultrProvider) RequiredCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{
{
Name: "api_key",
Label: "API Key",
Type: "password",
Placeholder: "Enter your Vultr API key",
Hint: "Generate from Vultr account settings",
},
}
}
func (p *VultrProvider) OptionalCredentialFields() []dnsprovider.CredentialFieldSpec {
return []dnsprovider.CredentialFieldSpec{}
}
func (p *VultrProvider) ValidateCredentials(creds map[string]string) error {
if creds["api_key"] == "" {
return fmt.Errorf("api_key is required")
}
return nil
}
func (p *VultrProvider) TestCredentials(creds map[string]string) error {
return p.ValidateCredentials(creds)
}
func (p *VultrProvider) SupportsMultiCredential() bool {
return false
}
func (p *VultrProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{
"name": "vultr",
"api_key": creds["api_key"],
}
}
func (p *VultrProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return p.BuildCaddyConfig(creds)
}
func (p *VultrProvider) PropagationTimeout() time.Duration {
return 120 * time.Second
}
func (p *VultrProvider) PollingInterval() time.Duration {
return 5 * time.Second
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,714 +0,0 @@
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
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,856 +0,0 @@
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
}

View File

@@ -1,45 +0,0 @@
package dnsprovider
import "errors"
// Common errors returned by the plugin system.
var (
// ErrProviderNotFound is returned when a requested provider type is not registered.
ErrProviderNotFound = errors.New("dns provider not found")
// ErrProviderAlreadyRegistered is returned when attempting to register
// a provider with a type that is already registered.
ErrProviderAlreadyRegistered = errors.New("dns provider already registered")
// ErrInvalidPlugin is returned when a plugin doesn't meet requirements
// (e.g., nil plugin, empty type, missing required symbol).
ErrInvalidPlugin = errors.New("invalid plugin: missing required fields or interface")
// ErrSignatureMismatch is returned when a plugin's signature doesn't match
// the expected signature in the allowlist.
ErrSignatureMismatch = errors.New("plugin signature does not match allowlist")
// ErrPluginNotInAllowlist is returned when attempting to load a plugin
// that isn't in the configured allowlist.
ErrPluginNotInAllowlist = errors.New("plugin not in allowlist")
// ErrInterfaceVersionMismatch is returned when a plugin was built against
// a different interface version than the host application.
ErrInterfaceVersionMismatch = errors.New("plugin interface version mismatch")
// ErrPluginLoadFailed is returned when the Go plugin system fails to load
// a .so file (e.g., missing symbol, incompatible Go version).
ErrPluginLoadFailed = errors.New("failed to load plugin")
// ErrPluginInitFailed is returned when a plugin's Init() method returns an error.
ErrPluginInitFailed = errors.New("plugin initialization failed")
// ErrPluginDisabled is returned when attempting to use a disabled plugin.
ErrPluginDisabled = errors.New("plugin is disabled")
// ErrCredentialsInvalid is returned when credential validation fails.
ErrCredentialsInvalid = errors.New("invalid credentials")
// ErrCredentialsTestFailed is returned when credential testing fails.
ErrCredentialsTestFailed = errors.New("credential test failed")
)

View File

@@ -1,96 +0,0 @@
// Package dnsprovider defines the plugin interface and types for DNS provider plugins.
// Both built-in providers and external plugins implement this interface.
package dnsprovider
import "time"
// InterfaceVersion is the current plugin interface version.
// Plugins built against a different version may not be compatible.
const InterfaceVersion = "v1"
// ProviderPlugin defines the interface that all DNS provider plugins must implement.
// Both built-in providers and external plugins implement this interface.
type ProviderPlugin interface {
// Type returns the unique provider type identifier (e.g., "cloudflare", "powerdns").
// This must be lowercase, alphanumeric with optional underscores.
Type() string
// Metadata returns descriptive information about the provider for UI display.
Metadata() ProviderMetadata
// Init is called after the plugin is registered. Use for startup initialization
// (loading config files, validating environment, establishing connections).
// Return an error to prevent the plugin from being registered.
Init() error
// Cleanup is called before the plugin is unregistered. Use for resource cleanup
// (closing connections, flushing caches). Note: Go plugins cannot be unloaded
// from memory - this is only called during graceful shutdown.
Cleanup() error
// RequiredCredentialFields returns the credential fields that must be provided.
RequiredCredentialFields() []CredentialFieldSpec
// OptionalCredentialFields returns credential fields that may be provided.
OptionalCredentialFields() []CredentialFieldSpec
// ValidateCredentials checks if the provided credentials are valid.
// Returns nil if valid, error describing the issue otherwise.
ValidateCredentials(creds map[string]string) error
// TestCredentials attempts to verify credentials work with the provider API.
// This may make network calls to the provider.
TestCredentials(creds map[string]string) error
// SupportsMultiCredential indicates if the provider can handle zone-specific credentials.
// If true, BuildCaddyConfigForZone will be called instead of BuildCaddyConfig when
// multi-credential mode is enabled (Phase 3 feature).
SupportsMultiCredential() bool
// BuildCaddyConfig constructs the Caddy DNS challenge configuration.
// The returned map is embedded into Caddy's TLS automation policy.
// Used when multi-credential mode is disabled.
BuildCaddyConfig(creds map[string]string) map[string]any
// BuildCaddyConfigForZone constructs config for a specific zone (multi-credential mode).
// Only called if SupportsMultiCredential() returns true.
// baseDomain is the zone being configured (e.g., "example.com").
BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any
// PropagationTimeout returns the recommended DNS propagation wait time.
PropagationTimeout() time.Duration
// PollingInterval returns the recommended polling interval for DNS verification.
PollingInterval() time.Duration
}
// ProviderMetadata contains descriptive information about a DNS provider.
type ProviderMetadata struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
DocumentationURL string `json:"documentation_url,omitempty"`
Author string `json:"author,omitempty"`
Version string `json:"version,omitempty"`
IsBuiltIn bool `json:"is_built_in"`
// Version compatibility (required for external plugins)
GoVersion string `json:"go_version,omitempty"` // Go version used to build (e.g., "1.23.4")
InterfaceVersion string `json:"interface_version,omitempty"` // Plugin interface version (e.g., "v1")
}
// CredentialFieldSpec defines a credential field for UI rendering.
type CredentialFieldSpec struct {
Name string `json:"name"` // Field key (e.g., "api_token")
Label string `json:"label"` // Display label (e.g., "API Token")
Type string `json:"type"` // "text", "password", "textarea", "select"
Placeholder string `json:"placeholder,omitempty"` // Input placeholder text
Hint string `json:"hint,omitempty"` // Help text shown below field
Options []SelectOption `json:"options,omitempty"` // For "select" type
}
// SelectOption represents an option in a select dropdown.
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}

View File

@@ -1,129 +0,0 @@
package dnsprovider
import (
"sort"
"sync"
)
// Registry is a thread-safe registry of DNS provider plugins.
type Registry struct {
providers map[string]ProviderPlugin
mu sync.RWMutex
}
// globalRegistry is the singleton registry instance.
var globalRegistry = &Registry{
providers: make(map[string]ProviderPlugin),
}
// Global returns the global provider registry.
func Global() *Registry {
return globalRegistry
}
// NewRegistry creates a new registry instance for testing purposes.
// Use Global() for production code.
func NewRegistry() *Registry {
return &Registry{
providers: make(map[string]ProviderPlugin),
}
}
// Register adds a provider to the registry.
// Returns ErrInvalidPlugin if the provider type is empty,
// or ErrProviderAlreadyRegistered if the type is already registered.
func (r *Registry) Register(provider ProviderPlugin) error {
if provider == nil {
return ErrInvalidPlugin
}
r.mu.Lock()
defer r.mu.Unlock()
providerType := provider.Type()
if providerType == "" {
return ErrInvalidPlugin
}
if _, exists := r.providers[providerType]; exists {
return ErrProviderAlreadyRegistered
}
r.providers[providerType] = provider
return nil
}
// Get retrieves a provider by type.
// Returns the provider and true if found, nil and false otherwise.
func (r *Registry) Get(providerType string) (ProviderPlugin, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
provider, ok := r.providers[providerType]
return provider, ok
}
// List returns all registered providers.
// The returned slice is sorted alphabetically by provider type.
func (r *Registry) List() []ProviderPlugin {
r.mu.RLock()
defer r.mu.RUnlock()
providers := make([]ProviderPlugin, 0, len(r.providers))
for _, p := range r.providers {
providers = append(providers, p)
}
// Sort by type for consistent ordering
sort.Slice(providers, func(i, j int) bool {
return providers[i].Type() < providers[j].Type()
})
return providers
}
// Types returns all registered provider type identifiers.
// The returned slice is sorted alphabetically.
func (r *Registry) Types() []string {
r.mu.RLock()
defer r.mu.RUnlock()
types := make([]string, 0, len(r.providers))
for t := range r.providers {
types = append(types, t)
}
sort.Strings(types)
return types
}
// IsSupported checks if a provider type is registered.
func (r *Registry) IsSupported(providerType string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.providers[providerType]
return ok
}
// Unregister removes a provider from the registry.
// Used primarily for plugin unloading during shutdown.
// Safe to call with a type that doesn't exist.
func (r *Registry) Unregister(providerType string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.providers, providerType)
}
// Count returns the number of registered providers.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.providers)
}
// Clear removes all providers from the registry.
// Primarily used for testing.
func (r *Registry) Clear() {
r.mu.Lock()
defer r.mu.Unlock()
r.providers = make(map[string]ProviderPlugin)
}

View File

@@ -1,553 +0,0 @@
package dnsprovider
import (
"sync"
"testing"
"time"
)
// mockProvider is a test implementation of ProviderPlugin
type mockProvider struct {
providerType string
}
func (m *mockProvider) Type() string {
return m.providerType
}
func (m *mockProvider) Metadata() ProviderMetadata {
return ProviderMetadata{
Type: m.providerType,
Name: "Mock Provider",
Version: "1.0.0",
IsBuiltIn: false,
}
}
func (m *mockProvider) Init() error {
return nil
}
func (m *mockProvider) Cleanup() error {
return nil
}
func (m *mockProvider) ValidateCredentials(creds map[string]string) error {
return nil
}
func (m *mockProvider) TestCredentials(creds map[string]string) error {
return nil
}
func (m *mockProvider) RequiredCredentialFields() []CredentialFieldSpec {
return []CredentialFieldSpec{}
}
func (m *mockProvider) OptionalCredentialFields() []CredentialFieldSpec {
return []CredentialFieldSpec{}
}
func (m *mockProvider) SupportsMultiCredential() bool {
return false
}
func (m *mockProvider) BuildCaddyConfig(creds map[string]string) map[string]any {
return map[string]any{}
}
func (m *mockProvider) BuildCaddyConfigForZone(baseDomain string, creds map[string]string) map[string]any {
return map[string]any{}
}
func (m *mockProvider) PropagationTimeout() time.Duration {
return time.Minute
}
func (m *mockProvider) PollingInterval() time.Duration {
return 2 * time.Second
}
// TestNewRegistry tests creating a new registry instance
func TestNewRegistry(t *testing.T) {
r := NewRegistry()
if r == nil {
t.Fatal("NewRegistry returned nil")
}
if r.Count() != 0 {
t.Errorf("expected empty registry, got %d providers", r.Count())
}
}
// TestGlobal tests the global registry singleton
func TestGlobal(t *testing.T) {
r1 := Global()
r2 := Global()
if r1 != r2 {
t.Error("Global() should return the same instance")
}
if r1 == nil {
t.Fatal("Global() returned nil")
}
}
// TestRegister tests registering providers
func TestRegister(t *testing.T) {
tests := []struct {
name string
provider ProviderPlugin
wantErr error
}{
{
name: "successful registration",
provider: &mockProvider{providerType: "test-provider"},
wantErr: nil,
},
{
name: "nil provider",
provider: nil,
wantErr: ErrInvalidPlugin,
},
{
name: "empty provider type",
provider: &mockProvider{providerType: ""},
wantErr: ErrInvalidPlugin,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := NewRegistry()
err := r.Register(tt.provider)
if err != tt.wantErr {
t.Errorf("Register() error = %v, wantErr %v", err, tt.wantErr)
}
// If successful, verify provider was registered
if err == nil && tt.provider != nil {
if !r.IsSupported(tt.provider.Type()) {
t.Errorf("provider %s was not registered", tt.provider.Type())
}
}
})
}
}
// TestRegister_Duplicate tests duplicate registration
func TestRegister_Duplicate(t *testing.T) {
r := NewRegistry()
provider := &mockProvider{providerType: "duplicate-test"}
// First registration should succeed
err := r.Register(provider)
if err != nil {
t.Fatalf("first registration failed: %v", err)
}
// Second registration should fail
err = r.Register(provider)
if err != ErrProviderAlreadyRegistered {
t.Errorf("expected ErrProviderAlreadyRegistered, got %v", err)
}
// Count should still be 1
if r.Count() != 1 {
t.Errorf("expected count 1, got %d", r.Count())
}
}
// TestGet tests retrieving providers
func TestGet(t *testing.T) {
r := NewRegistry()
provider := &mockProvider{providerType: "test-get"}
if err := r.Register(provider); err != nil {
t.Fatalf("failed to register provider: %v", err)
}
tests := []struct {
name string
providerType string
wantOK bool
}{
{
name: "existing provider",
providerType: "test-get",
wantOK: true,
},
{
name: "non-existent provider",
providerType: "does-not-exist",
wantOK: false,
},
{
name: "empty provider type",
providerType: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotProvider, gotOK := r.Get(tt.providerType)
if gotOK != tt.wantOK {
t.Errorf("Get() ok = %v, want %v", gotOK, tt.wantOK)
}
if tt.wantOK {
if gotProvider == nil {
t.Error("expected non-nil provider for existing type")
}
if gotProvider.Type() != tt.providerType {
t.Errorf("got provider type %s, want %s", gotProvider.Type(), tt.providerType)
}
} else {
if gotProvider != nil {
t.Error("expected nil provider for non-existent type")
}
}
})
}
}
// TestList tests listing all providers
func TestList(t *testing.T) {
r := NewRegistry()
// Test empty registry
list := r.List()
if len(list) != 0 {
t.Errorf("expected empty list, got %d providers", len(list))
}
// Register multiple providers
providers := []*mockProvider{
{providerType: "provider-c"},
{providerType: "provider-a"},
{providerType: "provider-b"},
}
for _, p := range providers {
if err := r.Register(p); err != nil {
t.Fatalf("failed to register provider %s: %v", p.Type(), err)
}
}
// Get list (should be sorted)
list = r.List()
if len(list) != 3 {
t.Fatalf("expected 3 providers, got %d", len(list))
}
// Verify alphabetical ordering
expectedOrder := []string{"provider-a", "provider-b", "provider-c"}
for i, p := range list {
if p.Type() != expectedOrder[i] {
t.Errorf("list[%d] = %s, want %s", i, p.Type(), expectedOrder[i])
}
}
}
// TestTypes tests listing provider types
func TestTypes(t *testing.T) {
r := NewRegistry()
// Test empty registry
types := r.Types()
if len(types) != 0 {
t.Errorf("expected empty types, got %d", len(types))
}
// Register multiple providers
providers := []*mockProvider{
{providerType: "zebra"},
{providerType: "alpha"},
{providerType: "beta"},
}
for _, p := range providers {
if err := r.Register(p); err != nil {
t.Fatalf("failed to register provider %s: %v", p.Type(), err)
}
}
// Get types (should be sorted)
types = r.Types()
if len(types) != 3 {
t.Fatalf("expected 3 types, got %d", len(types))
}
// Verify alphabetical ordering
expectedOrder := []string{"alpha", "beta", "zebra"}
for i, typ := range types {
if typ != expectedOrder[i] {
t.Errorf("types[%d] = %s, want %s", i, typ, expectedOrder[i])
}
}
}
// TestIsSupported tests checking provider support
func TestIsSupported(t *testing.T) {
r := NewRegistry()
// Register a provider
provider := &mockProvider{providerType: "supported-test"}
if err := r.Register(provider); err != nil {
t.Fatalf("failed to register provider: %v", err)
}
tests := []struct {
name string
providerType string
want bool
}{
{
name: "supported provider",
providerType: "supported-test",
want: true,
},
{
name: "unsupported provider",
providerType: "unsupported",
want: false,
},
{
name: "empty string",
providerType: "",
want: false,
},
{
name: "case sensitivity",
providerType: "SUPPORTED-TEST",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := r.IsSupported(tt.providerType)
if got != tt.want {
t.Errorf("IsSupported(%q) = %v, want %v", tt.providerType, got, tt.want)
}
})
}
}
// TestUnregister tests removing providers
func TestUnregister(t *testing.T) {
r := NewRegistry()
// Register a provider
provider := &mockProvider{providerType: "unregister-test"}
if err := r.Register(provider); err != nil {
t.Fatalf("failed to register provider: %v", err)
}
// Verify it's registered
if !r.IsSupported("unregister-test") {
t.Fatal("provider not registered")
}
// Unregister it
r.Unregister("unregister-test")
// Verify it's gone
if r.IsSupported("unregister-test") {
t.Error("provider still registered after unregister")
}
// Unregister non-existent (should not panic)
r.Unregister("does-not-exist")
// Count should be 0
if r.Count() != 0 {
t.Errorf("expected count 0, got %d", r.Count())
}
}
// TestCount tests counting registered providers
func TestCount(t *testing.T) {
r := NewRegistry()
// Initial count should be 0
if r.Count() != 0 {
t.Errorf("expected count 0, got %d", r.Count())
}
// Register providers
for i := 1; i <= 5; i++ {
provider := &mockProvider{providerType: "test-" + string(rune('a'+i-1))}
if err := r.Register(provider); err != nil {
t.Fatalf("failed to register provider: %v", err)
}
if r.Count() != i {
t.Errorf("after registering %d providers, count = %d", i, r.Count())
}
}
// Unregister one
r.Unregister("test-a")
if r.Count() != 4 {
t.Errorf("after unregistering, count = %d, want 4", r.Count())
}
}
// TestClear tests clearing all providers
func TestClear(t *testing.T) {
r := NewRegistry()
// Register multiple providers
for i := 0; i < 3; i++ {
provider := &mockProvider{providerType: "clear-test-" + string(rune('a'+i))}
if err := r.Register(provider); err != nil {
t.Fatalf("failed to register provider: %v", err)
}
}
// Verify count
if r.Count() != 3 {
t.Fatalf("expected count 3, got %d", r.Count())
}
// Clear registry
r.Clear()
// Verify empty
if r.Count() != 0 {
t.Errorf("after clear, count = %d, want 0", r.Count())
}
// Verify no providers are supported
if r.IsSupported("clear-test-a") {
t.Error("provider still supported after clear")
}
// List should be empty
if len(r.List()) != 0 {
t.Errorf("list not empty after clear, got %d providers", len(r.List()))
}
}
// TestConcurrency tests thread-safe operations
func TestConcurrency(t *testing.T) {
r := NewRegistry()
var wg sync.WaitGroup
// Concurrent registrations
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
provider := &mockProvider{providerType: "concurrent-" + string(rune('a'+n))}
_ = r.Register(provider)
}(i)
}
// Concurrent reads
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = r.List()
_ = r.Types()
_ = r.IsSupported("concurrent-a")
_, _ = r.Get("concurrent-a")
}()
}
wg.Wait()
// Verify final state
if r.Count() != 10 {
t.Errorf("expected 10 providers after concurrent registration, got %d", r.Count())
}
}
// TestRegistry_Operations tests combined operations
func TestRegistry_Operations(t *testing.T) {
r := NewRegistry()
// Start with empty registry
if r.Count() != 0 {
t.Errorf("new registry should be empty")
}
// Register providers
p1 := &mockProvider{providerType: "provider1"}
p2 := &mockProvider{providerType: "provider2"}
p3 := &mockProvider{providerType: "provider3"}
if err := r.Register(p1); err != nil {
t.Fatalf("failed to register p1: %v", err)
}
if err := r.Register(p2); err != nil {
t.Fatalf("failed to register p2: %v", err)
}
if err := r.Register(p3); err != nil {
t.Fatalf("failed to register p3: %v", err)
}
// Verify all are registered
for _, p := range []ProviderPlugin{p1, p2, p3} {
if !r.IsSupported(p.Type()) {
t.Errorf("provider %s not registered", p.Type())
}
retrieved, ok := r.Get(p.Type())
if !ok {
t.Errorf("failed to get provider %s", p.Type())
}
if retrieved.Type() != p.Type() {
t.Errorf("retrieved wrong provider: got %s, want %s", retrieved.Type(), p.Type())
}
}
// List should contain all 3
list := r.List()
if len(list) != 3 {
t.Errorf("list length = %d, want 3", len(list))
}
// Types should contain all 3
types := r.Types()
if len(types) != 3 {
t.Errorf("types length = %d, want 3", len(types))
}
// Unregister one
r.Unregister("provider2")
// Verify count decreased
if r.Count() != 2 {
t.Errorf("after unregister, count = %d, want 2", r.Count())
}
// Verify p2 is gone
if r.IsSupported("provider2") {
t.Error("provider2 still supported after unregister")
}
// Clear all
r.Clear()
// Verify empty
if r.Count() != 0 {
t.Errorf("after clear, count = %d, want 0", r.Count())
}
if len(r.List()) != 0 {
t.Error("list not empty after clear")
}
if len(r.Types()) != 0 {
t.Error("types not empty after clear")
}
}