vkcloud.go raw

   1  // Package vkcloud implements a DNS provider for solving the DNS-01 challenge using VK Cloud.
   2  package vkcloud
   3  
   4  import (
   5  	"errors"
   6  	"fmt"
   7  	"time"
   8  
   9  	"github.com/go-acme/lego/v4/challenge"
  10  	"github.com/go-acme/lego/v4/challenge/dns01"
  11  	"github.com/go-acme/lego/v4/platform/config/env"
  12  	"github.com/go-acme/lego/v4/providers/dns/vkcloud/internal"
  13  	"github.com/gophercloud/gophercloud"
  14  )
  15  
  16  // Environment variables names.
  17  const (
  18  	envNamespace = "VK_CLOUD_"
  19  
  20  	EnvDNSEndpoint = envNamespace + "DNS_ENDPOINT"
  21  
  22  	EnvIdentityEndpoint = envNamespace + "IDENTITY_ENDPOINT"
  23  	EnvDomainName       = envNamespace + "DOMAIN_NAME"
  24  
  25  	EnvProjectID = envNamespace + "PROJECT_ID"
  26  	EnvUsername  = envNamespace + "USERNAME"
  27  	EnvPassword  = envNamespace + "PASSWORD"
  28  
  29  	EnvTTL                = envNamespace + "TTL"
  30  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  31  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  32  )
  33  
  34  const (
  35  	defaultIdentityEndpoint = "https://infra.mail.ru/identity/v3/"
  36  	defaultDNSEndpoint      = "https://mcs.mail.ru/public-dns/v2/dns"
  37  )
  38  
  39  const defaultDomainName = "users"
  40  
  41  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  42  
  43  // Config is used to configure the creation of the DNSProvider.
  44  type Config struct {
  45  	ProjectID string
  46  	Username  string
  47  	Password  string
  48  
  49  	DNSEndpoint string
  50  
  51  	IdentityEndpoint string
  52  	DomainName       string
  53  
  54  	PropagationTimeout time.Duration
  55  	PollingInterval    time.Duration
  56  	TTL                int
  57  }
  58  
  59  // NewDefaultConfig returns a default configuration for the DNSProvider.
  60  func NewDefaultConfig() *Config {
  61  	return &Config{
  62  		TTL:                env.GetOrDefaultInt(EnvTTL, 60),
  63  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  64  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  65  	}
  66  }
  67  
  68  // DNSProvider implements the challenge.Provider interface.
  69  type DNSProvider struct {
  70  	client *internal.Client
  71  	config *Config
  72  }
  73  
  74  // NewDNSProvider returns a DNSProvider instance configured for VK Cloud.
  75  func NewDNSProvider() (*DNSProvider, error) {
  76  	values, err := env.Get(EnvProjectID, EnvUsername, EnvPassword)
  77  	if err != nil {
  78  		return nil, fmt.Errorf("vkcloud: %w", err)
  79  	}
  80  
  81  	config := NewDefaultConfig()
  82  	config.ProjectID = values[EnvProjectID]
  83  	config.Username = values[EnvUsername]
  84  	config.Password = values[EnvPassword]
  85  	config.IdentityEndpoint = env.GetOrDefaultString(EnvIdentityEndpoint, defaultIdentityEndpoint)
  86  	config.DomainName = env.GetOrDefaultString(EnvDomainName, defaultDomainName)
  87  	config.DNSEndpoint = env.GetOrDefaultString(EnvDNSEndpoint, defaultDNSEndpoint)
  88  
  89  	return NewDNSProviderConfig(config)
  90  }
  91  
  92  // NewDNSProviderConfig return a DNSProvider instance configured for VK Cloud.
  93  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  94  	if config == nil {
  95  		return nil, errors.New("vkcloud: the configuration of the DNS provider is nil")
  96  	}
  97  
  98  	if config.DNSEndpoint == "" {
  99  		return nil, errors.New("vkcloud: DNS endpoint is missing in config")
 100  	}
 101  
 102  	authOpts := gophercloud.AuthOptions{
 103  		IdentityEndpoint: config.IdentityEndpoint,
 104  		Username:         config.Username,
 105  		Password:         config.Password,
 106  		DomainName:       config.DomainName,
 107  		TenantID:         config.ProjectID,
 108  	}
 109  
 110  	client, err := internal.NewClient(config.DNSEndpoint, authOpts)
 111  	if err != nil {
 112  		return nil, fmt.Errorf("vkcloud: unable to build VK Cloud client: %w", err)
 113  	}
 114  
 115  	return &DNSProvider{
 116  		client: client,
 117  		config: config,
 118  	}, nil
 119  }
 120  
 121  // Present creates a TXT record to fulfill the dns-01 challenge.
 122  func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 123  	info := dns01.GetChallengeInfo(domain, keyAuth)
 124  
 125  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 126  	if err != nil {
 127  		return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err)
 128  	}
 129  
 130  	authZone = dns01.UnFqdn(authZone)
 131  
 132  	zones, err := d.client.ListZones()
 133  	if err != nil {
 134  		return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err)
 135  	}
 136  
 137  	var zoneUUID string
 138  
 139  	for _, zone := range zones {
 140  		if zone.Zone == authZone {
 141  			zoneUUID = zone.UUID
 142  		}
 143  	}
 144  
 145  	if zoneUUID == "" {
 146  		return fmt.Errorf("vkcloud: cant find dns zone %s in VK Cloud", authZone)
 147  	}
 148  
 149  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 150  	if err != nil {
 151  		return fmt.Errorf("vkcloud: %w", err)
 152  	}
 153  
 154  	err = d.upsertTXTRecord(zoneUUID, subDomain, info.Value)
 155  	if err != nil {
 156  		return fmt.Errorf("vkcloud: %w", err)
 157  	}
 158  
 159  	return nil
 160  }
 161  
 162  // CleanUp removes the TXT record matching the specified parameters.
 163  func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 164  	info := dns01.GetChallengeInfo(domain, keyAuth)
 165  
 166  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 167  	if err != nil {
 168  		return fmt.Errorf("vkcloud: could not find zone for domain %q: %w", domain, err)
 169  	}
 170  
 171  	authZone = dns01.UnFqdn(authZone)
 172  
 173  	zones, err := d.client.ListZones()
 174  	if err != nil {
 175  		return fmt.Errorf("vkcloud: unable to fetch dns zones: %w", err)
 176  	}
 177  
 178  	var zoneUUID string
 179  
 180  	for _, zone := range zones {
 181  		if zone.Zone == authZone {
 182  			zoneUUID = zone.UUID
 183  		}
 184  	}
 185  
 186  	if zoneUUID == "" {
 187  		return nil
 188  	}
 189  
 190  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 191  	if err != nil {
 192  		return fmt.Errorf("vkcloud: %w", err)
 193  	}
 194  
 195  	err = d.removeTXTRecord(zoneUUID, subDomain, info.Value)
 196  	if err != nil {
 197  		return fmt.Errorf("vkcloud: %w", err)
 198  	}
 199  
 200  	return nil
 201  }
 202  
 203  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 204  // Adjusting here to cope with spikes in propagation times.
 205  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 206  	return d.config.PropagationTimeout, d.config.PollingInterval
 207  }
 208  
 209  func (d *DNSProvider) upsertTXTRecord(zoneUUID, name, value string) error {
 210  	records, err := d.client.ListTXTRecords(zoneUUID)
 211  	if err != nil {
 212  		return err
 213  	}
 214  
 215  	for _, record := range records {
 216  		if record.Name == name && record.Content == value {
 217  			// The DNSRecord is already present, nothing to do
 218  			return nil
 219  		}
 220  	}
 221  
 222  	return d.client.CreateTXTRecord(zoneUUID, &internal.DNSTXTRecord{
 223  		Name:    name,
 224  		Content: value,
 225  		TTL:     d.config.TTL,
 226  	})
 227  }
 228  
 229  func (d *DNSProvider) removeTXTRecord(zoneUUID, name, value string) error {
 230  	records, err := d.client.ListTXTRecords(zoneUUID)
 231  	if err != nil {
 232  		return err
 233  	}
 234  
 235  	name = dns01.UnFqdn(name)
 236  	for _, record := range records {
 237  		if record.Name == name && record.Content == value {
 238  			return d.client.DeleteTXTRecord(zoneUUID, record.UUID)
 239  		}
 240  	}
 241  
 242  	// The DNSRecord is not present, nothing to do
 243  	return nil
 244  }
 245