aliesa.go raw
1 // Package aliesa implements a DNS provider for solving the DNS-01 challenge using AlibabaCloud ESA.
2 package aliesa
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "sync"
9 "time"
10
11 openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
12 "github.com/alibabacloud-go/tea/dara"
13 "github.com/aliyun/credentials-go/credentials"
14 esa "github.com/go-acme/esa-20240910/v2/client"
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/providers/dns/internal/ptr"
18 )
19
20 // Environment variables names.
21 const (
22 envNamespace = "ALIESA_"
23
24 EnvRAMRole = envNamespace + "RAM_ROLE"
25 EnvAccessKey = envNamespace + "ACCESS_KEY"
26 EnvSecretKey = envNamespace + "SECRET_KEY"
27 EnvSecurityToken = envNamespace + "SECURITY_TOKEN"
28 EnvRegionID = envNamespace + "REGION_ID"
29
30 EnvTTL = envNamespace + "TTL"
31 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
32 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
33 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
34 )
35
36 const defaultRegionID = "cn-hangzhou"
37
38 // Config is used to configure the creation of the DNSProvider.
39 type Config struct {
40 RAMRole string
41 APIKey string
42 SecretKey string
43 SecurityToken string
44 RegionID string
45
46 PropagationTimeout time.Duration
47 PollingInterval time.Duration
48 TTL int
49 HTTPTimeout time.Duration
50 }
51
52 // NewDefaultConfig returns a default configuration for the DNSProvider.
53 func NewDefaultConfig() *Config {
54 return &Config{
55 TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
56 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
57 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
58 HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
59 }
60 }
61
62 // DNSProvider implements the challenge.Provider interface.
63 type DNSProvider struct {
64 config *Config
65 client *esa.Client
66
67 recordIDs map[string]int64
68 recordIDsMu sync.Mutex
69 }
70
71 // NewDNSProvider returns a DNSProvider instance configured for AlibabaCloud ESA.
72 func NewDNSProvider() (*DNSProvider, error) {
73 config := NewDefaultConfig()
74 config.RegionID = env.GetOrFile(EnvRegionID)
75
76 values, err := env.Get(EnvRAMRole)
77 if err == nil {
78 config.RAMRole = values[EnvRAMRole]
79 return NewDNSProviderConfig(config)
80 }
81
82 values, err = env.Get(EnvAccessKey, EnvSecretKey)
83 if err != nil {
84 return nil, fmt.Errorf("aliesa: %w", err)
85 }
86
87 config.APIKey = values[EnvAccessKey]
88 config.SecretKey = values[EnvSecretKey]
89 config.SecurityToken = env.GetOrFile(EnvSecurityToken)
90
91 return NewDNSProviderConfig(config)
92 }
93
94 // NewDNSProviderConfig return a DNSProvider instance configured for AlibabaCloud ESA.
95 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
96 if config == nil {
97 return nil, errors.New("aliesa: the configuration of the DNS provider is nil")
98 }
99
100 if config.RegionID == "" {
101 config.RegionID = defaultRegionID
102 }
103
104 cfg := new(openapi.Config).
105 SetRegionId(config.RegionID).
106 SetReadTimeout(int(config.HTTPTimeout.Milliseconds()))
107
108 switch {
109 case config.RAMRole != "":
110 // https://www.alibabacloud.com/help/en/ecs/user-guide/attach-an-instance-ram-role-to-an-ecs-instance
111 credentialsCfg := new(credentials.Config).
112 SetType("ecs_ram_role").
113 SetRoleName(config.RAMRole)
114
115 credentialClient, err := credentials.NewCredential(credentialsCfg)
116 if err != nil {
117 return nil, fmt.Errorf("aliesa: new credential: %w", err)
118 }
119
120 cfg = cfg.SetCredential(credentialClient)
121
122 case config.APIKey != "" && config.SecretKey != "" && config.SecurityToken != "":
123 cfg = cfg.
124 SetAccessKeyId(config.APIKey).
125 SetAccessKeySecret(config.SecretKey).
126 SetSecurityToken(config.SecurityToken)
127
128 case config.APIKey != "" && config.SecretKey != "":
129 cfg = cfg.
130 SetAccessKeyId(config.APIKey).
131 SetAccessKeySecret(config.SecretKey)
132
133 default:
134 return nil, errors.New("aliesa: ram role or credentials missing")
135 }
136
137 client, err := esa.NewClient(cfg)
138 if err != nil {
139 return nil, fmt.Errorf("aliesa: new client: %w", err)
140 }
141
142 // Workaround to get a regional URL.
143 // https://github.com/alibabacloud-go/esa-20240910/blame/7660e3aab2045d4820e4b83427a154efe0c79319/client/client.go#L27
144 // The `EndpointRule` is hardcoded with an empty string, so the region is ignored.
145 client.Endpoint = nil
146 client.EndpointRule = ptr.Pointer("regional")
147
148 client.Endpoint, err = esa.GetEndpoint(client, dara.String("esa"), client.RegionId, client.EndpointRule, client.Network, client.Suffix, client.EndpointMap, client.Endpoint)
149 if err != nil {
150 return nil, fmt.Errorf("aliesa: get endpoint: %w", err)
151 }
152
153 return &DNSProvider{
154 config: config,
155 client: client,
156 recordIDs: make(map[string]int64),
157 }, nil
158 }
159
160 // Present creates a TXT record using the specified parameters.
161 func (d *DNSProvider) Present(domain, token, keyAuth string) error {
162 ctx := context.Background()
163
164 info := dns01.GetChallengeInfo(domain, keyAuth)
165
166 siteID, err := d.getSiteID(ctx, info.EffectiveFQDN)
167 if err != nil {
168 return fmt.Errorf("aliesa: %w", err)
169 }
170
171 crReq := new(esa.CreateRecordRequest).
172 SetSiteId(siteID).
173 SetType("TXT").
174 SetRecordName(dns01.UnFqdn(info.EffectiveFQDN)).
175 SetTtl(int32(d.config.TTL)).
176 SetData(new(esa.CreateRecordRequestData).SetValue(info.Value))
177
178 // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord
179 crResp, err := esa.CreateRecordWithContext(ctx, d.client, crReq, &dara.RuntimeOptions{})
180 if err != nil {
181 return fmt.Errorf("aliesa: create record: %w", err)
182 }
183
184 d.recordIDsMu.Lock()
185 d.recordIDs[token] = ptr.Deref(crResp.Body.GetRecordId())
186 d.recordIDsMu.Unlock()
187
188 return nil
189 }
190
191 // CleanUp removes the TXT record matching the specified parameters.
192 func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
193 ctx := context.Background()
194
195 info := dns01.GetChallengeInfo(domain, keyAuth)
196
197 // gets the record's unique ID
198 d.recordIDsMu.Lock()
199 recordID, ok := d.recordIDs[token]
200 d.recordIDsMu.Unlock()
201
202 if !ok {
203 return fmt.Errorf("aliesa: unknown record ID for '%s'", info.EffectiveFQDN)
204 }
205
206 drReq := new(esa.DeleteRecordRequest).
207 SetRecordId(recordID)
208
209 // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-deleterecord
210 _, err := esa.DeleteRecordWithContext(ctx, d.client, drReq, &dara.RuntimeOptions{})
211 if err != nil {
212 return fmt.Errorf("aliesa: delete record: %w", err)
213 }
214
215 d.recordIDsMu.Lock()
216 delete(d.recordIDs, token)
217 d.recordIDsMu.Unlock()
218
219 return nil
220 }
221
222 // Timeout returns the timeout and interval to use when checking for DNS propagation.
223 // Adjusting here to cope with spikes in propagation times.
224 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
225 return d.config.PropagationTimeout, d.config.PollingInterval
226 }
227
228 func (d *DNSProvider) getSiteID(ctx context.Context, fqdn string) (int64, error) {
229 authZone, err := dns01.FindZoneByFqdn(fqdn)
230 if err != nil {
231 return 0, fmt.Errorf("aliesa: could not find zone for domain %q: %w", fqdn, err)
232 }
233
234 lsReq := new(esa.ListSitesRequest).
235 SetSiteName(dns01.UnFqdn(authZone)).
236 SetSiteSearchType("suffix")
237
238 // https://www.alibabacloud.com/help/en/edge-security-acceleration/esa/api-esa-2024-09-10-listsites
239 lsResp, err := esa.ListSitesWithContext(ctx, d.client, lsReq, &dara.RuntimeOptions{})
240 if err != nil {
241 return 0, fmt.Errorf("list sites: %w", err)
242 }
243
244 for f := range dns01.UnFqdnDomainsSeq(fqdn) {
245 domain := dns01.UnFqdn(f)
246
247 for _, site := range lsResp.Body.GetSites() {
248 if ptr.Deref(site.GetSiteName()) == domain {
249 return ptr.Deref(site.GetSiteId()), nil
250 }
251 }
252 }
253
254 return 0, fmt.Errorf("site not found (fqdn: %q)", fqdn)
255 }
256