huaweicloud.go raw
1 // Package huaweicloud implements a DNS provider for solving the DNS-01 challenge using Huawei Cloud.
2 package huaweicloud
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "strconv"
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/platform/config/env"
17 "github.com/go-acme/lego/v4/platform/wait"
18 "github.com/go-acme/lego/v4/providers/dns/huaweicloud/internal"
19 "github.com/go-acme/lego/v4/providers/dns/internal/ptr"
20 hwauthbasic "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/auth/basic"
21 hwconfig "github.com/huaweicloud/huaweicloud-sdk-go-v3/core/config"
22 hwdns "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2"
23 hwmodel "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/model"
24 hwregion "github.com/huaweicloud/huaweicloud-sdk-go-v3/services/dns/v2/region"
25 )
26
27 // Environment variables names.
28 const (
29 envNamespace = "HUAWEICLOUD_"
30
31 EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID"
32 EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY"
33 EnvRegion = envNamespace + "REGION"
34
35 EnvTTL = envNamespace + "TTL"
36 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
37 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
38 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
39 )
40
41 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
42
43 // Config is used to configure the creation of the DNSProvider.
44 type Config struct {
45 AccessKeyID string
46 SecretAccessKey string
47 Region string
48
49 PropagationTimeout time.Duration
50 PollingInterval time.Duration
51 TTL int32
52 HTTPTimeout time.Duration
53 }
54
55 // NewDefaultConfig returns a default configuration for the DNSProvider.
56 func NewDefaultConfig() *Config {
57 return &Config{
58 TTL: int32(env.GetOrDefaultInt(EnvTTL, 300)),
59 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
60 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
61 HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
62 }
63 }
64
65 // DNSProvider implements the challenge.Provider interface.
66 type DNSProvider struct {
67 config *Config
68 client *internal.DnsClient
69
70 recordIDs map[string]string
71 recordIDsMu sync.Mutex
72 }
73
74 // NewDNSProvider returns a DNSProvider instance configured for Huawei Cloud.
75 // Credentials must be passed in the environment variables:
76 // HUAWEICLOUD_ACCESS_KEY_ID, HUAWEICLOUD_SECRET_ACCESS_KEY, and HUAWEICLOUD_REGION.
77 func NewDNSProvider() (*DNSProvider, error) {
78 values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey, EnvRegion)
79 if err != nil {
80 return nil, fmt.Errorf("huaweicloud: %w", err)
81 }
82
83 config := NewDefaultConfig()
84 config.AccessKeyID = values[EnvAccessKeyID]
85 config.SecretAccessKey = values[EnvSecretAccessKey]
86 config.Region = values[EnvRegion]
87
88 return NewDNSProviderConfig(config)
89 }
90
91 // NewDNSProviderConfig return a DNSProvider instance configured for Huawei Cloud.
92 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
93 if config == nil {
94 return nil, errors.New("huaweicloud: the configuration of the DNS provider is nil")
95 }
96
97 if config.AccessKeyID == "" || config.SecretAccessKey == "" || config.Region == "" {
98 return nil, errors.New("huaweicloud: credentials missing")
99 }
100
101 auth, err := hwauthbasic.NewCredentialsBuilder().
102 WithAk(config.AccessKeyID).
103 WithSk(config.SecretAccessKey).
104 SafeBuild()
105 if err != nil {
106 return nil, fmt.Errorf("huaweicloud: crendential build: %w", err)
107 }
108
109 region, err := hwregion.SafeValueOf(config.Region)
110 if err != nil {
111 return nil, fmt.Errorf("huaweicloud: safe region: %w", err)
112 }
113
114 client, err := hwdns.DnsClientBuilder().
115 WithHttpConfig(hwconfig.DefaultHttpConfig().WithTimeout(config.HTTPTimeout)).
116 WithRegion(region).
117 WithCredential(auth).
118 SafeBuild()
119 if err != nil {
120 return nil, fmt.Errorf("huaweicloud: client build: %w", err)
121 }
122
123 return &DNSProvider{
124 config: config,
125 client: internal.NewDnsClient(client),
126 recordIDs: map[string]string{},
127 }, nil
128 }
129
130 // Present creates a TXT record using the specified parameters.
131 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
132 info := dns01.GetChallengeInfo(domain, keyAuth)
133
134 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
135 if err != nil {
136 return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err)
137 }
138
139 zoneID, err := d.getZoneID(authZone)
140 if err != nil {
141 return fmt.Errorf("huaweicloud: %w", err)
142 }
143
144 recordSetID, err := d.getOrCreateRecordSetID(domain, zoneID, info)
145 if err != nil {
146 return fmt.Errorf("huaweicloud: %w", err)
147 }
148
149 d.recordIDsMu.Lock()
150 d.recordIDs[token] = recordSetID
151 d.recordIDsMu.Unlock()
152
153 err = wait.Retry(context.Background(),
154 func() error {
155 rs, errShow := d.client.ShowRecordSet(&hwmodel.ShowRecordSetRequest{
156 ZoneId: zoneID,
157 RecordsetId: recordSetID,
158 })
159 if errShow != nil {
160 return fmt.Errorf("show record set: %w", errShow)
161 }
162
163 if !strings.HasSuffix(ptr.Deref(rs.Status), "PENDING_") {
164 return nil
165 }
166
167 return fmt.Errorf("status: %s", ptr.Deref(rs.Status))
168 },
169 backoff.WithBackOff(backoff.NewConstantBackOff(d.config.PollingInterval)),
170 backoff.WithMaxElapsedTime(d.config.PropagationTimeout),
171 )
172 if err != nil {
173 return fmt.Errorf("huaweicloud: record set sync on %s: %w", domain, err)
174 }
175
176 return nil
177 }
178
179 // CleanUp removes the TXT record matching the specified parameters.
180 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
181 info := dns01.GetChallengeInfo(domain, keyAuth)
182
183 // gets the record's unique ID from when we created it
184 d.recordIDsMu.Lock()
185 recordID, ok := d.recordIDs[token]
186 d.recordIDsMu.Unlock()
187
188 if !ok {
189 return fmt.Errorf("huaweicloud: unknown record ID for '%s' '%s'", info.EffectiveFQDN, token)
190 }
191
192 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
193 if err != nil {
194 return fmt.Errorf("huaweicloud: could not find zone for domain %q: %w", domain, err)
195 }
196
197 zoneID, err := d.getZoneID(authZone)
198 if err != nil {
199 return fmt.Errorf("huaweicloud: %w", err)
200 }
201
202 request := &hwmodel.DeleteRecordSetRequest{
203 ZoneId: zoneID,
204 RecordsetId: recordID,
205 }
206
207 _, err = d.client.DeleteRecordSet(request)
208 if err != nil {
209 return fmt.Errorf("huaweicloud: delete record: %w", err)
210 }
211
212 d.recordIDsMu.Lock()
213 delete(d.recordIDs, token)
214 d.recordIDsMu.Unlock()
215
216 return nil
217 }
218
219 // Timeout returns the timeout and interval to use when checking for DNS propagation.
220 // Adjusting here to cope with spikes in propagation times.
221 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
222 return d.config.PropagationTimeout, d.config.PollingInterval
223 }
224
225 func (d *DNSProvider) getOrCreateRecordSetID(domain, zoneID string, info dns01.ChallengeInfo) (string, error) {
226 records, err := d.client.ListRecordSetsByZone(&hwmodel.ListRecordSetsByZoneRequest{
227 ZoneId: zoneID,
228 Name: ptr.Pointer(info.EffectiveFQDN),
229 })
230 if err != nil {
231 return "", fmt.Errorf("record list: unable to get record %s for zone %s: %w", info.EffectiveFQDN, domain, err)
232 }
233
234 var existingRecordSet *hwmodel.ListRecordSets
235
236 for _, record := range ptr.Deref(records.Recordsets) {
237 if ptr.Deref(record.Type) == "TXT" && ptr.Deref(record.Name) == info.EffectiveFQDN {
238 existingRecordSet = &record
239 }
240 }
241
242 value := strconv.Quote(info.Value)
243
244 if existingRecordSet == nil {
245 request := &hwmodel.CreateRecordSetRequest{
246 ZoneId: zoneID,
247 Body: &hwmodel.CreateRecordSetRequestBody{
248 Name: info.EffectiveFQDN,
249 Description: ptr.Pointer("Added TXT record for ACME dns-01 challenge using lego client"),
250 Type: "TXT",
251 Ttl: ptr.Pointer(d.config.TTL),
252 Records: []string{value},
253 },
254 }
255
256 resp, errCreate := d.client.CreateRecordSet(request)
257 if errCreate != nil {
258 return "", fmt.Errorf("create record set: %w", errCreate)
259 }
260
261 return ptr.Deref(resp.Id), nil
262 }
263
264 updateRequest := &hwmodel.UpdateRecordSetRequest{
265 ZoneId: zoneID,
266 RecordsetId: ptr.Deref(existingRecordSet.Id),
267 Body: &hwmodel.UpdateRecordSetReq{
268 Name: existingRecordSet.Name,
269 Description: existingRecordSet.Description,
270 Type: existingRecordSet.Type,
271 Ttl: existingRecordSet.Ttl,
272 Records: ptr.Pointer(append(ptr.Deref(existingRecordSet.Records), value)),
273 },
274 }
275
276 resp, err := d.client.UpdateRecordSet(updateRequest)
277 if err != nil {
278 return "", fmt.Errorf("update record set: %w", err)
279 }
280
281 return ptr.Deref(resp.Id), nil
282 }
283
284 func (d *DNSProvider) getZoneID(authZone string) (string, error) {
285 zones, err := d.client.ListPublicZones(&hwmodel.ListPublicZonesRequest{})
286 if err != nil {
287 return "", fmt.Errorf("unable to get zone: %w", err)
288 }
289
290 for _, zone := range ptr.Deref(zones.Zones) {
291 if ptr.Deref(zone.Name) == authZone {
292 return ptr.Deref(zone.Id), nil
293 }
294 }
295
296 return "", fmt.Errorf("zone %q not found", authZone)
297 }
298