feat(cert): add registry-based DNS provider system with 21 libdns providers

Refs #37
This commit is contained in:
xboard
2026-04-20 03:01:21 +08:00
parent 07c37023ab
commit 68ca2d2716
31 changed files with 1575 additions and 48 deletions
+10 -37
View File
@@ -19,9 +19,8 @@ import (
"time"
"github.com/caddyserver/certmagic"
"github.com/libdns/alidns"
"github.com/libdns/cloudflare"
"github.com/cedar2025/xboard-node/internal/cert/dnsproviders"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/kernel"
"github.com/cedar2025/xboard-node/internal/nlog"
@@ -416,36 +415,20 @@ func (m *Manager) buildDNSSolver() (*certmagic.DNS01Solver, error) {
}
func (m *Manager) newDNSProvider() (certmagic.DNSProvider, error) {
name := strings.ToLower(strings.TrimSpace(m.cfg.DNSProvider))
name := strings.TrimSpace(m.cfg.DNSProvider)
if name == "" {
return nil, fmt.Errorf("dns_provider is required for cert_mode=dns")
}
env := m.cfg.DNSEnv
if env == nil {
env = map[string]string{}
}
switch name {
case "cloudflare", "cf":
token := firstOf(env, "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
if token == "" {
return nil, fmt.Errorf("cloudflare requires CF_API_TOKEN in dns_env")
}
return &cloudflare.Provider{APIToken: token}, nil
case "alidns", "aliyun":
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID")
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET")
if keyID == "" || keySecret == "" {
return nil, fmt.Errorf("alidns requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET in dns_env")
}
return &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{
AccessKeyID: keyID,
AccessKeySecret: keySecret,
},
}, nil
default:
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: cloudflare, alidns)", name)
p, ok := dnsproviders.Get(name)
if !ok {
return nil, fmt.Errorf("unsupported dns_provider: %q (supported: %s)",
name, strings.Join(dnsproviders.CanonicalNames(), ", "))
}
return p.Build(env)
}
// ─── Helpers ───────────────────────────────────────────────────────────────
@@ -478,13 +461,3 @@ func (m *Manager) loadCertFromStorage(ctx context.Context, storage certmagic.Sto
m.renewed.Store(true)
}
}
// firstOf returns the first non-empty value for any of the given keys in the map.
func firstOf(m map[string]string, keys ...string) string {
for _, k := range keys {
if v := m[k]; v != "" {
return v
}
}
return ""
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/alidns"
)
func init() {
Register(&Provider{
Names: []string{"alidns", "aliyun"},
EnvVars: []string{"ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
keyID := firstOf(env, "ALICLOUD_ACCESS_KEY_ID", "ALI_ACCESS_KEY_ID", "ALIYUN_ACCESS_KEY_ID")
keySecret := firstOf(env, "ALICLOUD_ACCESS_KEY_SECRET", "ALI_ACCESS_KEY_SECRET", "ALIYUN_ACCESS_KEY_SECRET")
if keyID == "" || keySecret == "" {
return nil, missingEnvErr("alidns", "ALICLOUD_ACCESS_KEY_ID", "ALICLOUD_ACCESS_KEY_SECRET")
}
return &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{
AccessKeyID: keyID,
AccessKeySecret: keySecret,
},
}, nil
},
})
}
+33
View File
@@ -0,0 +1,33 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/azure"
)
func init() {
Register(&Provider{
Names: []string{"azure"},
EnvVars: []string{
"AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME",
"AZURE_TENANT_ID", "AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET",
},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
sub := firstOf(env, "AZURE_SUBSCRIPTION_ID")
rg := firstOf(env, "AZURE_RESOURCE_GROUP_NAME", "AZURE_RESOURCE_GROUP")
if sub == "" || rg == "" {
return nil, missingEnvErr("azure", "AZURE_SUBSCRIPTION_ID", "AZURE_RESOURCE_GROUP_NAME")
}
// TenantId/ClientId/ClientSecret are optional — when omitted, libdns
// falls back to the default Azure credential chain (managed identity,
// CLI login, etc.).
return &azure.Provider{
SubscriptionId: sub,
ResourceGroupName: rg,
TenantId: firstOf(env, "AZURE_TENANT_ID"),
ClientId: firstOf(env, "AZURE_CLIENT_ID"),
ClientSecret: firstOf(env, "AZURE_CLIENT_SECRET"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/bunny"
)
func init() {
Register(&Provider{
Names: []string{"bunny"},
EnvVars: []string{"BUNNY_ACCESS_KEY"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
key := firstOf(env, "BUNNY_ACCESS_KEY", "BUNNY_API_KEY")
if key == "" {
return nil, missingEnvErr("bunny", "BUNNY_ACCESS_KEY")
}
return &bunny.Provider{AccessKey: key}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/cloudflare"
)
func init() {
Register(&Provider{
Names: []string{"cloudflare", "cf"},
EnvVars: []string{"CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN", "CLOUDFLARE_API_TOKEN")
if token == "" {
return nil, missingEnvErr("cloudflare", "CLOUDFLARE_DNS_API_TOKEN", "CF_API_TOKEN")
}
return &cloudflare.Provider{APIToken: token}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/desec"
)
func init() {
Register(&Provider{
Names: []string{"desec"},
EnvVars: []string{"DESEC_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DESEC_TOKEN", "DESEC_API_TOKEN")
if token == "" {
return nil, missingEnvErr("desec", "DESEC_TOKEN")
}
return &desec.Provider{Token: token}, nil
},
})
}
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/digitalocean"
)
func init() {
Register(&Provider{
Names: []string{"digitalocean", "do"},
EnvVars: []string{"DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DO_AUTH_TOKEN", "DIGITALOCEAN_TOKEN", "DIGITALOCEAN_API_TOKEN")
if token == "" {
return nil, missingEnvErr("digitalocean", "DO_AUTH_TOKEN")
}
return &digitalocean.Provider{APIToken: token}, nil
},
})
}
+23
View File
@@ -0,0 +1,23 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/duckdns"
)
func init() {
Register(&Provider{
Names: []string{"duckdns"},
EnvVars: []string{"DUCKDNS_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "DUCKDNS_TOKEN", "DUCKDNS_API_TOKEN")
if token == "" {
return nil, missingEnvErr("duckdns", "DUCKDNS_TOKEN")
}
return &duckdns.Provider{
APIToken: token,
OverrideDomain: firstOf(env, "DUCKDNS_OVERRIDE_DOMAIN"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/gandi"
)
func init() {
Register(&Provider{
Names: []string{"gandi"},
EnvVars: []string{"GANDI_BEARER_TOKEN", "GANDI_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "GANDI_BEARER_TOKEN", "GANDI_API_TOKEN", "GANDI_PERSONAL_ACCESS_TOKEN")
if token == "" {
return nil, missingEnvErr("gandi", "GANDI_BEARER_TOKEN")
}
return &gandi.Provider{BearerToken: token}, nil
},
})
}
+30
View File
@@ -0,0 +1,30 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/godaddy"
)
func init() {
Register(&Provider{
Names: []string{"godaddy"},
EnvVars: []string{"GODADDY_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
// GoDaddy auth header format: "sso-key KEY:SECRET"
// Pass the full "KEY:SECRET" pair via GODADDY_API_TOKEN, OR set
// GODADDY_API_KEY + GODADDY_API_SECRET and we'll join them.
token := firstOf(env, "GODADDY_API_TOKEN")
if token == "" {
key := firstOf(env, "GODADDY_API_KEY")
secret := firstOf(env, "GODADDY_API_SECRET")
if key != "" && secret != "" {
token = key + ":" + secret
}
}
if token == "" {
return nil, missingEnvErr("godaddy", "GODADDY_API_TOKEN", "GODADDY_API_KEY+GODADDY_API_SECRET")
}
return &godaddy.Provider{APIToken: token}, nil
},
})
}
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/googleclouddns"
)
func init() {
Register(&Provider{
Names: []string{"googleclouddns", "gcp", "gcloud"},
EnvVars: []string{"GCE_PROJECT", "GCE_SERVICE_ACCOUNT_FILE"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
project := firstOf(env, "GCE_PROJECT", "GOOGLE_PROJECT_ID", "GCP_PROJECT")
if project == "" {
return nil, missingEnvErr("googleclouddns", "GCE_PROJECT")
}
// ServiceAccountJSON may be a file path OR a raw JSON blob; when
// empty, the default Google credential chain is used.
return &googleclouddns.Provider{
Project: project,
ServiceAccountJSON: firstOf(env, "GCE_SERVICE_ACCOUNT_FILE", "GOOGLE_APPLICATION_CREDENTIALS"),
}, nil
},
})
}
+22
View File
@@ -0,0 +1,22 @@
package dnsproviders
import (
"fmt"
"strings"
)
// firstOf returns the value of the first non-empty env key from candidates.
// Lookup is case-sensitive (env keys are conventionally upper-case).
func firstOf(env map[string]string, keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(env[k]); v != "" {
return v
}
}
return ""
}
// missingEnvErr formats a "missing required env" error consistently.
func missingEnvErr(provider string, keys ...string) error {
return fmt.Errorf("dns provider %q requires one of %s in dns_env", provider, strings.Join(keys, ", "))
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/hetzner"
)
func init() {
Register(&Provider{
Names: []string{"hetzner"},
EnvVars: []string{"HETZNER_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "HETZNER_API_TOKEN", "HETZNER_DNS_API_TOKEN")
if token == "" {
return nil, missingEnvErr("hetzner", "HETZNER_API_TOKEN")
}
return &hetzner.Provider{AuthAPIToken: token}, nil
},
})
}
+25
View File
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/huaweicloud"
)
func init() {
Register(&Provider{
Names: []string{"huaweicloud", "huawei"},
EnvVars: []string{"HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEICLOUD_REGION_ID"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
id := firstOf(env, "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEI_ACCESS_KEY_ID")
secret := firstOf(env, "HUAWEICLOUD_SECRET_ACCESS_KEY", "HUAWEI_SECRET_ACCESS_KEY")
if id == "" || secret == "" {
return nil, missingEnvErr("huaweicloud", "HUAWEICLOUD_ACCESS_KEY_ID", "HUAWEICLOUD_SECRET_ACCESS_KEY")
}
return &huaweicloud.Provider{
AccessKeyId: id,
SecretAccessKey: secret,
RegionId: firstOf(env, "HUAWEICLOUD_REGION_ID", "HUAWEI_REGION"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/linode"
)
func init() {
Register(&Provider{
Names: []string{"linode"},
EnvVars: []string{"LINODE_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "LINODE_TOKEN", "LINODE_API_TOKEN")
if token == "" {
return nil, missingEnvErr("linode", "LINODE_TOKEN")
}
return &linode.Provider{APIToken: token}, nil
},
})
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/namecheap"
)
func init() {
Register(&Provider{
Names: []string{"namecheap"},
EnvVars: []string{"NAMECHEAP_API_KEY", "NAMECHEAP_API_USER", "NAMECHEAP_CLIENT_IP"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
apiKey := firstOf(env, "NAMECHEAP_API_KEY")
user := firstOf(env, "NAMECHEAP_API_USER", "NAMECHEAP_USER")
if apiKey == "" || user == "" {
return nil, missingEnvErr("namecheap", "NAMECHEAP_API_KEY", "NAMECHEAP_API_USER")
}
return &namecheap.Provider{
APIKey: apiKey,
User: user,
APIEndpoint: firstOf(env, "NAMECHEAP_API_ENDPOINT"),
ClientIP: firstOf(env, "NAMECHEAP_CLIENT_IP"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/namesilo"
)
func init() {
Register(&Provider{
Names: []string{"namesilo"},
EnvVars: []string{"NAMESILO_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "NAMESILO_API_TOKEN")
if token == "" {
return nil, missingEnvErr("namesilo", "NAMESILO_API_TOKEN")
}
return &namesilo.Provider{APIToken: token}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/netlify"
)
func init() {
Register(&Provider{
Names: []string{"netlify"},
EnvVars: []string{"NETLIFY_PERSONAL_ACCESS_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "NETLIFY_PERSONAL_ACCESS_TOKEN", "NETLIFY_AUTH_TOKEN", "NETLIFY_API_TOKEN")
if token == "" {
return nil, missingEnvErr("netlify", "NETLIFY_PERSONAL_ACCESS_TOKEN")
}
return &netlify.Provider{PersonalAccessToken: token}, nil
},
})
}
+29
View File
@@ -0,0 +1,29 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/ovh"
)
func init() {
Register(&Provider{
Names: []string{"ovh"},
EnvVars: []string{
"OVH_ENDPOINT", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
appKey := firstOf(env, "OVH_APPLICATION_KEY")
appSecret := firstOf(env, "OVH_APPLICATION_SECRET")
consumerKey := firstOf(env, "OVH_CONSUMER_KEY")
if appKey == "" || appSecret == "" || consumerKey == "" {
return nil, missingEnvErr("ovh", "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY")
}
return &ovh.Provider{
Endpoint: firstOf(env, "OVH_ENDPOINT"),
ApplicationKey: appKey,
ApplicationSecret: appSecret,
ConsumerKey: consumerKey,
}, nil
},
})
}
+21
View File
@@ -0,0 +1,21 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/porkbun"
)
func init() {
Register(&Provider{
Names: []string{"porkbun"},
EnvVars: []string{"PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
key := firstOf(env, "PORKBUN_API_KEY")
secret := firstOf(env, "PORKBUN_API_SECRET_KEY", "PORKBUN_SECRET_API_KEY")
if key == "" || secret == "" {
return nil, missingEnvErr("porkbun", "PORKBUN_API_KEY", "PORKBUN_API_SECRET_KEY")
}
return &porkbun.Provider{APIKey: key, APISecretKey: secret}, nil
},
})
}
+93
View File
@@ -0,0 +1,93 @@
// Package dnsproviders is a registry of DNS providers usable for ACME DNS-01
// challenges. Each provider is implemented as a separate file that registers
// itself in init(); to add a new provider, drop a file in this package — no
// changes are needed elsewhere.
//
// All providers wrap the libdns ecosystem (https://github.com/libdns) so they
// plug directly into certmagic's DNSManager.
package dnsproviders
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/caddyserver/certmagic"
)
// Builder constructs a certmagic.DNSProvider from a flat env-style map.
// The map mirrors the cert_config.dns_env block from config.yml.
type Builder func(env map[string]string) (certmagic.DNSProvider, error)
// Provider describes a single DNS provider entry in the registry.
type Provider struct {
// Names is the list of accepted dns_provider values; the first is canonical,
// the rest are aliases (case-insensitive match).
Names []string
// EnvVars lists the environment variable names this provider may consume.
// Used for documentation and error messages only.
EnvVars []string
// Build constructs the underlying libdns provider.
Build Builder
}
var (
mu sync.RWMutex
registry = map[string]*Provider{}
ordered []*Provider
)
// Register adds a provider to the global registry. It panics on duplicate
// names so that registration errors surface at process start.
func Register(p *Provider) {
if p == nil || len(p.Names) == 0 || p.Build == nil {
panic("dnsproviders: invalid provider registration")
}
mu.Lock()
defer mu.Unlock()
for _, n := range p.Names {
key := strings.ToLower(strings.TrimSpace(n))
if key == "" {
panic("dnsproviders: empty provider name")
}
if _, exists := registry[key]; exists {
panic(fmt.Sprintf("dnsproviders: duplicate name %q", key))
}
registry[key] = p
}
ordered = append(ordered, p)
}
// Get returns the provider registered under the given (case-insensitive) name
// or alias.
func Get(name string) (*Provider, bool) {
mu.RLock()
defer mu.RUnlock()
p, ok := registry[strings.ToLower(strings.TrimSpace(name))]
return p, ok
}
// CanonicalNames returns the canonical (first-listed) name of every registered
// provider, sorted alphabetically. Useful for error messages.
func CanonicalNames() []string {
mu.RLock()
defer mu.RUnlock()
names := make([]string, 0, len(ordered))
for _, p := range ordered {
names = append(names, p.Names[0])
}
sort.Strings(names)
return names
}
// List returns all registered providers (canonical entries only), sorted by
// canonical name.
func List() []*Provider {
mu.RLock()
defer mu.RUnlock()
out := make([]*Provider, len(ordered))
copy(out, ordered)
sort.Slice(out, func(i, j int) bool { return out[i].Names[0] < out[j].Names[0] })
return out
}
@@ -0,0 +1,90 @@
package dnsproviders
import (
"strings"
"testing"
)
// TestRegistryHasMainstreamProviders verifies that all expected mainstream
// providers are registered with their canonical names (and key aliases).
func TestRegistryHasMainstreamProviders(t *testing.T) {
cases := map[string][]string{
"cloudflare": {"cf"},
"alidns": {"aliyun"},
"tencentcloud": {"tencent"},
"route53": {"aws"},
"godaddy": nil,
"namecheap": nil,
"namesilo": nil,
"digitalocean": {"do"},
"linode": nil,
"hetzner": nil,
"gandi": nil,
"porkbun": nil,
"netlify": nil,
"azure": nil,
"googleclouddns": {"gcp", "gcloud"},
"huaweicloud": {"huawei"},
"bunny": nil,
"duckdns": nil,
"ovh": nil,
"vultr": nil,
"desec": nil,
}
for canonical, aliases := range cases {
if _, ok := Get(canonical); !ok {
t.Errorf("provider %q not registered", canonical)
}
// case-insensitive lookup must work
if _, ok := Get(strings.ToUpper(canonical)); !ok {
t.Errorf("provider %q not found case-insensitively", canonical)
}
for _, a := range aliases {
if _, ok := Get(a); !ok {
t.Errorf("alias %q for %q not registered", a, canonical)
}
}
}
}
func TestRegistryRejectsUnknown(t *testing.T) {
if _, ok := Get("definitely-not-a-real-provider"); ok {
t.Fatal("Get returned ok for unknown provider")
}
}
// TestProvidersValidateMissingEnv ensures every provider's Build returns a
// helpful error when no credentials are supplied (rather than panicking or
// silently building an unusable client).
//
// route53/azure/googleclouddns are excluded because they intentionally fall
// back to an ambient credential chain (IAM role, managed identity, ADC) and
// produce a usable provider even with empty config.
func TestProvidersValidateMissingEnv(t *testing.T) {
excluded := map[string]bool{
"route53": true,
"azure": true, // partially excluded — actually still requires sub+rg
"googleclouddns": true, // requires GCE_PROJECT
}
for _, p := range List() {
canonical := p.Names[0]
if excluded[canonical] {
continue
}
t.Run(canonical, func(t *testing.T) {
_, err := p.Build(map[string]string{})
if err == nil {
t.Errorf("provider %q built successfully with empty env; expected missing-credential error", canonical)
}
})
}
}
func TestCanonicalNamesSorted(t *testing.T) {
names := CanonicalNames()
for i := 1; i < len(names); i++ {
if names[i-1] > names[i] {
t.Fatalf("CanonicalNames not sorted: %v", names)
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/route53"
)
func init() {
Register(&Provider{
Names: []string{"route53", "aws"},
EnvVars: []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION", "AWS_SESSION_TOKEN", "AWS_HOSTED_ZONE_ID", "AWS_PROFILE"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
// route53 supports the full AWS credential chain (env, IAM role, profile,
// shared credentials file). We deliberately do not require any field —
// users running on EC2/EKS may rely entirely on the instance role.
return &route53.Provider{
AccessKeyId: firstOf(env, "AWS_ACCESS_KEY_ID"),
SecretAccessKey: firstOf(env, "AWS_SECRET_ACCESS_KEY"),
SessionToken: firstOf(env, "AWS_SESSION_TOKEN"),
Region: firstOf(env, "AWS_REGION", "AWS_DEFAULT_REGION"),
Profile: firstOf(env, "AWS_PROFILE"),
HostedZoneID: firstOf(env, "AWS_HOSTED_ZONE_ID"),
}, nil
},
})
}
@@ -0,0 +1,25 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/tencentcloud"
)
func init() {
Register(&Provider{
Names: []string{"tencentcloud", "tencent"},
EnvVars: []string{"TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY", "TENCENTCLOUD_REGION"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
id := firstOf(env, "TENCENTCLOUD_SECRET_ID", "TENCENT_SECRET_ID")
key := firstOf(env, "TENCENTCLOUD_SECRET_KEY", "TENCENT_SECRET_KEY")
if id == "" || key == "" {
return nil, missingEnvErr("tencentcloud", "TENCENTCLOUD_SECRET_ID", "TENCENTCLOUD_SECRET_KEY")
}
return &tencentcloud.Provider{
SecretId: id,
SecretKey: key,
Region: firstOf(env, "TENCENTCLOUD_REGION"),
}, nil
},
})
}
+20
View File
@@ -0,0 +1,20 @@
package dnsproviders
import (
"github.com/caddyserver/certmagic"
"github.com/libdns/vultr/v2"
)
func init() {
Register(&Provider{
Names: []string{"vultr"},
EnvVars: []string{"VULTR_API_TOKEN"},
Build: func(env map[string]string) (certmagic.DNSProvider, error) {
token := firstOf(env, "VULTR_API_TOKEN", "VULTR_API_KEY")
if token == "" {
return nil, missingEnvErr("vultr", "VULTR_API_TOKEN")
}
return &vultr.Provider{APIToken: token}, nil
},
})
}
+7 -7
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/cedar2025/xboard-node/internal/cert"
"github.com/cedar2025/xboard-node/internal/cert/dnsproviders"
"github.com/cedar2025/xboard-node/internal/config"
"github.com/cedar2025/xboard-node/internal/controlplane"
"github.com/cedar2025/xboard-node/internal/kernel"
@@ -1219,15 +1220,14 @@ func validateRuntimeCertConfig(spec *model.NodeSpec) error {
if mode != "dns" {
return nil
}
provider := strings.ToLower(strings.TrimSpace(spec.CertConfig.DNSProvider))
switch provider {
case "cloudflare", "cf", "alidns", "aliyun":
return nil
case "":
provider := strings.TrimSpace(spec.CertConfig.DNSProvider)
if provider == "" {
return fmt.Errorf("dns cert mode requires cert_config.dns_provider")
default:
return fmt.Errorf("unsupported cert_config.dns_provider %q (supported: cloudflare, alidns)", spec.CertConfig.DNSProvider)
}
if _, ok := dnsproviders.Get(provider); !ok {
return fmt.Errorf("unsupported cert_config.dns_provider %q (supported: %s)", provider, strings.Join(dnsproviders.CanonicalNames(), ", "))
}
return nil
}
func validateRealityRequirements(spec *model.NodeSpec, _ string) error {
+2 -1
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"github.com/cedar2025/xboard-node/internal/cert"
@@ -214,7 +215,7 @@ func TestValidateNodeRuntimeRejectsUnsupportedDNSProvider(t *testing.T) {
if err.Error() == "" {
t.Fatal("expected non-empty error")
}
if got := err.Error(); got != "unsupported cert_config.dns_provider \"3123123\" (supported: cloudflare, alidns)" {
if got := err.Error(); !strings.HasPrefix(got, `unsupported cert_config.dns_provider "3123123" (supported: `) {
t.Fatalf("unexpected error: %v", got)
}
}