mittwald.go raw
1 // Package mittwald implements a DNS provider for solving the DNS-01 challenge using Mittwald.
2 package mittwald
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "sync"
10 "time"
11
12 "github.com/go-acme/lego/v4/challenge"
13 "github.com/go-acme/lego/v4/challenge/dns01"
14 "github.com/go-acme/lego/v4/platform/config/env"
15 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
16 "github.com/go-acme/lego/v4/providers/dns/mittwald/internal"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "MITTWALD_"
22
23 EnvToken = envNamespace + "TOKEN"
24
25 EnvTTL = envNamespace + "TTL"
26 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
27 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
28 EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL"
29 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
30 )
31
32 const minTTL = 300
33
34 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
35
36 // Config is used to configure the creation of the DNSProvider.
37 type Config struct {
38 Token string
39 TTL int
40 PropagationTimeout time.Duration
41 PollingInterval time.Duration
42 SequenceInterval time.Duration
43 HTTPClient *http.Client
44 }
45
46 // NewDefaultConfig returns a default configuration for the DNSProvider.
47 func NewDefaultConfig() *Config {
48 return &Config{
49 TTL: env.GetOrDefaultInt(EnvTTL, minTTL),
50 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
51 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 10*time.Second),
52 SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, 2*time.Minute),
53 HTTPClient: &http.Client{
54 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
55 },
56 }
57 }
58
59 // DNSProvider implements the challenge.Provider interface.
60 type DNSProvider struct {
61 config *Config
62 client *internal.Client
63
64 zoneIDs map[string]string
65 zoneIDsMu sync.Mutex
66 }
67
68 // NewDNSProvider returns a DNSProvider instance configured for Mittwald.
69 // Credentials must be passed in the environment variables: MITTWALD_TOKEN.
70 func NewDNSProvider() (*DNSProvider, error) {
71 values, err := env.Get(EnvToken)
72 if err != nil {
73 return nil, fmt.Errorf("mittwald: %w", err)
74 }
75
76 config := NewDefaultConfig()
77 config.Token = values[EnvToken]
78
79 return NewDNSProviderConfig(config)
80 }
81
82 // NewDNSProviderConfig return a DNSProvider instance configured for Mittwald.
83 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
84 if config == nil {
85 return nil, errors.New("mittwald: the configuration of the DNS provider is nil")
86 }
87
88 if config.Token == "" {
89 return nil, errors.New("mittwald: some credentials information are missing")
90 }
91
92 if config.TTL < minTTL {
93 return nil, fmt.Errorf("mittwald: invalid TTL, TTL (%d) must be greater than %d", config.TTL, minTTL)
94 }
95
96 client := internal.NewClient(config.Token)
97
98 if config.HTTPClient != nil {
99 client.HTTPClient = config.HTTPClient
100 }
101
102 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
103
104 return &DNSProvider{
105 config: config,
106 client: client,
107 zoneIDs: map[string]string{},
108 }, nil
109 }
110
111 // Timeout returns the timeout and interval to use when checking for DNS propagation.
112 // Adjusting here to cope with spikes in propagation times.
113 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
114 return d.config.PropagationTimeout, d.config.PollingInterval
115 }
116
117 // Sequential All DNS challenges for this provider will be resolved sequentially.
118 // Returns the interval between each iteration.
119 func (d *DNSProvider) Sequential() time.Duration {
120 return d.config.SequenceInterval
121 }
122
123 // Present creates a TXT record to fulfill the dns-01 challenge.
124 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
125 ctx := context.Background()
126 info := dns01.GetChallengeInfo(domain, keyAuth)
127
128 zone, err := d.getOrCreateZone(ctx, info.EffectiveFQDN)
129 if err != nil {
130 return fmt.Errorf("mittwald: get effective zone: %w", err)
131 }
132
133 record := internal.TXTRecord{
134 Settings: internal.Settings{
135 TTL: internal.TTL{Seconds: d.config.TTL},
136 },
137 Entries: []string{info.Value},
138 }
139
140 err = d.client.UpdateTXTRecord(ctx, zone.ID, record)
141 if err != nil {
142 return fmt.Errorf("mittwald: update/add TXT record: %w", err)
143 }
144
145 d.zoneIDsMu.Lock()
146 d.zoneIDs[token] = zone.ID
147 d.zoneIDsMu.Unlock()
148
149 return nil
150 }
151
152 // CleanUp removes the TXT record matching the specified parameters.
153 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
154 ctx := context.Background()
155 info := dns01.GetChallengeInfo(domain, keyAuth)
156
157 // get the record's unique ID from when we created it
158 d.zoneIDsMu.Lock()
159 zoneID, ok := d.zoneIDs[token]
160 d.zoneIDsMu.Unlock()
161
162 if !ok {
163 return fmt.Errorf("mittwald: unknown zone ID for '%s'", info.EffectiveFQDN)
164 }
165
166 record := internal.TXTRecord{Entries: make([]string, 0)}
167
168 err := d.client.UpdateTXTRecord(ctx, zoneID, record)
169 if err != nil {
170 return fmt.Errorf("mittwald: update/delete TXT record: %w", err)
171 }
172
173 d.zoneIDsMu.Lock()
174 delete(d.zoneIDs, token)
175 d.zoneIDsMu.Unlock()
176
177 return nil
178 }
179
180 func (d *DNSProvider) getOrCreateZone(ctx context.Context, fqdn string) (*internal.DNSZone, error) {
181 domains, err := d.client.ListDomains(ctx)
182 if err != nil {
183 return nil, fmt.Errorf("list domains: %w", err)
184 }
185
186 dom, err := findDomain(domains, fqdn)
187 if err != nil {
188 return nil, fmt.Errorf("find domain: %w", err)
189 }
190
191 zones, err := d.client.ListDNSZones(ctx, dom.ProjectID)
192 if err != nil {
193 return nil, fmt.Errorf("list DNS zones: %w", err)
194 }
195
196 for _, zone := range zones {
197 if zone.Domain == dns01.UnFqdn(fqdn) {
198 return &zone, nil
199 }
200 }
201
202 // Looking for parent zone to create a new zone for the subdomain.
203
204 parentZone, err := findZone(zones, fqdn)
205 if err != nil {
206 return nil, fmt.Errorf("find zone: %w", err)
207 }
208
209 subDomain, err := dns01.ExtractSubDomain(fqdn, parentZone.Domain)
210 if err != nil {
211 return nil, err
212 }
213
214 request := internal.CreateDNSZoneRequest{
215 Name: subDomain,
216 ParentZoneID: parentZone.ID,
217 }
218
219 zone, err := d.client.CreateDNSZone(ctx, request)
220 if err != nil {
221 return nil, fmt.Errorf("create DNS zone: %w", err)
222 }
223
224 return zone, nil
225 }
226
227 func findDomain(domains []internal.Domain, fqdn string) (internal.Domain, error) {
228 for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
229 for _, dom := range domains {
230 if dom.Domain == domain {
231 return dom, nil
232 }
233 }
234 }
235
236 return internal.Domain{}, fmt.Errorf("domain %s not found", fqdn)
237 }
238
239 func findZone(zones []internal.DNSZone, fqdn string) (internal.DNSZone, error) {
240 for domain := range dns01.UnFqdnDomainsSeq(fqdn) {
241 for _, zon := range zones {
242 if zon.Domain == domain {
243 return zon, nil
244 }
245 }
246 }
247
248 return internal.DNSZone{}, fmt.Errorf("zone %s not found", fqdn)
249 }
250