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