yandexcloud.go raw

   1  // Package yandexcloud implements a DNS provider for solving the DNS-01 challenge using Yandex Cloud.
   2  package yandexcloud
   3  
   4  import (
   5  	"context"
   6  	"encoding/base64"
   7  	"encoding/json"
   8  	"errors"
   9  	"fmt"
  10  	"slices"
  11  	"strings"
  12  	"time"
  13  
  14  	"github.com/go-acme/lego/v4/challenge"
  15  	"github.com/go-acme/lego/v4/challenge/dns01"
  16  	"github.com/go-acme/lego/v4/platform/config/env"
  17  	ycdnsproto "github.com/yandex-cloud/go-genproto/yandex/cloud/dns/v1"
  18  	ycdns "github.com/yandex-cloud/go-sdk/services/dns/v1"
  19  	ycsdk "github.com/yandex-cloud/go-sdk/v2"
  20  	"github.com/yandex-cloud/go-sdk/v2/credentials"
  21  	"github.com/yandex-cloud/go-sdk/v2/pkg/iamkey"
  22  	"github.com/yandex-cloud/go-sdk/v2/pkg/options"
  23  )
  24  
  25  // Environment variables names.
  26  const (
  27  	envNamespace = "YANDEX_CLOUD_"
  28  
  29  	EnvIamToken = envNamespace + "IAM_TOKEN"
  30  	EnvFolderID = envNamespace + "FOLDER_ID"
  31  
  32  	EnvTTL                = envNamespace + "TTL"
  33  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  34  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  35  )
  36  
  37  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  38  
  39  // Config is used to configure the creation of the DNSProvider.
  40  type Config struct {
  41  	IamToken string
  42  	FolderID string
  43  
  44  	PropagationTimeout time.Duration
  45  	PollingInterval    time.Duration
  46  	TTL                int
  47  }
  48  
  49  // NewDefaultConfig returns a default configuration for the DNSProvider.
  50  func NewDefaultConfig() *Config {
  51  	return &Config{
  52  		TTL:                env.GetOrDefaultInt(EnvTTL, 60),
  53  		PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout),
  54  		PollingInterval:    env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval),
  55  	}
  56  }
  57  
  58  // DNSProvider implements the challenge.Provider interface.
  59  type DNSProvider struct {
  60  	client ycdns.DnsZoneClient
  61  	config *Config
  62  }
  63  
  64  // NewDNSProvider returns a DNSProvider instance configured for Yandex Cloud.
  65  func NewDNSProvider() (*DNSProvider, error) {
  66  	values, err := env.Get(EnvIamToken, EnvFolderID)
  67  	if err != nil {
  68  		return nil, fmt.Errorf("yandexcloud: %w", err)
  69  	}
  70  
  71  	config := NewDefaultConfig()
  72  	config.IamToken = values[EnvIamToken]
  73  	config.FolderID = values[EnvFolderID]
  74  
  75  	return NewDNSProviderConfig(config)
  76  }
  77  
  78  // NewDNSProviderConfig return a DNSProvider instance configured for Yandex Cloud.
  79  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
  80  	if config == nil {
  81  		return nil, errors.New("yandexcloud: the configuration of the DNS provider is nil")
  82  	}
  83  
  84  	if config.IamToken == "" {
  85  		return nil, errors.New("yandexcloud: some credentials information are missing IAM token")
  86  	}
  87  
  88  	if config.FolderID == "" {
  89  		return nil, errors.New("yandexcloud: some credentials information are missing folder id")
  90  	}
  91  
  92  	creds, err := decodeCredentials(config.IamToken)
  93  	if err != nil {
  94  		return nil, fmt.Errorf("yandexcloud: iam token is malformed: %w", err)
  95  	}
  96  
  97  	sdk, err := ycsdk.Build(context.Background(), options.WithCredentials(creds))
  98  	if err != nil {
  99  		return nil, errors.New("yandexcloud: unable to build yandex cloud sdk")
 100  	}
 101  
 102  	return &DNSProvider{
 103  		client: ycdns.NewDnsZoneClient(sdk),
 104  		config: config,
 105  	}, nil
 106  }
 107  
 108  // Present creates a TXT record to fulfill the dns-01 challenge.
 109  func (d *DNSProvider) Present(domain, _, keyAuth string) error {
 110  	info := dns01.GetChallengeInfo(domain, keyAuth)
 111  
 112  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 113  	if err != nil {
 114  		return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err)
 115  	}
 116  
 117  	ctx := context.Background()
 118  
 119  	zones, err := d.getZones(ctx)
 120  	if err != nil {
 121  		return fmt.Errorf("yandexcloud: %w", err)
 122  	}
 123  
 124  	var zoneID string
 125  
 126  	for _, zone := range zones {
 127  		if zone.GetZone() == authZone {
 128  			zoneID = zone.GetId()
 129  		}
 130  	}
 131  
 132  	if zoneID == "" {
 133  		return fmt.Errorf("yandexcloud: cant find dns zone %s in yandex cloud", authZone)
 134  	}
 135  
 136  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 137  	if err != nil {
 138  		return fmt.Errorf("yandexcloud: %w", err)
 139  	}
 140  
 141  	err = d.upsertRecordSetData(ctx, zoneID, subDomain, info.Value)
 142  	if err != nil {
 143  		return fmt.Errorf("yandexcloud: %w", err)
 144  	}
 145  
 146  	return nil
 147  }
 148  
 149  // CleanUp removes the TXT record matching the specified parameters.
 150  func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error {
 151  	info := dns01.GetChallengeInfo(domain, keyAuth)
 152  
 153  	authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN)
 154  	if err != nil {
 155  		return fmt.Errorf("yandexcloud: could not find zone for domain %q: %w", domain, err)
 156  	}
 157  
 158  	ctx := context.Background()
 159  
 160  	zones, err := d.getZones(ctx)
 161  	if err != nil {
 162  		return fmt.Errorf("yandexcloud: %w", err)
 163  	}
 164  
 165  	var zoneID string
 166  
 167  	for _, zone := range zones {
 168  		if zone.GetZone() == authZone {
 169  			zoneID = zone.GetId()
 170  		}
 171  	}
 172  
 173  	if zoneID == "" {
 174  		return nil
 175  	}
 176  
 177  	subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
 178  	if err != nil {
 179  		return fmt.Errorf("yandexcloud: %w", err)
 180  	}
 181  
 182  	err = d.removeRecordSetData(ctx, zoneID, subDomain, info.Value)
 183  	if err != nil {
 184  		return fmt.Errorf("yandexcloud: %w", err)
 185  	}
 186  
 187  	return nil
 188  }
 189  
 190  // Timeout returns the timeout and interval to use when checking for DNS propagation.
 191  // Adjusting here to cope with spikes in propagation times.
 192  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 193  	return d.config.PropagationTimeout, d.config.PollingInterval
 194  }
 195  
 196  // getZones retrieves available zones from yandex cloud.
 197  func (d *DNSProvider) getZones(ctx context.Context) ([]*ycdnsproto.DnsZone, error) {
 198  	list := &ycdnsproto.ListDnsZonesRequest{
 199  		FolderId: d.config.FolderID,
 200  	}
 201  
 202  	response, err := d.client.List(ctx, list)
 203  	if err != nil {
 204  		return nil, errors.New("unable to fetch dns zones")
 205  	}
 206  
 207  	return response.GetDnsZones(), nil
 208  }
 209  
 210  func (d *DNSProvider) upsertRecordSetData(ctx context.Context, zoneID, name, value string) error {
 211  	get := &ycdnsproto.GetDnsZoneRecordSetRequest{
 212  		DnsZoneId: zoneID,
 213  		Name:      name,
 214  		Type:      "TXT",
 215  	}
 216  
 217  	exist, err := d.client.GetRecordSet(ctx, get)
 218  	if err != nil {
 219  		if !strings.Contains(err.Error(), "RecordSet not found") {
 220  			return err
 221  		}
 222  	}
 223  
 224  	record := &ycdnsproto.RecordSet{
 225  		Name: name,
 226  		Type: "TXT",
 227  		Ttl:  int64(d.config.TTL),
 228  		Data: []string{},
 229  	}
 230  
 231  	var deletions []*ycdnsproto.RecordSet
 232  
 233  	if exist != nil {
 234  		record.SetData(append(record.GetData(), exist.GetData()...))
 235  		deletions = append(deletions, exist)
 236  	}
 237  
 238  	appended := appendRecordSetData(record, value)
 239  	if !appended {
 240  		// The value already present in RecordSet, nothing to do
 241  		return nil
 242  	}
 243  
 244  	update := &ycdnsproto.UpdateRecordSetsRequest{
 245  		DnsZoneId: zoneID,
 246  		Deletions: deletions,
 247  		Additions: []*ycdnsproto.RecordSet{record},
 248  	}
 249  
 250  	_, err = d.client.UpdateRecordSets(ctx, update)
 251  
 252  	return err
 253  }
 254  
 255  func (d *DNSProvider) removeRecordSetData(ctx context.Context, zoneID, name, value string) error {
 256  	get := &ycdnsproto.GetDnsZoneRecordSetRequest{
 257  		DnsZoneId: zoneID,
 258  		Name:      name,
 259  		Type:      "TXT",
 260  	}
 261  
 262  	previousRecord, err := d.client.GetRecordSet(ctx, get)
 263  	if err != nil {
 264  		if strings.Contains(err.Error(), "RecordSet not found") {
 265  			// RecordSet is not present, nothing to do
 266  			return nil
 267  		}
 268  
 269  		return err
 270  	}
 271  
 272  	var additions []*ycdnsproto.RecordSet
 273  
 274  	if len(previousRecord.GetData()) > 1 {
 275  		// RecordSet is not empty we should update it
 276  		record := &ycdnsproto.RecordSet{
 277  			Name: name,
 278  			Type: "TXT",
 279  			Ttl:  int64(d.config.TTL),
 280  			Data: []string{},
 281  		}
 282  
 283  		for _, data := range previousRecord.GetData() {
 284  			if data != value {
 285  				record.SetData(append(record.GetData(), data))
 286  			}
 287  		}
 288  
 289  		additions = append(additions, record)
 290  	}
 291  
 292  	update := &ycdnsproto.UpdateRecordSetsRequest{
 293  		DnsZoneId: zoneID,
 294  		Deletions: []*ycdnsproto.RecordSet{previousRecord},
 295  		Additions: additions,
 296  	}
 297  
 298  	_, err = d.client.UpdateRecordSets(ctx, update)
 299  
 300  	return err
 301  }
 302  
 303  // decodeCredentials converts base64 encoded json of iam token to struct.
 304  func decodeCredentials(accountB64 string) (credentials.Credentials, error) {
 305  	account, err := base64.StdEncoding.DecodeString(accountB64)
 306  	if err != nil {
 307  		return nil, err
 308  	}
 309  
 310  	key := &iamkey.Key{}
 311  
 312  	err = json.Unmarshal(account, key)
 313  	if err != nil {
 314  		return nil, err
 315  	}
 316  
 317  	return credentials.ServiceAccountKey(key)
 318  }
 319  
 320  func appendRecordSetData(record *ycdnsproto.RecordSet, value string) bool {
 321  	if slices.Contains(record.GetData(), value) {
 322  		return false
 323  	}
 324  
 325  	record.SetData(append(record.GetData(), value))
 326  
 327  	return true
 328  }
 329