forked from TrueCloudLab/lego
400 lines
12 KiB
Go
400 lines
12 KiB
Go
// Package gcloud implements a DNS provider for solving the DNS-01 challenge using Google Cloud DNS.
|
|
package gcloud
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"cloud.google.com/go/compute/metadata"
|
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
|
"github.com/go-acme/lego/v4/log"
|
|
"github.com/go-acme/lego/v4/platform/config/env"
|
|
"github.com/go-acme/lego/v4/platform/wait"
|
|
"golang.org/x/net/context"
|
|
"golang.org/x/oauth2/google"
|
|
"google.golang.org/api/dns/v1"
|
|
"google.golang.org/api/googleapi"
|
|
"google.golang.org/api/option"
|
|
)
|
|
|
|
const (
|
|
changeStatusDone = "done"
|
|
)
|
|
|
|
// Environment variables names.
|
|
const (
|
|
envNamespace = "GCE_"
|
|
|
|
EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT"
|
|
EnvProject = envNamespace + "PROJECT"
|
|
EnvZoneID = envNamespace + "ZONE_ID"
|
|
EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE"
|
|
EnvDebug = envNamespace + "DEBUG"
|
|
|
|
EnvTTL = envNamespace + "TTL"
|
|
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
|
|
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
|
|
)
|
|
|
|
// Config is used to configure the creation of the DNSProvider.
|
|
type Config struct {
|
|
Debug bool
|
|
Project string
|
|
ZoneID string
|
|
AllowPrivateZone bool
|
|
PropagationTimeout time.Duration
|
|
PollingInterval time.Duration
|
|
TTL int
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewDefaultConfig returns a default configuration for the DNSProvider.
|
|
func NewDefaultConfig() *Config {
|
|
return &Config{
|
|
Debug: env.GetOrDefaultBool(EnvDebug, false),
|
|
ZoneID: env.GetOrDefaultString(EnvZoneID, ""),
|
|
AllowPrivateZone: env.GetOrDefaultBool(EnvAllowPrivateZone, false),
|
|
TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL),
|
|
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 180*time.Second),
|
|
PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, 5*time.Second),
|
|
}
|
|
}
|
|
|
|
// DNSProvider implements the challenge.Provider interface.
|
|
type DNSProvider struct {
|
|
config *Config
|
|
client *dns.Service
|
|
}
|
|
|
|
// NewDNSProvider returns a DNSProvider instance configured for Google Cloud DNS.
|
|
// By default, the project name is auto-detected by using the metadata service,
|
|
// it can be overridden using the GCE_PROJECT environment variable.
|
|
// A Service Account can be passed in the environment variable: GCE_SERVICE_ACCOUNT
|
|
// or by specifying the keyfile location: GCE_SERVICE_ACCOUNT_FILE.
|
|
func NewDNSProvider() (*DNSProvider, error) {
|
|
// Use a service account file if specified via environment variable.
|
|
if saKey := env.GetOrFile(EnvServiceAccount); saKey != "" {
|
|
return NewDNSProviderServiceAccountKey([]byte(saKey))
|
|
}
|
|
|
|
// Use default credentials.
|
|
project := env.GetOrDefaultString(EnvProject, autodetectProjectID(context.Background()))
|
|
return NewDNSProviderCredentials(project)
|
|
}
|
|
|
|
// NewDNSProviderCredentials uses the supplied credentials
|
|
// to return a DNSProvider instance configured for Google Cloud DNS.
|
|
func NewDNSProviderCredentials(project string) (*DNSProvider, error) {
|
|
if project == "" {
|
|
return nil, errors.New("googlecloud: project name missing")
|
|
}
|
|
|
|
client, err := google.DefaultClient(context.Background(), dns.NdevClouddnsReadwriteScope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("googlecloud: unable to get Google Cloud client: %w", err)
|
|
}
|
|
|
|
config := NewDefaultConfig()
|
|
config.Project = project
|
|
config.HTTPClient = client
|
|
|
|
return NewDNSProviderConfig(config)
|
|
}
|
|
|
|
// NewDNSProviderServiceAccountKey uses the supplied service account JSON
|
|
// to return a DNSProvider instance configured for Google Cloud DNS.
|
|
func NewDNSProviderServiceAccountKey(saKey []byte) (*DNSProvider, error) {
|
|
if len(saKey) == 0 {
|
|
return nil, errors.New("googlecloud: Service Account is missing")
|
|
}
|
|
|
|
// If GCE_PROJECT is non-empty it overrides the project in the service
|
|
// account file.
|
|
project := env.GetOrDefaultString(EnvProject, "")
|
|
if project == "" {
|
|
// read project id from service account file
|
|
var datJSON struct {
|
|
ProjectID string `json:"project_id"`
|
|
}
|
|
err := json.Unmarshal(saKey, &datJSON)
|
|
if err != nil || datJSON.ProjectID == "" {
|
|
return nil, errors.New("googlecloud: project ID not found in Google Cloud Service Account file")
|
|
}
|
|
project = datJSON.ProjectID
|
|
}
|
|
|
|
conf, err := google.JWTConfigFromJSON(saKey, dns.NdevClouddnsReadwriteScope)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("googlecloud: unable to acquire config: %w", err)
|
|
}
|
|
client := conf.Client(context.Background())
|
|
|
|
config := NewDefaultConfig()
|
|
config.Project = project
|
|
config.HTTPClient = client
|
|
|
|
return NewDNSProviderConfig(config)
|
|
}
|
|
|
|
// NewDNSProviderServiceAccount uses the supplied service account JSON file
|
|
// to return a DNSProvider instance configured for Google Cloud DNS.
|
|
func NewDNSProviderServiceAccount(saFile string) (*DNSProvider, error) {
|
|
if saFile == "" {
|
|
return nil, errors.New("googlecloud: Service Account file missing")
|
|
}
|
|
|
|
saKey, err := os.ReadFile(saFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("googlecloud: unable to read Service Account file: %w", err)
|
|
}
|
|
|
|
return NewDNSProviderServiceAccountKey(saKey)
|
|
}
|
|
|
|
// NewDNSProviderConfig return a DNSProvider instance configured for Google Cloud DNS.
|
|
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
|
|
if config == nil {
|
|
return nil, errors.New("googlecloud: the configuration of the DNS provider is nil")
|
|
}
|
|
if config.HTTPClient == nil {
|
|
return nil, errors.New("googlecloud: unable to create Google Cloud DNS service: client is nil")
|
|
}
|
|
|
|
svc, err := dns.NewService(context.Background(), option.WithHTTPClient(config.HTTPClient))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("googlecloud: unable to create Google Cloud DNS service: %w", err)
|
|
}
|
|
|
|
return &DNSProvider{config: config, client: svc}, nil
|
|
}
|
|
|
|
// Present creates a TXT record to fulfill the dns-01 challenge.
|
|
func (d *DNSProvider) Present(domain, token, keyAuth string) error {
|
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
|
|
|
zone, err := d.getHostedZone(info.EffectiveFQDN)
|
|
if err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
|
|
// Look for existing records.
|
|
existingRrSet, err := d.findTxtRecords(zone, info.EffectiveFQDN)
|
|
if err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
|
|
for _, rrSet := range existingRrSet {
|
|
var rrd []string
|
|
for _, rr := range rrSet.Rrdatas {
|
|
data := mustUnquote(rr)
|
|
rrd = append(rrd, data)
|
|
|
|
if data == info.Value {
|
|
log.Printf("skip: the record already exists: %s", info.Value)
|
|
return nil
|
|
}
|
|
}
|
|
rrSet.Rrdatas = rrd
|
|
}
|
|
|
|
// Attempt to delete the existing records before adding the new one.
|
|
if len(existingRrSet) > 0 {
|
|
if err = d.applyChanges(zone, &dns.Change{Deletions: existingRrSet}); err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
}
|
|
|
|
rec := &dns.ResourceRecordSet{
|
|
Name: info.EffectiveFQDN,
|
|
Rrdatas: []string{info.Value},
|
|
Ttl: int64(d.config.TTL),
|
|
Type: "TXT",
|
|
}
|
|
|
|
// Append existing TXT record data to the new TXT record data
|
|
for _, rrSet := range existingRrSet {
|
|
for _, rr := range rrSet.Rrdatas {
|
|
if rr != info.Value {
|
|
rec.Rrdatas = append(rec.Rrdatas, rr)
|
|
}
|
|
}
|
|
}
|
|
|
|
change := &dns.Change{
|
|
Additions: []*dns.ResourceRecordSet{rec},
|
|
}
|
|
|
|
if err = d.applyChanges(zone, change); err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *DNSProvider) applyChanges(zone string, change *dns.Change) error {
|
|
if d.config.Debug {
|
|
data, _ := json.Marshal(change)
|
|
log.Printf("change (Create): %s", string(data))
|
|
}
|
|
|
|
chg, err := d.client.Changes.Create(d.config.Project, zone, change).Do()
|
|
if err != nil {
|
|
var v *googleapi.Error
|
|
if errors.As(err, &v) && v.Code == http.StatusNotFound {
|
|
return nil
|
|
}
|
|
|
|
data, _ := json.Marshal(change)
|
|
return fmt.Errorf("failed to perform changes [zone %s, change %s]: %w", zone, string(data), err)
|
|
}
|
|
|
|
if chg.Status == changeStatusDone {
|
|
return nil
|
|
}
|
|
|
|
chgID := chg.Id
|
|
|
|
// wait for change to be acknowledged
|
|
return wait.For("apply change", 30*time.Second, 3*time.Second, func() (bool, error) {
|
|
if d.config.Debug {
|
|
data, _ := json.Marshal(change)
|
|
log.Printf("change (Get): %s", string(data))
|
|
}
|
|
|
|
chg, err = d.client.Changes.Get(d.config.Project, zone, chgID).Do()
|
|
if err != nil {
|
|
data, _ := json.Marshal(change)
|
|
return false, fmt.Errorf("failed to get changes [zone %s, change %s]: %w", zone, string(data), err)
|
|
}
|
|
|
|
if chg.Status == changeStatusDone {
|
|
return true, nil
|
|
}
|
|
|
|
return false, fmt.Errorf("status: %s", chg.Status)
|
|
})
|
|
}
|
|
|
|
// CleanUp removes the TXT record matching the specified parameters.
|
|
func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error {
|
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
|
|
|
zone, err := d.getHostedZone(info.EffectiveFQDN)
|
|
if err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
|
|
records, err := d.findTxtRecords(zone, info.EffectiveFQDN)
|
|
if err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err = d.client.Changes.Create(d.config.Project, zone, &dns.Change{Deletions: records}).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("googlecloud: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Timeout customizes the timeout values used by the ACME package for checking
|
|
// DNS record validity.
|
|
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
|
|
return d.config.PropagationTimeout, d.config.PollingInterval
|
|
}
|
|
|
|
// getHostedZone returns the managed-zone.
|
|
func (d *DNSProvider) getHostedZone(domain string) (string, error) {
|
|
authZone, zones, err := d.lookupHostedZoneID(domain)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(zones) == 0 {
|
|
return "", fmt.Errorf("no matching domain found for domain %s", authZone)
|
|
}
|
|
|
|
for _, z := range zones {
|
|
if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) {
|
|
return z.Name, nil
|
|
}
|
|
}
|
|
|
|
if d.config.AllowPrivateZone {
|
|
return "", fmt.Errorf("no public or private zone found for domain %s", authZone)
|
|
}
|
|
|
|
return "", fmt.Errorf("no public zone found for domain %s", authZone)
|
|
}
|
|
|
|
// lookupHostedZoneID finds the managed zone ID in Google.
|
|
//
|
|
// Be careful here.
|
|
// An automated system might run in a GCloud Service Account, with access to edit the zone
|
|
//
|
|
// (gcloud dns managed-zones get-iam-policy $zone_id) (role roles/dns.admin)
|
|
//
|
|
// but not with project-wide access to list all zones
|
|
//
|
|
// (gcloud projects get-iam-policy $project_id) (a role with permission dns.managedZones.list)
|
|
//
|
|
// If we force a zone list to succeed, we demand more permissions than needed.
|
|
func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZone, error) {
|
|
// GCE_ZONE_ID override for service accounts to avoid needing zones-list permission
|
|
if d.config.ZoneID != "" {
|
|
zone, err := d.client.ManagedZones.Get(d.config.Project, d.config.ZoneID).Do()
|
|
if err != nil {
|
|
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)
|
|
}
|
|
|
|
return zone.DnsName, []*dns.ManagedZone{zone}, nil
|
|
}
|
|
|
|
authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain))
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("could not find zone: %w", err)
|
|
}
|
|
|
|
zones, err := d.client.ManagedZones.
|
|
List(d.config.Project).
|
|
DnsName(authZone).
|
|
Do()
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err)
|
|
}
|
|
|
|
return authZone, zones.ManagedZones, nil
|
|
}
|
|
|
|
func (d *DNSProvider) findTxtRecords(zone, fqdn string) ([]*dns.ResourceRecordSet, error) {
|
|
recs, err := d.client.ResourceRecordSets.List(d.config.Project, zone).Name(fqdn).Type("TXT").Do()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return recs.Rrsets, nil
|
|
}
|
|
|
|
func mustUnquote(raw string) string {
|
|
clean, err := strconv.Unquote(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
return clean
|
|
}
|
|
|
|
func autodetectProjectID(ctx context.Context) string {
|
|
if pid, err := metadata.ProjectIDWithContext(ctx); err == nil {
|
|
return pid
|
|
}
|
|
|
|
return ""
|
|
}
|