chore: clean .gitignore cache
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user