dns: add libdns provider registry

Curated cloudflare, gigahost, hetzner, powerdns and rfc2136.
Settings decode via json tags; env per field, <field>_path for secrets.

Updates #2137
This commit is contained in:
Kristoffer Dalby committed 2026-10-18 20:53:18 +02:00
1 parent a777969027
commit 24cf63294c
5 files changed
+320 -3

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
{
"vendor": {
"goModSum": "sha256-aq3f+UFdqP7KURd92D2IvlViCkYouZtK/6UzRyNTBgE=",
"sri": "sha256-EZ6+6LYzxcc48FwNddi46h6kaHur108O1qv1TIXfRzY="
"goModSum": "sha256-oSB3v9cn+i6QxfOQGzsjCFjVgSOTrpp9mjR7mJmzQYs=",
"sri": "sha256-KpiRQbHBAPp2BB+NodeSih2/WIlg52dfoOpYxbrVmBI="
}
}
+11 -1
View File
@@ -19,9 +19,16 @@ require (
github.com/go-chi/metrics v0.1.1
github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-cmp v0.7.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jagottsicher/termcolor v1.0.2
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974
github.com/libdns/cloudflare v0.2.2
github.com/libdns/hetzner/v2 v2.0.1
github.com/libdns/libdns v1.1.1
github.com/libdns/powerdns v0.1.4
github.com/libdns/rfc2136 v1.0.1
github.com/oapi-codegen/runtime v1.7.0
github.com/oauth2-proxy/mockoidc v0.0.0-20240214162133-caebfff84d25
github.com/ory/dockertest/v3 v3.12.0
@@ -135,7 +142,6 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go4org/hashtriemap v0.0.0-20260925222741-44e5305f85d9 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
@@ -151,6 +157,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/hetznercloud/hcloud-go/v2 v2.27.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -163,6 +170,7 @@ require (
github.com/kamstrup/intmap v0.5.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
@@ -170,7 +178,9 @@ require (
github.com/mattn/go-runewidth v0.0.31 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.7.0 // indirect
github.com/miekg/dns v1.1.73 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mittwald/go-powerdns v0.6.6 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.1 // indirect
+24
View File
@@ -259,12 +259,16 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/hetznercloud/hcloud-go/v2 v2.27.0 h1:SOGpAP3kQ6+aevB4Hxr63ukNsdYJjHhuWNB1C3NsiJo=
github.com/hetznercloud/hcloud-go/v2 v2.27.0/go.mod h1:OVlbjfoEuvNPI8ji3Sm/jPkjOxO7MKEiPyfctZ0R8jw=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
@@ -311,12 +315,27 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed h1:Jc+HE4/e+TK1H40RzQSa6ZR7Yu2Mx2brH+O4/uWsCgo=
github.com/kradalby/gigahost-go/client v0.0.0-20261014144355-025b38b667ed/go.mod h1:sZmT6pTI0BarSbOc1CvUxwpxLl5SSIXhitioezuVmD0=
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974 h1:G3F633EXxBI7ls+F8Jet7geawmCsqZMzyLEp+ZmS4aI=
github.com/kradalby/libdns-gigahost v0.0.0-20261014145029-4c03bc63b974/go.mod h1:cTFSzETMrxz9nLduiEMrI1VuLLohAg8AA56kJjw25Vw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI=
github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60=
github.com/libdns/hetzner/v2 v2.0.1 h1:phsNc9er8JbVOCsytLG7VuMbCdTPsv+WWwNX1WCbk4Q=
github.com/libdns/hetzner/v2 v2.0.1/go.mod h1:IkEcr62KG0rXYhs2K5VWCG4SFPUQWuI1SzlpXBz9amk=
github.com/libdns/libdns v1.0.0-beta.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libdns/powerdns v0.1.4 h1:QdQ+FL2t5ky2UYiWSodbz2HOwQkCWpd+WGa4OoWGpBk=
github.com/libdns/powerdns v0.1.4/go.mod h1:kTRi2e4sCcValWW6iWwyfcvFXvlxl1mn83vDAgq8bhA=
github.com/libdns/rfc2136 v1.0.1 h1:aiztZgzI2cd9FAtBNPILz01mQcZs1jMqJ467KKI4UQ0=
github.com/libdns/rfc2136 v1.0.1/go.mod h1:Uf4niCfXVgiMgwUrkPdIa5/sqLFdjVhkZj1ZfFAuSq4=
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
@@ -340,6 +359,8 @@ github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mittwald/go-powerdns v0.6.6 h1:yQcuszhl98+jJgELjD5ecfxCQWoshhnArexpwrwQxLY=
github.com/mittwald/go-powerdns v0.6.6/go.mod h1:adWJ860laOgm14afg+7V0nCa5NQT37oEYe2HRhoS/CA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
@@ -358,6 +379,7 @@ github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
@@ -627,6 +649,8 @@ google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/h2non/gock.v1 v1.0.14 h1:fTeu9fcUvSnLNacYvYI54h+1/XEteDyHvrVCZEEEYNM=
gopkg.in/h2non/gock.v1 v1.0.14/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+157
View File
@@ -0,0 +1,157 @@
package dns
import (
"errors"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"github.com/go-viper/mapstructure/v2"
gigahost "github.com/kradalby/libdns-gigahost"
"github.com/libdns/cloudflare"
"github.com/libdns/hetzner/v2"
"github.com/libdns/libdns"
"github.com/libdns/powerdns"
"github.com/libdns/rfc2136"
)
// Provider writes ACME DNS-01 challenge records into a zone.
//
// It is append and delete only: a wildcard order puts two values on the
// same name, so SetRecords would replace the value the CA is validating.
type Provider interface {
libdns.RecordAppender
libdns.RecordDeleter
}
// providers is the compiled-in provider registry. Providers are decoded
// from configuration through their json tags, so adding one is one line
// plus its module; the set is curated because each provider is a
// dependency to keep current and its quirks become ours.
var providers = map[string]func() Provider{
"cloudflare": func() Provider { return new(cloudflare.Provider) },
"gigahost": func() Provider { return new(gigahost.Provider) },
"hetzner": func() Provider { return new(hetzner.Provider) },
"powerdns": func() Provider { return new(powerdns.Provider) },
"rfc2136": func() Provider { return new(rfc2136.Provider) },
}
var (
ErrUnknownProvider = errors.New("unknown DNS provider")
ErrProviderConfig = errors.New("invalid DNS provider configuration")
)
// pathSuffix marks a setting whose value is read from a file, keeping
// secrets out of the configuration file.
const pathSuffix = "_path"
// ProviderNames returns the names of the compiled-in providers.
func ProviderNames() []string {
return slices.Sorted(maps.Keys(providers))
}
// NewProvider builds the named provider from its settings.
//
// settings holds the provider's keys as written in the configuration
// file. get looks up a single key and is consulted for every field of
// the provider, because environment variables only reach individual
// keys, never a map. Any string field can instead be read from a file
// named by "<field>_path". Unknown keys are an error so a typo does not
// silently leave a credential empty.
func NewProvider(
name string,
settings map[string]any,
get func(key string) (any, bool),
) (Provider, error) {
newProvider, ok := providers[strings.ToLower(name)]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownProvider, name)
}
provider := newProvider()
input := make(map[string]any, len(settings))
for k, v := range settings {
input[strings.ToLower(k)] = v
}
delete(input, "name")
fields := jsonFields(provider)
for tag, kind := range fields {
if v, ok := get(tag); ok {
input[tag] = v
}
pathKey := tag + pathSuffix
if _, isField := fields[pathKey]; isField || kind != reflect.String {
continue
}
if v, ok := get(pathKey); ok {
input[pathKey] = v
}
path, ok := input[pathKey]
if !ok {
continue
}
if _, both := input[tag]; both {
return nil, fmt.Errorf("%w: %s and %s are mutually exclusive", ErrProviderConfig, tag, pathKey)
}
value, err := os.ReadFile(os.ExpandEnv(fmt.Sprint(path)))
if err != nil {
return nil, fmt.Errorf("%w: reading %s: %w", ErrProviderConfig, pathKey, err)
}
input[tag] = strings.TrimSpace(string(value))
delete(input, pathKey)
}
// Every provider needs a credential or a server: a block with only a
// name is a secret that was not set, and would fail at the first
// certificate instead of at startup.
if len(input) == 0 {
return nil, fmt.Errorf("%w for %s: no settings", ErrProviderConfig, name)
}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
TagName: "json",
WeaklyTypedInput: true,
ErrorUnused: true,
Result: provider,
})
if err != nil {
return nil, fmt.Errorf("creating decoder: %w", err)
}
err = decoder.Decode(input)
if err != nil {
return nil, fmt.Errorf("%w for %s: %w", ErrProviderConfig, name, err)
}
return provider, nil
}
// jsonFields returns the json key and kind of each configurable field of
// the provider struct.
func jsonFields(provider Provider) map[string]reflect.Kind {
t := reflect.TypeOf(provider).Elem()
fields := make(map[string]reflect.Kind, t.NumField())
for f := range t.Fields() {
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if !f.IsExported() || name == "" || name == "-" {
continue
}
fields[name] = f.Type.Kind()
}
return fields
}
+126
View File
@@ -0,0 +1,126 @@
package dns
import (
"os"
"path/filepath"
"testing"
gigahost "github.com/kradalby/libdns-gigahost"
"github.com/libdns/cloudflare"
"github.com/libdns/hetzner/v2"
"github.com/libdns/powerdns"
"github.com/libdns/rfc2136"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func noEnv(string) (any, bool) { return nil, false }
func TestNewProvider(t *testing.T) {
secret := filepath.Join(t.TempDir(), "token")
require.NoError(t, os.WriteFile(secret, []byte("from-file\n"), 0o600))
tests := []struct {
name string
provider string
settings map[string]any
want Provider
wantErr error
}{
{
name: "cloudflare from settings",
provider: "cloudflare",
settings: map[string]any{"name": "cloudflare", "api_token": "tok"},
want: &cloudflare.Provider{APIToken: "tok"},
},
{
name: "name is case insensitive",
provider: "CloudFlare",
settings: map[string]any{"api_token": "tok"},
want: &cloudflare.Provider{APIToken: "tok"},
},
{
name: "rfc2136",
provider: "rfc2136",
settings: map[string]any{
"server": "ns1.example.com:53",
"key_name": "headscale",
"key_alg": "hmac-sha256",
"key": "c2VjcmV0",
},
want: &rfc2136.Provider{
Server: "ns1.example.com:53",
KeyName: "headscale",
KeyAlg: "hmac-sha256",
Key: "c2VjcmV0",
},
},
{
name: "secret from path",
provider: "cloudflare",
settings: map[string]any{"api_token_path": secret},
want: &cloudflare.Provider{APIToken: "from-file"},
},
{
name: "value and path both set",
provider: "cloudflare",
settings: map[string]any{"api_token": "tok", "api_token_path": secret},
wantErr: ErrProviderConfig,
},
{
name: "unreadable path",
provider: "cloudflare",
settings: map[string]any{"api_token_path": filepath.Join(t.TempDir(), "missing")},
wantErr: ErrProviderConfig,
},
{
name: "unknown key",
provider: "cloudflare",
settings: map[string]any{"auth_token": "tok"},
wantErr: ErrProviderConfig,
},
{
name: "unknown provider",
provider: "route53",
wantErr: ErrUnknownProvider,
},
{
name: "gigahost",
provider: "gigahost",
settings: map[string]any{"api_key_path": secret},
want: &gigahost.Provider{APIKey: "from-file"},
},
{
name: "hetzner",
provider: "hetzner",
settings: map[string]any{"api_token_path": secret},
want: &hetzner.Provider{APIToken: "from-file"},
},
{
name: "powerdns",
provider: "powerdns",
settings: map[string]any{"server_url": "https://pdns.example.com", "api_token": "tok"},
want: &powerdns.Provider{ServerURL: "https://pdns.example.com", APIToken: "tok"},
},
{
name: "only a name",
provider: "gigahost",
settings: map[string]any{"name": "gigahost"},
wantErr: ErrProviderConfig,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewProvider(tt.provider, tt.settings, noEnv)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}