servercow.go raw
1 // Package servercow implements a DNS provider for solving the DNS-01 challenge using Servercow DNS.
2 package servercow
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "slices"
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/servercow/internal"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "SERVERCOW_"
22
23 EnvUsername = envNamespace + "USERNAME"
24 EnvPassword = envNamespace + "PASSWORD"
25
26 EnvTTL = envNamespace + "TTL"
27 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
28 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
29 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
30 )
31
32 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
33
34 // Config is used to configure the creation of the DNSProvider.
35 type Config struct {
36 Username string
37 Password string
38
39 PropagationTimeout time.Duration
40 PollingInterval time.Duration
41 TTL int
42 HTTPClient *http.Client
43 }
44
45 // NewDefaultConfig returns a default configuration for the DNSProvider.
46 func NewDefaultConfig() *Config {
47 return &Config{
48 TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
49 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
50 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
51 HTTPClient: &http.Client{
52 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
53 },
54 }
55 }
56
57 // DNSProvider implements the challenge.Provider interface.
58 type DNSProvider struct {
59 config *Config
60 client *internal.Client
61 }
62
63 // NewDNSProvider returns a DNSProvider instance.
64 func NewDNSProvider() (*DNSProvider, error) {
65 values, err := env.Get(EnvUsername, EnvPassword)
66 if err != nil {
67 return nil, fmt.Errorf("servercow: %w", err)
68 }
69
70 config := NewDefaultConfig()
71 config.Username = values[EnvUsername]
72 config.Password = values[EnvPassword]
73
74 return NewDNSProviderConfig(config)
75 }
76
77 // NewDNSProviderConfig return a DNSProvider instance configured for Servercow.
78 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
79 if config.Username == "" || config.Password == "" {
80 return nil, errors.New("servercow: incomplete credentials, missing username and/or password")
81 }
82
83 client := internal.NewClient(config.Username, config.Password)
84
85 if config.HTTPClient == nil {
86 client.HTTPClient = config.HTTPClient
87 }
88
89 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
90
91 return &DNSProvider{
92 config: config,
93 client: client,
94 }, nil
95 }
96
97 // Timeout returns the timeout and interval to use when checking for DNS propagation.
98 // Adjusting here to cope with spikes in propagation times.
99 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
100 return d.config.PropagationTimeout, d.config.PollingInterval
101 }
102
103 // Present creates a TXT record to fulfill the dns-01 challenge.
104 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
105 info := dns01.GetChallengeInfo(domain, keyAuth)
106
107 authZone, err := getAuthZone(info.EffectiveFQDN)
108 if err != nil {
109 return fmt.Errorf("servercow: %w", err)
110 }
111
112 ctx := context.Background()
113
114 records, err := d.client.GetRecords(ctx, authZone)
115 if err != nil {
116 return fmt.Errorf("servercow: %w", err)
117 }
118
119 recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
120 if err != nil {
121 return fmt.Errorf("servercow: %w", err)
122 }
123
124 record := findRecords(records, recordName)
125
126 // TXT record entry already existing
127 if record != nil {
128 if slices.Contains(record.Content, info.Value) {
129 return nil
130 }
131
132 request := internal.Record{
133 Name: record.Name,
134 TTL: record.TTL,
135 Type: record.Type,
136 Content: append(record.Content, info.Value),
137 }
138
139 _, err = d.client.CreateUpdateRecord(ctx, authZone, request)
140 if err != nil {
141 return fmt.Errorf("servercow: failed to update TXT records: %w", err)
142 }
143
144 return nil
145 }
146
147 request := internal.Record{
148 Type: "TXT",
149 Name: recordName,
150 TTL: d.config.TTL,
151 Content: internal.Value{info.Value},
152 }
153
154 _, err = d.client.CreateUpdateRecord(ctx, authZone, request)
155 if err != nil {
156 return fmt.Errorf("servercow: failed to create TXT record %s: %w", info.EffectiveFQDN, err)
157 }
158
159 return nil
160 }
161
162 // CleanUp removes the TXT record previously created.
163 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
164 info := dns01.GetChallengeInfo(domain, keyAuth)
165
166 authZone, err := getAuthZone(info.EffectiveFQDN)
167 if err != nil {
168 return fmt.Errorf("servercow: %w", err)
169 }
170
171 ctx := context.Background()
172
173 records, err := d.client.GetRecords(ctx, authZone)
174 if err != nil {
175 return fmt.Errorf("servercow: failed to get TXT records: %w", err)
176 }
177
178 recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
179 if err != nil {
180 return fmt.Errorf("servercow: %w", err)
181 }
182
183 record := findRecords(records, recordName)
184 if record == nil {
185 return nil
186 }
187
188 if !slices.Contains(record.Content, info.Value) {
189 return nil
190 }
191
192 // only 1 record value, the whole record must be deleted.
193 if len(record.Content) == 1 {
194 _, err = d.client.DeleteRecord(ctx, authZone, *record)
195 if err != nil {
196 return fmt.Errorf("servercow: failed to delete TXT records: %w", err)
197 }
198
199 return nil
200 }
201
202 request := internal.Record{
203 Name: record.Name,
204 Type: record.Type,
205 TTL: record.TTL,
206 }
207
208 for _, val := range record.Content {
209 if val != info.Value {
210 request.Content = append(request.Content, val)
211 }
212 }
213
214 _, err = d.client.CreateUpdateRecord(ctx, authZone, request)
215 if err != nil {
216 return fmt.Errorf("servercow: failed to update TXT records: %w", err)
217 }
218
219 return nil
220 }
221
222 func getAuthZone(domain string) (string, error) {
223 authZone, err := dns01.FindZoneByFqdn(domain)
224 if err != nil {
225 return "", fmt.Errorf("could not find zone: %w", err)
226 }
227
228 return dns01.UnFqdn(authZone), nil
229 }
230
231 func findRecords(records []internal.Record, name string) *internal.Record {
232 for _, r := range records {
233 if r.Type == "TXT" && r.Name == name {
234 return &r
235 }
236 }
237
238 return nil
239 }
240