// Package selectelv2 implements a DNS provider for solving the DNS-01 challenge using Selectel Domains APIv2. package selectelv2 import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/internal/useragent" selectelapi "github.com/selectel/domains-go/pkg/v2" "github.com/selectel/go-selvpcclient/v3/selvpcclient" "golang.org/x/net/idna" ) const ( envNamespace = "SELECTELV2_" EnvBaseURL = envNamespace + "BASE_URL" EnvUsernameOS = envNamespace + "USERNAME" EnvPasswordOS = envNamespace + "PASSWORD" EnvAccount = envNamespace + "ACCOUNT_ID" EnvProjectID = envNamespace + "PROJECT_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) const ( defaultBaseURL = "https://api.selectel.ru/domains/v2" defaultTTL = 60 defaultPropagationTimeout = 120 * time.Second defaultPollingInterval = 5 * time.Second defaultHTTPTimeout = 30 * time.Second ) const tokenHeader = "X-Auth-Token" var errNotFound = errors.New("rrset not found") // Config is used to configure the creation of the DNSProvider. type Config struct { BaseURL string Username string Password string Account string ProjectID string TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ BaseURL: env.GetOrDefaultString(EnvBaseURL, defaultBaseURL), TTL: env.GetOrDefaultInt(EnvTTL, defaultTTL), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, defaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, defaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, defaultHTTPTimeout), }, } } type DNSProvider struct { baseClient selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet] config *Config } // NewDNSProvider returns a DNSProvider instance configured for Selectel Domains APIv2. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsernameOS, EnvPasswordOS, EnvAccount, EnvProjectID) if err != nil { return nil, fmt.Errorf("selectelv2: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsernameOS] config.Password = values[EnvPasswordOS] config.Account = values[EnvAccount] config.ProjectID = values[EnvProjectID] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for selectel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("selectelv2: the configuration of the DNS provider is nil") } if config.Username == "" { return nil, errors.New("selectelv2: missing username") } if config.Password == "" { return nil, errors.New("selectelv2: missing password") } if config.Account == "" { return nil, errors.New("selectelv2: missing account") } if config.ProjectID == "" { return nil, errors.New("selectelv2: missing project ID") } headers := http.Header{} useragent.SetHeader(headers) return &DNSProvider{ baseClient: selectelapi.NewClient(config.BaseURL, config.HTTPClient, headers), config: config, }, nil } // Timeout returns the Timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (p *DNSProvider) Timeout() (timeout, interval time.Duration) { return p.config.PropagationTimeout, p.config.PollingInterval } // Present creates a TXT record to fulfill DNS-01 challenge. func (p *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() client, err := p.authorize() if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := client.getZone(ctx, domain) if err != nil { return fmt.Errorf("selectelv2: get zone: %w", err) } rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID) if err != nil { if !errors.Is(err, errNotFound) { return fmt.Errorf("selectelv2: get RRSet: %w", err) } newRRSet := &selectelapi.RRSet{ Name: info.EffectiveFQDN, Type: selectelapi.TXT, TTL: p.config.TTL, Records: []selectelapi.RecordItem{{Content: fmt.Sprintf("%q", info.Value)}}, } _, err = client.CreateRRSet(ctx, zone.ID, newRRSet) if err != nil { return fmt.Errorf("selectelv2: create RRSet: %w", err) } return nil } rrset.Records = append(rrset.Records, selectelapi.RecordItem{Content: fmt.Sprintf("%q", info.Value)}) err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset) if err != nil { return fmt.Errorf("selectelv2: update RRSet: %w", err) } return nil } // CleanUp removes a TXT record used for DNS-01 challenge. func (p *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() client, err := p.authorize() if err != nil { return fmt.Errorf("selectelv2: authorize: %w", err) } info := dns01.GetChallengeInfo(domain, keyAuth) zone, err := client.getZone(ctx, domain) if err != nil { return fmt.Errorf("selectelv2: get zone: %w", err) } rrset, err := client.getRRset(ctx, dns01.UnFqdn(info.EffectiveFQDN), zone.ID) if err != nil { return fmt.Errorf("selectelv2: get RRSet: %w", err) } if len(rrset.Records) <= 1 { err = client.DeleteRRSet(ctx, zone.ID, rrset.ID) if err != nil { return fmt.Errorf("selectelv2: %w", err) } return nil } for i, item := range rrset.Records { if strings.Trim(item.Content, `"`) == info.Value { rrset.Records = append(rrset.Records[:i], rrset.Records[i+1:]...) break } } err = client.UpdateRRSet(ctx, zone.ID, rrset.ID, rrset) if err != nil { return fmt.Errorf("selectelv2: update RRSet: %w", err) } return nil } func (p *DNSProvider) authorize() (*clientWrapper, error) { token, err := obtainOpenstackToken(p.config) if err != nil { return nil, err } extraHeaders := http.Header{} extraHeaders.Set(tokenHeader, token) return &clientWrapper{ DNSClient: p.baseClient.WithHeaders(extraHeaders), }, nil } func obtainOpenstackToken(config *Config) (string, error) { vpcClient, err := selvpcclient.NewClient(&selvpcclient.ClientOptions{ Username: config.Username, Password: config.Password, UserDomainName: config.Account, ProjectID: config.ProjectID, }) if err != nil { return "", fmt.Errorf("new VPC client: %w", err) } return vpcClient.GetXAuthToken(), nil } type clientWrapper struct { selectelapi.DNSClient[selectelapi.Zone, selectelapi.RRSet] } func (w *clientWrapper) getZone(ctx context.Context, name string) (*selectelapi.Zone, error) { unicodeName, err := idna.ToUnicode(name) if err != nil { return nil, fmt.Errorf("to unicode: %w", err) } params := &map[string]string{"filter": unicodeName} zones, err := w.ListZones(ctx, params) if err != nil { return nil, fmt.Errorf("list zone: %w", err) } for _, zone := range zones.GetItems() { if zone.Name == dns01.ToFqdn(unicodeName) { return zone, nil } } if len(strings.Split(dns01.UnFqdn(name), ".")) == 1 { return nil, fmt.Errorf("zone '%s' for challenge has not been found", name) } // -1 can not be returned since if no dots present we exit above i := strings.Index(name, ".") return w.getZone(ctx, name[i+1:]) } func (w *clientWrapper) getRRset(ctx context.Context, name, zoneID string) (*selectelapi.RRSet, error) { unicodeName, err := idna.ToUnicode(name) if err != nil { return nil, fmt.Errorf("to unicode: %w", err) } params := &map[string]string{"name": unicodeName, "rrset_types": string(selectelapi.TXT)} resp, err := w.ListRRSets(ctx, zoneID, params) if err != nil { return nil, fmt.Errorf("list rrset: %w", err) } for _, rrset := range resp.GetItems() { if rrset.Name == dns01.ToFqdn(unicodeName) { return rrset, nil } } return nil, errNotFound }