netcup.go raw
1 // Package netcup implements a DNS Provider for solving the DNS-01 challenge using the netcup DNS API.
2 package netcup
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "net/http"
9 "strings"
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/log"
15 "github.com/go-acme/lego/v4/platform/config/env"
16 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
17 "github.com/go-acme/lego/v4/providers/dns/netcup/internal"
18 )
19
20 // Environment variables names.
21 const (
22 envNamespace = "NETCUP_"
23
24 EnvCustomerNumber = envNamespace + "CUSTOMER_NUMBER"
25 EnvAPIKey = envNamespace + "API_KEY"
26 EnvAPIPassword = envNamespace + "API_PASSWORD"
27
28 // Deprecated: the TTL is not configurable on record.
29 EnvTTL = envNamespace + "TTL"
30
31 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
32 EnvPollingInterval = envNamespace + "POLLING_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 Key string
41 Password string
42 Customer string
43 PropagationTimeout time.Duration
44 PollingInterval time.Duration
45 HTTPClient *http.Client
46
47 // Deprecated: the TTL is not configurable on record.
48 TTL int
49 }
50
51 // NewDefaultConfig returns a default configuration for the DNSProvider.
52 func NewDefaultConfig() *Config {
53 return &Config{
54 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 15*time.Minute),
55 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 30*time.Second),
56 HTTPClient: &http.Client{
57 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 10*time.Second),
58 },
59 }
60 }
61
62 // DNSProvider implements the challenge.Provider interface.
63 type DNSProvider struct {
64 client *internal.Client
65 config *Config
66 }
67
68 // NewDNSProvider returns a DNSProvider instance configured for netcup.
69 // Credentials must be passed in the environment variables:
70 // NETCUP_CUSTOMER_NUMBER, NETCUP_API_KEY, NETCUP_API_PASSWORD.
71 func NewDNSProvider() (*DNSProvider, error) {
72 values, err := env.Get(EnvCustomerNumber, EnvAPIKey, EnvAPIPassword)
73 if err != nil {
74 return nil, fmt.Errorf("netcup: %w", err)
75 }
76
77 config := NewDefaultConfig()
78 config.Customer = values[EnvCustomerNumber]
79 config.Key = values[EnvAPIKey]
80 config.Password = values[EnvAPIPassword]
81
82 return NewDNSProviderConfig(config)
83 }
84
85 // NewDNSProviderConfig return a DNSProvider instance configured for netcup.
86 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
87 if config == nil {
88 return nil, errors.New("netcup: the configuration of the DNS provider is nil")
89 }
90
91 client, err := internal.NewClient(config.Customer, config.Key, config.Password)
92 if err != nil {
93 return nil, fmt.Errorf("netcup: %w", err)
94 }
95
96 if config.HTTPClient != nil {
97 client.HTTPClient = config.HTTPClient
98 }
99
100 client.HTTPClient = clientdebug.Wrap(client.HTTPClient)
101
102 return &DNSProvider{client: client, config: config}, nil
103 }
104
105 // Present creates a TXT record to fulfill the dns-01 challenge.
106 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
107 info := dns01.GetChallengeInfo(domain, keyAuth)
108
109 zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
110 if err != nil {
111 return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err)
112 }
113
114 ctx, err := d.client.CreateSessionContext(context.Background())
115 if err != nil {
116 return fmt.Errorf("netcup: %w", err)
117 }
118
119 defer func() {
120 err = d.client.Logout(ctx)
121 if err != nil {
122 log.Printf("netcup: %v", err)
123 }
124 }()
125
126 hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1)
127 record := internal.DNSRecord{
128 Hostname: hostname,
129 RecordType: "TXT",
130 Destination: info.Value,
131 }
132
133 zone = dns01.UnFqdn(zone)
134
135 records, err := d.client.GetDNSRecords(ctx, zone)
136 if err != nil {
137 // skip no existing records
138 log.Infof("no existing records, error ignored: %v", err)
139 }
140
141 records = append(records, record)
142
143 err = d.client.UpdateDNSRecord(ctx, zone, records)
144 if err != nil {
145 return fmt.Errorf("netcup: failed to add TXT-Record: %w", err)
146 }
147
148 return nil
149 }
150
151 // CleanUp removes the TXT record matching the specified parameters.
152 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
153 info := dns01.GetChallengeInfo(domain, keyAuth)
154
155 zone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
156 if err != nil {
157 return fmt.Errorf("netcup: could not find zone for domain %q: %w", domain, err)
158 }
159
160 ctx, err := d.client.CreateSessionContext(context.Background())
161 if err != nil {
162 return fmt.Errorf("netcup: %w", err)
163 }
164
165 defer func() {
166 err = d.client.Logout(ctx)
167 if err != nil {
168 log.Printf("netcup: %v", err)
169 }
170 }()
171
172 hostname := strings.Replace(info.EffectiveFQDN, "."+zone, "", 1)
173
174 zone = dns01.UnFqdn(zone)
175
176 records, err := d.client.GetDNSRecords(ctx, zone)
177 if err != nil {
178 return fmt.Errorf("netcup: %w", err)
179 }
180
181 record := internal.DNSRecord{
182 Hostname: hostname,
183 RecordType: "TXT",
184 Destination: info.Value,
185 }
186
187 idx, err := internal.GetDNSRecordIdx(records, record)
188 if err != nil {
189 return fmt.Errorf("netcup: %w", err)
190 }
191
192 records[idx].DeleteRecord = true
193
194 err = d.client.UpdateDNSRecord(ctx, zone, []internal.DNSRecord{records[idx]})
195 if err != nil {
196 return fmt.Errorf("netcup: %w", err)
197 }
198
199 return nil
200 }
201
202 // Timeout returns the timeout and interval to use when checking for DNS propagation.
203 // Adjusting here to cope with spikes in propagation times.
204 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
205 return d.config.PropagationTimeout, d.config.PollingInterval
206 }
207