variomedia.go raw
1 // Package variomedia implements a DNS provider for solving the DNS-01 challenge using Variomedia DNS.
2 package variomedia
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/cenkalti/backoff/v5"
14 "github.com/go-acme/lego/v4/challenge"
15 "github.com/go-acme/lego/v4/challenge/dns01"
16 "github.com/go-acme/lego/v4/log"
17 "github.com/go-acme/lego/v4/platform/config/env"
18 "github.com/go-acme/lego/v4/platform/wait"
19 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
20 "github.com/go-acme/lego/v4/providers/dns/variomedia/internal"
21 )
22
23 // Environment variables names.
24 const (
25 envNamespace = "VARIOMEDIA_"
26
27 EnvAPIToken = envNamespace + "API_TOKEN"
28
29 EnvTTL = envNamespace + "TTL"
30 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
31 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
32 EnvSequenceInterval = envNamespace + "SEQUENCE_INTERVAL"
33 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
34 )
35
36 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
37
38 // Config is used to configure the creation of the DNSProvider.
39 type Config struct {
40 APIToken string
41
42 PropagationTimeout time.Duration
43 PollingInterval time.Duration
44 SequenceInterval time.Duration
45 TTL int
46 HTTPClient *http.Client
47 }
48
49 // NewDefaultConfig returns a default configuration for the DNSProvider.
50 func NewDefaultConfig() *Config {
51 return &Config{
52 TTL: env.GetOrDefaultInt(EnvTTL, 300),
53 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
54 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
55 SequenceInterval: env.GetOrDefaultSecond(EnvSequenceInterval, dns01.DefaultPropagationTimeout),
56 HTTPClient: &http.Client{
57 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
58 },
59 }
60 }
61
62 // DNSProvider implements the challenge.Provider interface.
63 type DNSProvider struct {
64 config *Config
65 client *internal.Client
66
67 recordIDs map[string]string
68 recordIDsMu sync.Mutex
69 }
70
71 // NewDNSProvider returns a DNSProvider instance.
72 func NewDNSProvider() (*DNSProvider, error) {
73 values, err := env.Get(EnvAPIToken)
74 if err != nil {
75 return nil, fmt.Errorf("variomedia: %w", err)
76 }
77
78 config := NewDefaultConfig()
79 config.APIToken = values[EnvAPIToken]
80
81 return NewDNSProviderConfig(config)
82 }
83
84 // NewDNSProviderConfig return a DNSProvider instance configured for Variomedia.
85 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
86 if config.APIToken == "" {
87 return nil, errors.New("variomedia: missing credentials")
88 }
89
90 client := internal.NewClient(config.APIToken)
91
92 if config.HTTPClient != nil {
93 client.HTTPClient = config.HTTPClient
94 }
95
96 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
97
98 return &DNSProvider{
99 config: config,
100 client: client,
101 recordIDs: make(map[string]string),
102 }, nil
103 }
104
105 // Timeout returns the timeout and interval to use when checking for DNS propagation.
106 // Adjusting here to cope with spikes in propagation times.
107 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
108 return d.config.PropagationTimeout, d.config.PollingInterval
109 }
110
111 // Sequential All DNS challenges for this provider will be resolved sequentially.
112 // Returns the interval between each iteration.
113 func (d *DNSProvider) Sequential() time.Duration {
114 return d.config.SequenceInterval
115 }
116
117 // Present creates a TXT record to fulfill the dns-01 challenge.
118 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
119 info := dns01.GetChallengeInfo(domain, keyAuth)
120
121 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
122 if err != nil {
123 return fmt.Errorf("variomedia: could not find zone for domain %q: %w", domain, err)
124 }
125
126 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
127 if err != nil {
128 return fmt.Errorf("variomedia: %w", err)
129 }
130
131 ctx := context.Background()
132
133 record := internal.DNSRecord{
134 RecordType: "TXT",
135 Name: subDomain,
136 Domain: dns01.UnFqdn(authZone),
137 Data: info.Value,
138 TTL: d.config.TTL,
139 }
140
141 cdrr, err := d.client.CreateDNSRecord(ctx, record)
142 if err != nil {
143 return fmt.Errorf("variomedia: %w", err)
144 }
145
146 err = d.waitJob(ctx, domain, cdrr.Data.ID)
147 if err != nil {
148 return fmt.Errorf("variomedia: %w", err)
149 }
150
151 d.recordIDsMu.Lock()
152 d.recordIDs[token] = strings.TrimPrefix(cdrr.Data.Links.DNSRecord, "https://api.variomedia.de/dns-records/")
153 d.recordIDsMu.Unlock()
154
155 return nil
156 }
157
158 // CleanUp removes the TXT record previously created.
159 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
160 info := dns01.GetChallengeInfo(domain, keyAuth)
161
162 ctx := context.Background()
163
164 // get the record's unique ID from when we created it
165 d.recordIDsMu.Lock()
166 recordID, ok := d.recordIDs[token]
167 d.recordIDsMu.Unlock()
168
169 if !ok {
170 return fmt.Errorf("variomedia: unknown record ID for '%s'", info.EffectiveFQDN)
171 }
172
173 ddrr, err := d.client.DeleteDNSRecord(ctx, recordID)
174 if err != nil {
175 return fmt.Errorf("variomedia: %w", err)
176 }
177
178 err = d.waitJob(ctx, domain, ddrr.Data.ID)
179 if err != nil {
180 return fmt.Errorf("variomedia: %w", err)
181 }
182
183 d.recordIDsMu.Lock()
184 delete(d.recordIDs, token)
185 d.recordIDsMu.Unlock()
186
187 return nil
188 }
189
190 func (d *DNSProvider) waitJob(ctx context.Context, domain, id string) error {
191 return wait.Retry(ctx,
192 func() error {
193 result, err := d.client.GetJob(ctx, id)
194 if err != nil {
195 return fmt.Errorf("apply change on %s: %w", domain, err)
196 }
197
198 log.Infof("variomedia: [%s] %s: %s %s", domain, result.Data.ID, result.Data.Attributes.JobType, result.Data.Attributes.Status)
199
200 if result.Data.Attributes.Status != "done" {
201 return fmt.Errorf("apply change on %s: status: %s", domain, result.Data.Attributes.Status)
202 }
203
204 return nil
205 },
206 backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
207 backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
208 )
209 }
210