googlecloud.go raw

   1  // Package gcloud implements a DNS provider for solving the DNS-01 challenge using Google Cloud DNS.
   2  package gcloud
   3  
   4  import (
   5  	"context"
   6  	"encoding/json"
   7  	"errors"
   8  	"fmt"
   9  	"net/http"
  10  	"os"
  11  	"strconv"
  12  	"time"
  13  
  14  	"cloud.google.com/go/compute/metadata"
  15  	"github.com/cenkalti/backoff/v5"
  16  	"github.com/go-acme/lego/v4/challenge"
  17  	"github.com/go-acme/lego/v4/challenge/dns01"
  18  	"github.com/go-acme/lego/v4/log"
  19  	"github.com/go-acme/lego/v4/platform/config/env"
  20  	"github.com/go-acme/lego/v4/platform/wait"
  21  	"github.com/go-acme/lego/v4/providers/dns/internal/clientdebug"
  22  	"github.com/miekg/dns"
  23  	"golang.org/x/oauth2"
  24  	"golang.org/x/oauth2/google"
  25  	gdns "google.golang.org/api/dns/v1"
  26  	"google.golang.org/api/googleapi"
  27  	"google.golang.org/api/impersonate"
  28  	"google.golang.org/api/option"
  29  )
  30  
  31  // Environment variables names.
  32  const (
  33  	envNamespace = "GCE_"
  34  
  35  	EnvServiceAccount            = envNamespace + "SERVICE_ACCOUNT"
  36  	EnvProject                   = envNamespace + "PROJECT"
  37  	EnvZoneID                    = envNamespace + "ZONE_ID"
  38  	EnvAllowPrivateZone          = envNamespace + "ALLOW_PRIVATE_ZONE"
  39  	EnvDebug                     = envNamespace + "DEBUG"
  40  	EnvImpersonateServiceAccount = envNamespace + "IMPERSONATE_SERVICE_ACCOUNT"
  41  
  42  	EnvTTL                = envNamespace + "TTL"
  43  	EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
  44  	EnvPollingInterval    = envNamespace + "POLLING_INTERVAL"
  45  )
  46  
  47  const changeStatusDone = "done"
  48  
  49  var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
  50  
  51  // Config is used to configure the creation of the DNSProvider.
  52  type Config struct {
  53  	Debug                     bool
  54  	Project                   string
  55  	ZoneID                    string
  56  	AllowPrivateZone          bool
  57  	ImpersonateServiceAccount string
  58  	PropagationTimeout        time.Duration
  59  	PollingInterval           time.Duration
  60  	TTL                       int
  61  	HTTPClient                *http.Client
  62  }
  63  
  64  // NewDefaultConfig returns a default configuration for the DNSProvider.
  65  func NewDefaultConfig() *Config {
  66  	return &Config{
  67  		Debug:                     env.GetOrDefaultBool(EnvDebug, false),
  68  		ZoneID:                    env.GetOrDefaultString(EnvZoneID, ""),
  69  		AllowPrivateZone:          env.GetOrDefaultBool(EnvAllowPrivateZone, false),
  70  		ImpersonateServiceAccount: env.GetOrDefaultString(EnvImpersonateServiceAccount, ""),
  71  		TTL:                       env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
  72  		PropagationTimeout:        env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second),
  73  		PollingInterval:           env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
  74  	}
  75  }
  76  
  77  // DNSProvider implements the challenge.Provider interface.
  78  type DNSProvider struct {
  79  	config *Config
  80  	client *gdns.Service
  81  }
  82  
  83  // NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS.
  84  // By default, the project name is auto-detected by using the metadata service,
  85  // it can be overridden using the GCE_PROJECT environment variable.
  86  // A Service Account can be passed in the environment variable: GCE_SERVICE_ACCOUNT
  87  // or by specifying the keyfile location: GCE_SERVICE_ACCOUNT_FILE.
  88  func NewDNSProvider() (*DNSProvider, error) {
  89  	// Use a service account file if specified via environment variable.
  90  	if saKey := env.GetOrFile(EnvServiceAccount); saKey != "" {
  91  		return NewDNSProviderServiceAccountKey([]byte(saKey))
  92  	}
  93  
  94  	// Use default credentials.
  95  	project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background()))
  96  
  97  	return NewDNSProviderCredentials(project)
  98  }
  99  
 100  // NewDNSProviderCredentials uses the supplied credentials
 101  // to return a DNSProvider instance configured for Google Cloud DNS.
 102  func NewDNSProviderCredentials(project string) (*DNSProvider, error) {
 103  	if project == "" {
 104  		return nil, errors.New("googlecloud: project name missing")
 105  	}
 106  
 107  	config := NewDefaultConfig()
 108  	config.Project = project
 109  
 110  	var err error
 111  
 112  	config.HTTPClient, err = newClientFromCredentials(context.Background(), config)
 113  	if err != nil {
 114  		return nil, fmt.Errorf("googlecloud: %w", err)
 115  	}
 116  
 117  	return NewDNSProviderConfig(config)
 118  }
 119  
 120  // NewDNSProviderServiceAccountKey uses the supplied service account JSON
 121  // to return a DNSProvider instance configured for Google Cloud DNS.
 122  func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) {
 123  	if len(saKey) == 0 {
 124  		return nil, errors.New("googlecloud: Service Account is missing")
 125  	}
 126  
 127  	// If GCE_PROJECT is non-empty it overrides the project in the service
 128  	// account file.
 129  	project := env.GetOrDefaultString(EnvProject, "")
 130  	if project == "" {
 131  		// read project id from service account file
 132  		var datJSON struct {
 133  			ProjectID string `json:"project_id"`
 134  		}
 135  
 136  		err := json.Unmarshal(saKey, &datJSON)
 137  		if err != nil || datJSON.ProjectID == "" {
 138  			return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file")
 139  		}
 140  
 141  		project = datJSON.ProjectID
 142  	}
 143  
 144  	config := NewDefaultConfig()
 145  	config.Project = project
 146  
 147  	var err error
 148  
 149  	config.HTTPClient, err = newClientFromServiceAccountKey(context.Background(), config, saKey)
 150  	if err != nil {
 151  		return nil, fmt.Errorf("googlecloud: %w", err)
 152  	}
 153  
 154  	return NewDNSProviderConfig(config)
 155  }
 156  
 157  // NewDNSProviderServiceAccount uses the supplied service account JSON file
 158  // to return a DNSProvider instance configured for Google Cloud DNS.
 159  func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) {
 160  	if saFile == "" {
 161  		return nil, errors.New("googlecloud: Service Account file missing")
 162  	}
 163  
 164  	saKey, err := os.ReadFile(saFile)
 165  	if err != nil {
 166  		return nil, fmt.Errorf("googlecloud: unable to read Service Account file: %w", err)
 167  	}
 168  
 169  	return NewDNSProviderServiceAccountKey(saKey)
 170  }
 171  
 172  // NewDNSProviderConfig return a DNSProvider instance configured for Google Cloud DNS.
 173  func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
 174  	if config == nil {
 175  		return nil, errors.New("googlecloud: the configuration of the DNS provider is nil")
 176  	}
 177  
 178  	if config.HTTPClient == nil {
 179  		return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil")
 180  	}
 181  
 182  	svc, err := gdns.NewService(context.Background(), option.WithHTTPClient(clientdebug.Wrap(config.HTTPClient)))
 183  	if err != nil {
 184  		return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err)
 185  	}
 186  
 187  	return &DNSProvider{config: config, client: svc}, nil
 188  }
 189  
 190  // Present creates a TXT record to fulfill the dns-01 challenge.
 191  func (d *DNSProvider) Present(domain, token, keyAuth string) error {
 192  	ctx := context.Background()
 193  
 194  	info := dns01.GetChallengeInfo(domain, keyAuth)
 195  
 196  	zone, err := d.getHostedZone(info.EffectiveFQDN)
 197  	if err != nil {
 198  		return fmt.Errorf("googlecloud: %w", err)
 199  	}
 200  
 201  	// Look for existing records.
 202  	existingRrSet, err := d.findTxtRecords(zone, info.EffectiveFQDN)
 203  	if err != nil {
 204  		return fmt.Errorf("googlecloud: %w", err)
 205  	}
 206  
 207  	for _, rrSet := range existingRrSet {
 208  		var rrd []string
 209  
 210  		for _, rr := range rrSet.Rrdatas {
 211  			data := mustUnquote(rr)
 212  			rrd = append(rrd, data)
 213  
 214  			if data == info.Value {
 215  				log.Printf("skip: the record already exists: %s", info.Value)
 216  				return nil
 217  			}
 218  		}
 219  
 220  		rrSet.Rrdatas = rrd
 221  	}
 222  
 223  	// Attempt to delete the existing records before adding the new one.
 224  	if len(existingRrSet) > 0 {
 225  		if err = d.applyChanges(ctx, zone, &gdns.Change{Deletions: existingRrSet}); err != nil {
 226  			return fmt.Errorf("googlecloud: %w", err)
 227  		}
 228  	}
 229  
 230  	rec := &gdns.ResourceRecordSet{
 231  		Name:    info.EffectiveFQDN,
 232  		Rrdatas: []string{info.Value},
 233  		Ttl:     int64(d.config.TTL),
 234  		Type:    "TXT",
 235  	}
 236  
 237  	// Append existing TXT record data to the new TXT record data
 238  	for _, rrSet := range existingRrSet {
 239  		for _, rr := range rrSet.Rrdatas {
 240  			if rr != info.Value {
 241  				rec.Rrdatas = append(rec.Rrdatas, rr)
 242  			}
 243  		}
 244  	}
 245  
 246  	change := &gdns.Change{
 247  		Additions: []*gdns.ResourceRecordSet{rec},
 248  	}
 249  
 250  	if err = d.applyChanges(ctx, zone, change); err != nil {
 251  		return fmt.Errorf("googlecloud: %w", err)
 252  	}
 253  
 254  	return nil
 255  }
 256  
 257  func (d *DNSProvider) applyChanges(ctx context.Context, zone string, change *gdns.Change) error {
 258  	if d.config.Debug {
 259  		data, _ := json.Marshal(change)
 260  		log.Printf("change (Create): %s", string(data))
 261  	}
 262  
 263  	chg, err := d.client.Changes.Create(d.config.Project, zone, change).Do()
 264  	if err != nil {
 265  		var v *googleapi.Error
 266  		if errors.As(err, &v) && v.Code == http.StatusNotFound {
 267  			return nil
 268  		}
 269  
 270  		data, _ := json.Marshal(change)
 271  
 272  		return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err)
 273  	}
 274  
 275  	if chg.Status == changeStatusDone {
 276  		return nil
 277  	}
 278  
 279  	chgID := chg.Id
 280  
 281  	// wait for change to be acknowledged
 282  	return wait.Retry(ctx,
 283  		func() error {
 284  			if d.config.Debug {
 285  				data, _ := json.Marshal(change)
 286  				log.Printf("change (Get): %s", string(data))
 287  			}
 288  
 289  			chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do()
 290  			if err != nil {
 291  				data, _ := json.Marshal(change)
 292  				return fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err)
 293  			}
 294  
 295  			if chg.Status != changeStatusDone {
 296  				return fmt.Errorf("status: %s", chg.Status)
 297  			}
 298  
 299  			return nil
 300  		},
 301  		backoff.WithBackOff(backoff.NewConstantBackOff(3*time.Second)),
 302  		backoff.WithMaxElapsedTime(30*time.Second),
 303  	)
 304  }
 305  
 306  // CleanUp removes the TXT record matching the specified parameters.
 307  func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
 308  	info := dns01.GetChallengeInfo(domain, keyAuth)
 309  
 310  	zone, err := d.getHostedZone(info.EffectiveFQDN)
 311  	if err != nil {
 312  		return fmt.Errorf("googlecloud: %w", err)
 313  	}
 314  
 315  	records, err := d.findTxtRecords(zone, info.EffectiveFQDN)
 316  	if err != nil {
 317  		return fmt.Errorf("googlecloud: %w", err)
 318  	}
 319  
 320  	if len(records) == 0 {
 321  		return nil
 322  	}
 323  
 324  	_, err = d.client.Changes.Create(d.config.Project, zone, &gdns.Change{Deletions: records}).Do()
 325  	if err != nil {
 326  		return fmt.Errorf("googlecloud: %w", err)
 327  	}
 328  
 329  	return nil
 330  }
 331  
 332  // Timeout customizes the timeout values used by the ACME package for checking
 333  // DNS record validity.
 334  func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
 335  	return d.config.PropagationTimeout, d.config.PollingInterval
 336  }
 337  
 338  // getHostedZone returns the managed-zone.
 339  func (d *DNSProvider) getHostedZone(domain string) (string, error) {
 340  	authZone, zones, err := d.lookupHostedZoneID(domain)
 341  	if err != nil {
 342  		return "", err
 343  	}
 344  
 345  	if len(zones) == 0 {
 346  		return "", fmt.Errorf("no matching domain found for domain %s", authZone)
 347  	}
 348  
 349  	for _, z := range zones {
 350  		if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) {
 351  			return z.Name, nil
 352  		}
 353  	}
 354  
 355  	if d.config.AllowPrivateZone {
 356  		return "", fmt.Errorf("no public or private zone found for domain %s", authZone)
 357  	}
 358  
 359  	return "", fmt.Errorf("no public zone found for domain %s", authZone)
 360  }
 361  
 362  // lookupHostedZoneID finds the managed zone ID in Google.
 363  //
 364  // Be careful here.
 365  // An automated system might run in a GCloud Service Account, with access to edit the zone
 366  //
 367  //	(gcloud dns managed-zones get-iam-policy $zone_id) (role roles/dns.admin)
 368  //
 369  // but not with project-wide access to list all zones
 370  //
 371  //	(gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list)
 372  //
 373  // If we force a zone list to succeed, we demand more permissions than needed.
 374  func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*gdns.ManagedZone, error) {
 375  	// GCE_ZONE_ID override for service accounts to avoid needing zones-list permission
 376  	if d.config.ZoneID != "" {
 377  		zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do()
 378  		if err != nil {
 379  			return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone ID %q in project %q failed: %w", d.config.ZoneID, d.config.Project, err)
 380  		}
 381  
 382  		return zone.DnsName, []*gdns.ManagedZone{zone}, nil
 383  	}
 384  
 385  	authZone, err := dns01.FindZoneByFqdn(dns.Fqdn(domain))
 386  	if err != nil {
 387  		return "", nil, fmt.Errorf("could not find zone: %w", err)
 388  	}
 389  
 390  	zones, err := d.client.ManagedZones.
 391  		List(d.config.Project).
 392  		DnsName(authZone).
 393  		Do()
 394  	if err != nil {
 395  		return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err)
 396  	}
 397  
 398  	return authZone, zones.ManagedZones, nil
 399  }
 400  
 401  func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*gdns.ResourceRecordSet, error) {
 402  	recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do()
 403  	if err != nil {
 404  		return nil, err
 405  	}
 406  
 407  	return recs.Rrsets, nil
 408  }
 409  
 410  func newClientFromCredentials(ctx context.Context, config *Config) (*http.Client, error) {
 411  	if config.ImpersonateServiceAccount != "" {
 412  		ts, err := google.DefaultTokenSource(ctx, "https://www.googleapis.com/auth/cloud-platform")
 413  		if err != nil {
 414  			return nil, fmt.Errorf("unable to get default token source: %w", err)
 415  		}
 416  
 417  		return newImpersonateClient(ctx, config.ImpersonateServiceAccount, ts)
 418  	}
 419  
 420  	client, err := google.DefaultClient(ctx, gdns.NdevClouddnsReadwriteScope)
 421  	if err != nil {
 422  		return nil, fmt.Errorf("unable to get Google Cloud client: %w", err)
 423  	}
 424  
 425  	return client, nil
 426  }
 427  
 428  func newClientFromServiceAccountKey(ctx context.Context, config *Config, saKey []byte) (*http.Client, error) {
 429  	if config.ImpersonateServiceAccount != "" {
 430  		conf, err := google.JWTConfigFromJSON(saKey, "https://www.googleapis.com/auth/cloud-platform")
 431  		if err != nil {
 432  			return nil, fmt.Errorf("unable to acquire config: %w", err)
 433  		}
 434  
 435  		return newImpersonateClient(ctx, config.ImpersonateServiceAccount, conf.TokenSource(ctx))
 436  	}
 437  
 438  	conf, err := google.JWTConfigFromJSON(saKey, gdns.NdevClouddnsReadwriteScope)
 439  	if err != nil {
 440  		return nil, fmt.Errorf("unable to acquire config: %w", err)
 441  	}
 442  
 443  	return conf.Client(ctx), nil
 444  }
 445  
 446  func newImpersonateClient(ctx context.Context, impersonateServiceAccount string, ts oauth2.TokenSource) (*http.Client, error) {
 447  	impersonatedTS, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
 448  		TargetPrincipal: impersonateServiceAccount,
 449  		Scopes:          []string{gdns.NdevClouddnsReadwriteScope},
 450  	}, option.WithTokenSource(ts))
 451  	if err != nil {
 452  		return nil, fmt.Errorf("unable to create impersonated credentials: %w", err)
 453  	}
 454  
 455  	return oauth2.NewClient(ctx, impersonatedTS), nil
 456  }
 457  
 458  func mustUnquote(raw string) string {
 459  	clean, err := strconv.Unquote(raw)
 460  	if err != nil {
 461  		return raw
 462  	}
 463  
 464  	return clean
 465  }
 466  
 467  func autodetectProjectID(ctx context.Context) string {
 468  	if pid, err := metadata.ProjectIDWithContext(ctx); err == nil {
 469  		return pid
 470  	}
 471  
 472  	return ""
 473  }
 474