selectelv2.go raw
1 // Package selectelv2 implements a DNS provider for solving the DNS-01 challenge using Selectel Domains APIv2.
2 package selectelv2
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/dns01"
13 "github.com/go-acme/lego/v4/platform/config/env"
14 "github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
15 "github.com/go-acme/lego/v4/providers/dns/internal/useragent"
16 "github.com/miekg/dns"
17 selectelapi "github.com/selectel/domains-go/pkg/v2"
18 "github.com/selectel/go-selvpcclient/v4/selvpcclient"
19 "golang.org/x/net/idna"
20 )
21
22 const (
23 envNamespace = "SELECTELV2_"
24
25 EnvBaseURL = envNamespace + "BASE_URL"
26 EnvUsernameOS = envNamespace + "USERNAME"
27 EnvPasswordOS = envNamespace + "PASSWORD"
28 EnvDomainName = envNamespace + "ACCOUNT_ID"
29 EnvProjectID = envNamespace + "PROJECT_ID"
30 EnvAuthRegion = envNamespace + "AUTH_REGION"
31 EnvAuthURL = envNamespace + "AUTH_URL"
32 EnvUserDomainName = envNamespace + "USER_DOMAIN_NAME"
33
34 EnvTTL = envNamespace + "TTL"
35 EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
36 EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
37 EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
38 )
39
40 const (
41 defaultBaseURL = "https://api.selectel.ru/domains/v2"
42 defaultAuthRegion = "ru-1"
43 defaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3/"
44 )
45
46 const (
47 defaultTTL = 60
48 defaultPropagationTimeout = 120 * time.Second
49 defaultPollingInterval = 5 * time.Second
50 defaultHTTPTimeout = 30 * time.Second
51 )
52
53 const tokenHeader = "X-Auth-Token"
54
55 var errNotFound = errors.New("rrset not found")
56
57 // Config is used to configure the creation of the DNSProvider.
58 type Config struct {
59 BaseURL string
60 Username string
61 Password string
62 DomainName string
63 ProjectID string
64 AuthURL string
65 AuthRegion string
66 UserDomainName string
67
68 TTL int
69 PropagationTimeout time.Duration
70 PollingInterval time.Duration
71 HTTPClient *http.Client
72 }
73
74 // NewDefaultConfig returns a default configuration for the DNSProvider.
75 func NewDefaultConfig() *Config {
76 return &Config{
77 BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL),
78 AuthRegion: env.GetOrDefaultString(EnvAuthRegion, defaultAuthRegion),
79 AuthURL: env.GetOrDefaultString(EnvAuthURL, defaultAuthURL),
80
81 TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL),
82 PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout),
83 PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval),
84 HTTPClient: &http.Client{
85 Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, defaultHTTPTimeout),
86 },
87 }
88 }
89
90 type DNSProvider struct {
91 baseClient selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet]
92 config *Config
93 }
94
95 // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains APIv2.
96 func NewDNSProvider() (*DNSProvider, error) {
97 values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvDomainName, EnvProjectID)
98 if err != nil {
99 return nil, fmt.Errorf("selectelv2: %w", err)
100 }
101
102 config := NewDefaultConfig()
103 config.Username = values[EnvUsernameOS]
104 config.Password = values[EnvPasswordOS]
105 config.DomainName = values[EnvDomainName]
106 config.ProjectID = values[EnvProjectID]
107 config.UserDomainName = env.GetOrDefaultString(EnvUserDomainName, "")
108
109 return NewDNSProviderConfig(config)
110 }
111
112 // NewDNSProviderConfig return a DNSProvider instance configured for selectel.
113 func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
114 if config == nil {
115 return nil, errors.New("selectelv2: the configuration of the DNS provider is nil")
116 }
117
118 if config.Username == "" {
119 return nil, errors.New("selectelv2: missing username")
120 }
121
122 if config.Password == "" {
123 return nil, errors.New("selectelv2: missing password")
124 }
125
126 if config.DomainName == "" {
127 return nil, errors.New("selectelv2: missing account ID")
128 }
129
130 if config.ProjectID == "" {
131 return nil, errors.New("selectelv2: missing project ID")
132 }
133
134 headers := http.Header{}
135 useragent.SetHeader(headers)
136
137 return &DNSProvider{
138 baseClient: selectelapi.NewClient(config.BaseURL, clientdebug.Wrap(config.HTTPClient), headers),
139 config: config,
140 }, nil
141 }
142
143 // Timeout returns the Timeout and interval to use when checking for DNS propagation.
144 // Adjusting here to cope with spikes in propagation times.
145 func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
146 return d.config.PropagationTimeout, d.config.PollingInterval
147 }
148
149 // Present creates a TXT record to fulfill DNS-01 challenge.
150 func (d *DNSProvider) Present(domain, _, keyAuth string) error {
151 ctx := context.Background()
152
153 client, err := d.authorize(ctx)
154 if err != nil {
155 return fmt.Errorf("selectelv2: authorize: %w", err)
156 }
157
158 info := dns01.GetChallengeInfo(domain, keyAuth)
159
160 zone, err := client.getZone(ctx, domain)
161 if err != nil {
162 return fmt.Errorf("selectelv2: get zone: %w", err)
163 }
164
165 rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID)
166 if err != nil {
167 if !errors.Is(err, errNotFound) {
168 return fmt.Errorf("selectelv2: get RRSet: %w", err)
169 }
170
171 newRRSet := &selectelapi.RRSet{
172 Name: info.EffectiveFQDN,
173 Type: selectelapi.TXT,
174 TTL: d.config.TTL,
175 Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}},
176 }
177
178 _, err = client.CreateRRSet(ctx, zone.ID, newRRSet)
179 if err != nil {
180 return fmt.Errorf("selectelv2: create RRSet: %w", err)
181 }
182
183 return nil
184 }
185
186 rrset.Records = append(rrset.Records, selectelapi.RecordItem{Content: fmt.Sprintf("%q", info.Value)})
187
188 err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset)
189 if err != nil {
190 return fmt.Errorf("selectelv2: update RRSet: %w", err)
191 }
192
193 return nil
194 }
195
196 // CleanUp removes a TXT record used for DNS-01 challenge.
197 func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
198 ctx := context.Background()
199
200 client, err := d.authorize(ctx)
201 if err != nil {
202 return fmt.Errorf("selectelv2: authorize: %w", err)
203 }
204
205 info := dns01.GetChallengeInfo(domain, keyAuth)
206
207 zone, err := client.getZone(ctx, domain)
208 if err != nil {
209 return fmt.Errorf("selectelv2: get zone: %w", err)
210 }
211
212 rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID)
213 if err != nil {
214 return fmt.Errorf("selectelv2: get RRSet: %w", err)
215 }
216
217 if len(rrset.Records) <= 1 {
218 err = client.DeleteRRSet(ctx, zone.ID, rrset.ID)
219 if err != nil {
220 return fmt.Errorf("selectelv2: %w", err)
221 }
222
223 return nil
224 }
225
226 for i, item := range rrset.Records {
227 if strings.Trim(item.Content, `"`) == info.Value {
228 rrset.Records = append(rrset.Records[:i], rrset.Records[i+1:]...)
229 break
230 }
231 }
232
233 err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset)
234 if err != nil {
235 return fmt.Errorf("selectelv2: update RRSet: %w", err)
236 }
237
238 return nil
239 }
240
241 func (d *DNSProvider) authorize(ctx context.Context) (*clientWrapper, error) {
242 token, err := obtainOpenstackToken(ctx, d.config)
243 if err != nil {
244 return nil, err
245 }
246
247 extraHeaders := http.Header{}
248 extraHeaders.Set(tokenHeader, token)
249
250 return &clientWrapper{
251 DNSClient: d.baseClient.WithHeaders(extraHeaders),
252 }, nil
253 }
254
255 func obtainOpenstackToken(ctx context.Context, config *Config) (string, error) {
256 vpcClient, err := selvpcclient.NewClient(&selvpcclient.ClientOptions{
257 Context: ctx,
258 DomainName: config.DomainName,
259 AuthURL: config.AuthURL,
260 AuthRegion: config.AuthRegion,
261 Username: config.Username,
262 Password: config.Password,
263 ProjectID: config.ProjectID,
264 UserDomainName: config.UserDomainName,
265 })
266 if err != nil {
267 return "", fmt.Errorf("new VPC client: %w", err)
268 }
269
270 return vpcClient.GetXAuthToken(), nil
271 }
272
273 type clientWrapper struct {
274 selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet]
275 }
276
277 func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi.Zone, error) {
278 unicodeName, err := idna.ToUnicode(name)
279 if err != nil {
280 return nil, fmt.Errorf("to unicode: %w", err)
281 }
282
283 params := &map[string]string{"filter": unicodeName}
284
285 zones, err := w.ListZones(ctx, params)
286 if err != nil {
287 return nil, fmt.Errorf("list zone: %w", err)
288 }
289
290 for _, zone := range zones.GetItems() {
291 if zone.Name == dns.Fqdn(unicodeName) {
292 return zone, nil
293 }
294 }
295
296 if len(strings.Split(dns01.UnFqdn(name), ".")) == 1 {
297 return nil, fmt.Errorf("zone '%s' for challenge has not been found", name)
298 }
299
300 // after is always defined since if no dots present we exit above.
301 _, after, _ := strings.Cut(name, ".")
302
303 return w.getZone(ctx, after)
304 }
305
306 func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*selectelapi.RRSet, error) {
307 unicodeName, err := idna.ToUnicode(name)
308 if err != nil {
309 return nil, fmt.Errorf("to unicode: %w", err)
310 }
311
312 params := &map[string]string{"name": unicodeName, "rrset_types": string(selectelapi.TXT)}
313
314 resp, err := w.ListRRSets(ctx, zoneID, params)
315 if err != nil {
316 return nil, fmt.Errorf("list rrset: %w", err)
317 }
318
319 for _, rrset := range resp.GetItems() {
320 if rrset.Name == dns.Fqdn(unicodeName) {
321 return rrset, nil
322 }
323 }
324
325 return nil, errNotFound
326 }
327