desec.go raw
1 // Package desec implements a DNS provider for solving the DNS-01 challenge using deSEC DNS.
2 package desec
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "log"
9 "net/http"
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/nrdcg/desec"
17 )
18
19 // Environment variables names.
20 const (
21 envNamespace = "DESEC_"
22
23 EnvToken = envNamespace + "TOKEN"
24
25 EnvTTL = envNamespace + "TTL"
26 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
27 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
28 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
29 )
30
31 // https://github.com/desec-io/desec-stack/issues/216
32 // https://desec.readthedocs.io/_/downloads/en/latest/pdf/
33 const defaultTTL int = 3600
34
35 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
36
37 // Config is used to configure the creation of the DNSProvider.
38 type Config struct {
39 Token string
40 PropagationTimeout time.Duration
41 PollingInterval time.Duration
42 TTL int
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, defaultTTL),
50 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 120*time.Second),
51 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 4*time.Second),
52 HTTPClient: &http.Client{
53 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
54 },
55 }
56 }
57
58 // DNSProvider implements the challenge.Provider interface.
59 type DNSProvider struct {
60 config *Config
61 client *desec.Client
62 }
63
64 // NewDNSProvider returns a DNSProvider instance configured for deSEC.
65 // Credentials must be passed in the environment variable: DESEC_TOKEN.
66 func NewDNSProvider() (*DNSProvider, error) {
67 values, err := env.Get(EnvToken)
68 if err != nil {
69 return nil, fmt.Errorf("desec: %w", err)
70 }
71
72 config := NewDefaultConfig()
73 config.Token = values[EnvToken]
74
75 return NewDNSProviderConfig(config)
76 }
77
78 // NewDNSProviderConfig return a DNSProvider instance configured for deSEC.
79 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
80 if config == nil {
81 return nil, errors.New("desec: the configuration of the DNS provider is nil")
82 }
83
84 if config.Token == "" {
85 return nil, errors.New("desec: incomplete credentials, missing token")
86 }
87
88 opts := desec.NewDefaultClientOptions()
89 if config.HTTPClient != nil {
90 opts.HTTPClient = config.HTTPClient
91 }
92
93 opts.HTTPClient = clientdebug.Wrap(opts.HTTPClient)
94
95 opts.Logger = log.Default()
96
97 client := desec.New(config.Token, opts)
98
99 return &DNSProvider{config: config, client: client}, nil
100 }
101
102 // Timeout returns the timeout and interval to use when checking for DNS propagation.
103 // Adjusting here to cope with spikes in propagation times.
104 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
105 return d.config.PropagationTimeout, d.config.PollingInterval
106 }
107
108 // Present creates a TXT record using the specified parameters.
109 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
110 ctx := context.Background()
111 info := dns01.GetChallengeInfo(domain, keyAuth)
112
113 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
114 if err != nil {
115 return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err)
116 }
117
118 recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
119 if err != nil {
120 return fmt.Errorf("desec: %w", err)
121 }
122
123 domainName := dns01.UnFqdn(authZone)
124
125 quotedValue := fmt.Sprintf(`%q`, info.Value)
126
127 rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT")
128 if err != nil {
129 var nf *desec.NotFoundError
130 if !errors.As(err, &nf) {
131 return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err)
132 }
133
134 // Not found case -> create
135 _, err = d.client.Records.Create(ctx, desec.RRSet{
136 Domain: domainName,
137 SubName: recordName,
138 Type: "TXT",
139 Records: []string{quotedValue},
140 TTL: d.config.TTL,
141 })
142 if err != nil {
143 return fmt.Errorf("desec: failed to create records: domainName=%s, recordName=%s: %w", domainName, recordName, err)
144 }
145
146 return nil
147 }
148
149 // update
150 records := append(rrSet.Records, quotedValue)
151
152 _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records})
153 if err != nil {
154 return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err)
155 }
156
157 return nil
158 }
159
160 // CleanUp removes the TXT record matching the specified parameters.
161 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
162 ctx := context.Background()
163 info := dns01.GetChallengeInfo(domain, keyAuth)
164
165 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
166 if err != nil {
167 return fmt.Errorf("desec: could not find zone for domain %q: %w", domain, err)
168 }
169
170 recordName, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
171 if err != nil {
172 return fmt.Errorf("desec: %w", err)
173 }
174
175 domainName := dns01.UnFqdn(authZone)
176
177 rrSet, err := d.client.Records.Get(ctx, domainName, recordName, "TXT")
178 if err != nil {
179 return fmt.Errorf("desec: failed to get records: domainName=%s, recordName=%s: %w", domainName, recordName, err)
180 }
181
182 records := make([]string, 0)
183
184 for _, record := range rrSet.Records {
185 if record != fmt.Sprintf(`%q`, info.Value) {
186 records = append(records, record)
187 }
188 }
189
190 _, err = d.client.Records.Update(ctx, domainName, recordName, "TXT", desec.RRSet{Records: records})
191 if err != nil {
192 return fmt.Errorf("desec: failed to update records: domainName=%s, recordName=%s: %w", domainName, recordName, err)
193 }
194
195 return nil
196 }
197