Skip to content

Commit 3f47dc8

Browse files
committed
MEDIUM: Add ACME dns-01 challenge support
With this commit, dataplaneapi can use libdns to resolve dns-01 challenges for HAProxy. To use it, make sure you are using master-worker mode, configure HAProxy's acme section to use dns-01 challenge with the appropriate acme-provider (dns challenge provider) and acme-vars. The list of supported DNS providers is in acme/dns01-providers.txt.
1 parent 086f7fb commit 3f47dc8

File tree

18 files changed

+1206
-16
lines changed

18 files changed

+1206
-16
lines changed

.aspell.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ allowed:
4343
- const
4444
- cpu
4545
- crd
46+
- crl
4647
- cronjob
4748
- crt
4849
- cve
@@ -125,9 +126,11 @@ allowed:
125126
- ktls
126127
- kubebuilder
127128
- kubernetes
129+
- libdns
128130
- lifecycle
129131
- linter
130132
- linters
133+
- logrus
131134
- lowercased
132135
- lookups
133136
- lts
@@ -146,6 +149,8 @@ allowed:
146149
- mutexes
147150
- namespace
148151
- namespaces
152+
- newcert
153+
- ocsp
149154
- oidc
150155
- omitempty
151156
- openapi
@@ -199,6 +204,7 @@ allowed:
199204
- tls
200205
- tooltip
201206
- tsconfig
207+
- txt
202208
- typings
203209
- ubuntu
204210
- uniq

acme/constructor.go

Lines changed: 89 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

acme/constructor.tmpl

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Code generated from 'constructor.tmpl'; DO NOT EDIT.
2+
3+
package acme
4+
5+
import (
6+
"fmt"
7+
8+
jsoniter "github.com/json-iterator/go"
9+
{{- range $mod := .}}
10+
"{{$mod}}"
11+
{{- end}}
12+
)
13+
14+
func NewDNSProvider(name string, params map[string]any) (DNSProvider, error) {
15+
var prov DNSProvider
16+
17+
switch name {
18+
{{- range $mod := .}}
19+
case "{{basename $mod}}":
20+
prov = &{{basename $mod}}.Provider{}
21+
{{- end}}
22+
default:
23+
return nil, fmt.Errorf("invalid DNS provider name: '%s'", name)
24+
}
25+
26+
jsoni := jsoniter.ConfigCompatibleWithStandardLibrary
27+
js, err := jsoni.Marshal(params)
28+
if err != nil {
29+
return nil, fmt.Errorf("failed to marshal params for DNS provider %s: %w", name, err)
30+
}
31+
if err = jsoni.Unmarshal(js, prov); err != nil {
32+
return nil, fmt.Errorf("invalid params for DNS provider %s: %w", name, err)
33+
}
34+
35+
return prov, nil
36+
}

acme/dns01-providers.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/libdns/azure
2+
github.com/libdns/cloudflare
3+
github.com/libdns/cloudns
4+
github.com/libdns/digitalocean
5+
github.com/haproxytech/dataplaneapi/acme/exec
6+
github.com/libdns/gandi
7+
github.com/libdns/godaddy
8+
github.com/libdns/googleclouddns
9+
github.com/libdns/hetzner
10+
github.com/libdns/infomaniak
11+
github.com/libdns/inwx
12+
github.com/libdns/ionos
13+
github.com/libdns/linode
14+
github.com/libdns/namecheap
15+
github.com/libdns/netcup
16+
github.com/libdns/ovh
17+
github.com/libdns/porkbun
18+
github.com/libdns/rfc2136
19+
//github.com/libdns/route53 req. go1.25
20+
github.com/libdns/scaleway
21+
github.com/libdns/vultr/v2

acme/dns01.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright 2025 HAProxy Technologies
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
package acme
17+
18+
//go:generate go run gen_constructor.go -i dns01-providers.txt -t constructor.tmpl -o constructor.go
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"strings"
24+
"time"
25+
26+
"github.com/libdns/libdns"
27+
)
28+
29+
// TTL of the temporary DNS record used for DNS-01 validation.
30+
const DefaultTTL = 30 * time.Second
31+
32+
// DNSProvider defines the operations required for dns-01 challenges.
33+
type DNSProvider interface {
34+
libdns.RecordAppender
35+
libdns.RecordDeleter
36+
}
37+
38+
// A DNS01Solver uses a DNSProvider to actually solve the challenge.
39+
type DNS01Solver struct {
40+
provider DNSProvider
41+
TTL time.Duration
42+
}
43+
44+
func NewDNS01Solver(name string, params map[string]any, ttl ...time.Duration) (*DNS01Solver, error) {
45+
prov, err := NewDNSProvider(name, params)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
recordTTL := DefaultTTL
51+
if len(ttl) > 0 {
52+
recordTTL = ttl[0]
53+
}
54+
55+
return &DNS01Solver{provider: prov, TTL: recordTTL}, nil
56+
}
57+
58+
// Present creates the DNS TXT record for the given ACME challenge.
59+
func (s *DNS01Solver) Present(ctx context.Context, domain, zone, keyAuth string) error {
60+
rec := makeRecord(domain, keyAuth, s.TTL)
61+
62+
if zone == "" {
63+
zone = guessZone(domain)
64+
} else {
65+
zone = rooted(zone)
66+
}
67+
68+
results, err := s.provider.AppendRecords(ctx, zone, []libdns.Record{rec})
69+
if err != nil {
70+
return fmt.Errorf("adding temporary record for zone %q: %w", zone, err)
71+
}
72+
if len(results) != 1 {
73+
return fmt.Errorf("expected one record, got %d: %v", len(results), results)
74+
}
75+
76+
return nil
77+
}
78+
79+
// CleanUp deletes the DNS TXT record created in Present().
80+
func (s *DNS01Solver) CleanUp(ctx context.Context, domain, zone, keyAuth string) error {
81+
rr := makeRecord(domain, keyAuth, s.TTL)
82+
83+
if zone == "" {
84+
zone = guessZone(domain)
85+
} else {
86+
zone = rooted(zone)
87+
}
88+
89+
_, err := s.provider.DeleteRecords(ctx, zone, []libdns.Record{rr})
90+
if err != nil {
91+
return fmt.Errorf("deleting temporary record for name %q in zone %q: %w", zone, rr, err)
92+
}
93+
94+
return nil
95+
}
96+
97+
// Assemble a TXT Record suited for DNS-01 challenges.
98+
func makeRecord(fqdn, keyAuth string, ttl time.Duration) libdns.RR {
99+
return libdns.RR{
100+
Type: "TXT",
101+
Name: "_acme-challenge." + trimWildcard(fqdn),
102+
Data: keyAuth,
103+
TTL: ttl,
104+
}
105+
}
106+
107+
// Extract the root zone for a domain in case the user did not provide it.
108+
//
109+
// This simplistic algorithm will only work for simple cases. The correct
110+
// way to do this would be to do an SOA request on the FQDN, but since
111+
// dataplaneapi may not use the right resolvers (as configured in haproxy.cfg)
112+
// it is better to avoid doing any DNS request.
113+
func guessZone(fqdn string) string {
114+
fqdn = trimWildcard(fqdn)
115+
parts := make([]string, 0, 8)
116+
strings.SplitSeq(fqdn, ".")(func(part string) bool {
117+
if part != "" {
118+
parts = append(parts, part)
119+
}
120+
return true
121+
})
122+
123+
n := len(parts)
124+
if n < 3 {
125+
return rooted(fqdn)
126+
}
127+
return rooted(strings.Join(parts[n-2:], "."))
128+
}
129+
130+
// Remove the wildcard from a domain so it can be used in a record name.
131+
func trimWildcard(fqdn string) string {
132+
fqdn = strings.TrimSpace(fqdn)
133+
return strings.TrimPrefix(fqdn, "*.")
134+
}
135+
136+
// Ensures a domain name has its final dot (the root zone).
137+
func rooted(domain string) string {
138+
if !strings.HasSuffix(domain, ".") {
139+
domain += "."
140+
}
141+
return domain
142+
}

0 commit comments

Comments
 (0)