yandexcloud.go raw
1 // Package yandexcloud implements a DNS provider for solving the DNS-01 challenge using Yandex Cloud.
2 package yandexcloud
3
4 import (
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "slices"
11 "strings"
12 "time"
13
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 ycdnsproto "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1"
18 ycdns "github.com/yandex-cloud/go-sdk/services/dns/v1"
19 ycsdk "github.com/yandex-cloud/go-sdk/v2"
20 "github.com/yandex-cloud/go-sdk/v2/credentials"
21 "github.com/yandex-cloud/go-sdk/v2/pkg/iamkey"
22 "github.com/yandex-cloud/go-sdk/v2/pkg/options"
23 )
24
25 // Environment variables names.
26 const (
27 envNamespace = "YANDEX_CLOUD_"
28
29 EnvIamToken = envNamespace + "IAM_TOKEN"
30 EnvFolderID = envNamespace + "FOLDER_ID"
31
32 EnvTTL = envNamespace + "TTL"
33 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
34 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
35 )
36
37 var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
38
39 // Config is used to configure the creation of the DNSProvider.
40 type Config struct {
41 IamToken string
42 FolderID string
43
44 PropagationTimeout time.Duration
45 PollingInterval time.Duration
46 TTL int
47 }
48
49 // NewDefaultConfig returns a default configuration for the DNSProvider.
50 func NewDefaultConfig() *Config {
51 return &Config{
52 TTL: env.GetOrDefaultInt(EnvTTL, 60),
53 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
54 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
55 }
56 }
57
58 // DNSProvider implements the challenge.Provider interface.
59 type DNSProvider struct {
60 client ycdns.DnsZoneClient
61 config *Config
62 }
63
64 // NewDNSProvider returns a DNSProvider instance configured for Yandex Cloud.
65 func NewDNSProvider() (*DNSProvider, error) {
66 values, err := env.Get(EnvIamToken, EnvFolderID)
67 if err != nil {
68 return nil, fmt.Errorf("yandexcloud: %w", err)
69 }
70
71 config := NewDefaultConfig()
72 config.IamToken = values[EnvIamToken]
73 config.FolderID = values[EnvFolderID]
74
75 return NewDNSProviderConfig(config)
76 }
77
78 // NewDNSProviderConfig return a DNSProvider instance configured for Yandex Cloud.
79 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
80 if config == nil {
81 return nil, errors.New("yandexcloud: the configuration of the DNS provider is nil")
82 }
83
84 if config.IamToken == "" {
85 return nil, errors.New("yandexcloud: some credentials information are missing IAM token")
86 }
87
88 if config.FolderID == "" {
89 return nil, errors.New("yandexcloud: some credentials information are missing folder id")
90 }
91
92 creds, err := decodeCredentials(config.IamToken)
93 if err != nil {
94 return nil, fmt.Errorf("yandexcloud: iam token is malformed: %w", err)
95 }
96
97 sdk, err := ycsdk.Build(context.Background(), options.WithCredentials(creds))
98 if err != nil {
99 return nil, errors.New("yandexcloud: unable to build yandex cloud sdk")
100 }
101
102 return &DNSProvider{
103 client: ycdns.NewDnsZoneClient(sdk),
104 config: config,
105 }, nil
106 }
107
108 // Present creates a TXT record to fulfill the dns-01 challenge.
109 func (d *DNSProvider) Present(domain, _, keyAuth string) error {
110 info := dns01.GetChallengeInfo(domain, keyAuth)
111
112 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
113 if err != nil {
114 return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err)
115 }
116
117 ctx := context.Background()
118
119 zones, err := d.getZones(ctx)
120 if err != nil {
121 return fmt.Errorf("yandexcloud: %w", err)
122 }
123
124 var zoneID string
125
126 for _, zone := range zones {
127 if zone.GetZone() == authZone {
128 zoneID = zone.GetId()
129 }
130 }
131
132 if zoneID == "" {
133 return fmt.Errorf("yandexcloud: cant find dns zone %s in yandex cloud", authZone)
134 }
135
136 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
137 if err != nil {
138 return fmt.Errorf("yandexcloud: %w", err)
139 }
140
141 err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value)
142 if err != nil {
143 return fmt.Errorf("yandexcloud: %w", err)
144 }
145
146 return nil
147 }
148
149 // CleanUp removes the TXT record matching the specified parameters.
150 func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
151 info := dns01.GetChallengeInfo(domain, keyAuth)
152
153 authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
154 if err != nil {
155 return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err)
156 }
157
158 ctx := context.Background()
159
160 zones, err := d.getZones(ctx)
161 if err != nil {
162 return fmt.Errorf("yandexcloud: %w", err)
163 }
164
165 var zoneID string
166
167 for _, zone := range zones {
168 if zone.GetZone() == authZone {
169 zoneID = zone.GetId()
170 }
171 }
172
173 if zoneID == "" {
174 return nil
175 }
176
177 subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
178 if err != nil {
179 return fmt.Errorf("yandexcloud: %w", err)
180 }
181
182 err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value)
183 if err != nil {
184 return fmt.Errorf("yandexcloud: %w", err)
185 }
186
187 return nil
188 }
189
190 // Timeout returns the timeout and interval to use when checking for DNS propagation.
191 // Adjusting here to cope with spikes in propagation times.
192 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
193 return d.config.PropagationTimeout, d.config.PollingInterval
194 }
195
196 // getZones retrieves available zones from yandex cloud.
197 func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdnsproto.DnsZone, error) {
198 list := &ycdnsproto.ListDnsZonesRequest{
199 FolderId: d.config.FolderID,
200 }
201
202 response, err := d.client.List(ctx, list)
203 if err != nil {
204 return nil, errors.New("unable to fetch dns zones")
205 }
206
207 return response.GetDnsZones(), nil
208 }
209
210 func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error {
211 get := &ycdnsproto.GetDnsZoneRecordSetRequest{
212 DnsZoneId: zoneID,
213 Name: name,
214 Type: "TXT",
215 }
216
217 exist, err := d.client.GetRecordSet(ctx, get)
218 if err != nil {
219 if !strings.Contains(err.Error(), "RecordSet not found") {
220 return err
221 }
222 }
223
224 record := &ycdnsproto.RecordSet{
225 Name: name,
226 Type: "TXT",
227 Ttl: int64(d.config.TTL),
228 Data: []string{},
229 }
230
231 var deletions []*ycdnsproto.RecordSet
232
233 if exist != nil {
234 record.SetData(append(record.GetData(), exist.GetData()...))
235 deletions = append(deletions, exist)
236 }
237
238 appended := appendRecordSetData(record, value)
239 if !appended {
240 // The value already present in RecordSet, nothing to do
241 return nil
242 }
243
244 update := &ycdnsproto.UpdateRecordSetsRequest{
245 DnsZoneId: zoneID,
246 Deletions: deletions,
247 Additions: []*ycdnsproto.RecordSet{record},
248 }
249
250 _, err = d.client.UpdateRecordSets(ctx, update)
251
252 return err
253 }
254
255 func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error {
256 get := &ycdnsproto.GetDnsZoneRecordSetRequest{
257 DnsZoneId: zoneID,
258 Name: name,
259 Type: "TXT",
260 }
261
262 previousRecord, err := d.client.GetRecordSet(ctx, get)
263 if err != nil {
264 if strings.Contains(err.Error(), "RecordSet not found") {
265 // RecordSet is not present, nothing to do
266 return nil
267 }
268
269 return err
270 }
271
272 var additions []*ycdnsproto.RecordSet
273
274 if len(previousRecord.GetData()) > 1 {
275 // RecordSet is not empty we should update it
276 record := &ycdnsproto.RecordSet{
277 Name: name,
278 Type: "TXT",
279 Ttl: int64(d.config.TTL),
280 Data: []string{},
281 }
282
283 for _, data := range previousRecord.GetData() {
284 if data != value {
285 record.SetData(append(record.GetData(), data))
286 }
287 }
288
289 additions = append(additions, record)
290 }
291
292 update := &ycdnsproto.UpdateRecordSetsRequest{
293 DnsZoneId: zoneID,
294 Deletions: []*ycdnsproto.RecordSet{previousRecord},
295 Additions: additions,
296 }
297
298 _, err = d.client.UpdateRecordSets(ctx, update)
299
300 return err
301 }
302
303 // decodeCredentials converts base64 encoded json of iam token to struct.
304 func decodeCredentials(accountB64 string) (credentials.Credentials, error) {
305 account, err := base64.StdEncoding.DecodeString(accountB64)
306 if err != nil {
307 return nil, err
308 }
309
310 key := &iamkey.Key{}
311
312 err = json.Unmarshal(account, key)
313 if err != nil {
314 return nil, err
315 }
316
317 return credentials.ServiceAccountKey(key)
318 }
319
320 func appendRecordSetData(record *ycdnsproto.RecordSet, value string) bool {
321 if slices.Contains(record.GetData(), value) {
322 return false
323 }
324
325 record.SetData(append(record.GetData(), value))
326
327 return true
328 }
329