lego/providers/dns/selectelv2/selectelv2.go
2024-10-04 23:18:51 +02:00

294 lines
7.9 KiB
Go

// 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"
selectelapi "github.com/selectel/domains-go/pkg/v2"
"github.com/selectel/go-selvpcclient/v3/selvpcclient"
)
const tokenHeader = "X-Auth-Token"
const (
defaultBaseURL = "https://api.selectel.ru/domains/v2"
defaultTTL = 60
defaultPropagationTimeout = 120 * time.Second
defaultPollingInterval = 5 * time.Second
defaultHTTPTimeout = 30 * time.Second
)
const defaultUserAgent = "go-acme/lego"
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"
)
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{}
headers.Set("User-Agent", defaultUserAgent)
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) {
params := &map[string]string{"filter": name}
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(name) {
return zone, nil
}
}
if len(strings.Split(dns01.UnFqdn(name), ".")) == 1 {
return nil, errors.New("zone for challenge has not been found")
}
// -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) {
params := &map[string]string{"name": name, "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(name) {
return rrset, nil
}
}
return nil, errNotFound
}