From cb9e80da69c21b8a40f407707efb3f6e0507ccce Mon Sep 17 00:00:00 2001 From: Phil Pennock Date: Tue, 26 Dec 2023 22:36:52 -0500 Subject: [PATCH 1/4] gcloud: support GCE_ZONE_ID to bypass zone list The GCloud IAM permission system permits a zone to grant access to an actor, without the project granting any access. This can be used with Service Accounts to let an SA edit DNS in one particular zone, and nothing more. Remove the need for the caller to have project-level role access granting the `dns.managedZones.list` permission, in exchange for the caller telling us the explicit zone ID to use, via the `GCE_ZONE_ID` environment variable. --- providers/dns/gcloud/googlecloud.go | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go index 34a7d1e08c..653401791a 100644 --- a/providers/dns/gcloud/googlecloud.go +++ b/providers/dns/gcloud/googlecloud.go @@ -11,15 +11,16 @@ import ( "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" + + "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" ) const ( @@ -34,6 +35,7 @@ const ( EnvProject = envNamespace + "PROJECT" EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" EnvDebug = envNamespace + "DEBUG" + EnvZoneID = envNamespace + "ZONE_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -310,17 +312,37 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // getHostedZone returns the managed-zone. func (d *DNSProvider) getHostedZone(domain string) (string, error) { + // 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. + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) if err != nil { return "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err) } + if zoneId := env.GetOrDefaultString(EnvZoneID, ""); zoneId != "" { + // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission + z, err := d.client.ManagedZones.Get(d.config.Project, zoneId).Do() + if err != nil { + return "", fmt.Errorf("API call ManagedZones.Get for explicit zone-id %q in project %q failed: %w", zoneId, d.config.Project, err) + } + if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) { + return z.Name, nil + } + return "", fmt.Errorf("zone %q in project %q is not public, needed for ACME", zoneId, d.config.Project) + } + zones, err := d.client.ManagedZones. List(d.config.Project). DnsName(authZone). Do() if err != nil { - return "", fmt.Errorf("API call failed: %w", err) + return "", fmt.Errorf("API call ManagedZones.List failed: %w", err) } if len(zones.ManagedZones) == 0 { From 951bb8d3d78c338b4f125f23bd60ef1f871bcaba Mon Sep 17 00:00:00 2001 From: Phil Pennock Date: Wed, 27 Dec 2023 23:59:08 -0500 Subject: [PATCH 2/4] gcloud: reduce complexity for linters Restructure to reduce complexity, rename variables for compatibility, and use `golangci-lint run --fix` before check-in. --- providers/dns/gcloud/googlecloud.go | 72 ++++++++++++++++------------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go index 653401791a..d81cca02b1 100644 --- a/providers/dns/gcloud/googlecloud.go +++ b/providers/dns/gcloud/googlecloud.go @@ -11,16 +11,15 @@ import ( "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" - - "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" ) const ( @@ -310,31 +309,32 @@ 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) { - // 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. - - authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) - if err != nil { - return "", fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err) - } - - if zoneId := env.GetOrDefaultString(EnvZoneID, ""); zoneId != "" { - // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission - z, err := d.client.ManagedZones.Get(d.config.Project, zoneId).Do() +// 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) (authZone string, gZones []*dns.ManagedZone, err error) { + // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission + if zoneID := env.GetOrDefaultString(EnvZoneID, ""); zoneID != "" { + var gcloudZone *dns.ManagedZone + gcloudZone, err = d.client.ManagedZones.Get(d.config.Project, zoneID).Do() if err != nil { - return "", fmt.Errorf("API call ManagedZones.Get for explicit zone-id %q in project %q failed: %w", zoneId, d.config.Project, err) + return "", nil, fmt.Errorf("API call ManagedZones.Get for explicit zone-id %q in project %q failed: %w", zoneID, d.config.Project, err) } - if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) { - return z.Name, nil - } - return "", fmt.Errorf("zone %q in project %q is not public, needed for ACME", zoneId, d.config.Project) + return gcloudZone.DnsName, []*dns.ManagedZone{gcloudZone}, nil + } + + authZone, err = dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + if err != nil { + return "", nil, fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err) } zones, err := d.client.ManagedZones. @@ -342,14 +342,24 @@ func (d *DNSProvider) getHostedZone(domain string) (string, error) { DnsName(authZone). Do() if err != nil { - return "", fmt.Errorf("API call ManagedZones.List failed: %w", err) + return "", nil, fmt.Errorf("API call ManagedZones.List failed: %w", err) + } + + return authZone, zones.ManagedZones, nil +} + +// 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.ManagedZones) == 0 { + if len(zones) == 0 { return "", fmt.Errorf("no matching domain found for domain %s", authZone) } - for _, z := range zones.ManagedZones { + for _, z := range zones { if z.Visibility == "public" || z.Visibility == "" || (z.Visibility == "private" && d.config.AllowPrivateZone) { return z.Name, nil } From 2c083cdc3e9b5040b2a1adeea3342ea80b1e58c4 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Fri, 12 Jan 2024 21:43:10 +0100 Subject: [PATCH 3/4] review --- providers/dns/gcloud/googlecloud.go | 68 +++++++++++++++-------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/providers/dns/gcloud/googlecloud.go b/providers/dns/gcloud/googlecloud.go index d81cca02b1..ff9fb6f077 100644 --- a/providers/dns/gcloud/googlecloud.go +++ b/providers/dns/gcloud/googlecloud.go @@ -32,9 +32,9 @@ const ( EnvServiceAccount = envNamespace + "SERVICE_ACCOUNT" EnvProject = envNamespace + "PROJECT" + EnvZoneID = envNamespace + "ZONE_ID" EnvAllowPrivateZone = envNamespace + "ALLOW_PRIVATE_ZONE" EnvDebug = envNamespace + "DEBUG" - EnvZoneID = envNamespace + "ZONE_ID" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" @@ -45,6 +45,7 @@ const ( type Config struct { Debug bool Project string + ZoneID string AllowPrivateZone bool PropagationTimeout time.Duration PollingInterval time.Duration @@ -56,6 +57,7 @@ type Config struct { 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), @@ -309,6 +311,30 @@ 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. @@ -321,20 +347,20 @@ func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { // (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) (authZone string, gZones []*dns.ManagedZone, err error) { +func (d *DNSProvider) lookupHostedZoneID(domain string) (string, []*dns.ManagedZone, error) { // GCE_ZONE_ID override for service accounts to avoid needing zones-list permission - if zoneID := env.GetOrDefaultString(EnvZoneID, ""); zoneID != "" { - var gcloudZone *dns.ManagedZone - gcloudZone, err = d.client.ManagedZones.Get(d.config.Project, zoneID).Do() + 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", zoneID, d.config.Project, err) + 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 gcloudZone.DnsName, []*dns.ManagedZone{gcloudZone}, nil + + return zone.DnsName, []*dns.ManagedZone{zone}, nil } - authZone, err = dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) + authZone, err := dns01.FindZoneByFqdn(dns01.ToFqdn(domain)) if err != nil { - return "", nil, fmt.Errorf("designate: could not find zone for FQDN %q: %w", domain, err) + return "", nil, fmt.Errorf("could not find zone for FQDN %q: %w", domain, err) } zones, err := d.client.ManagedZones. @@ -348,30 +374,6 @@ func (d *DNSProvider) lookupHostedZoneID(domain string) (authZone string, gZones return authZone, zones.ManagedZones, nil } -// 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) -} - 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 { From e0df42ced5a840acb1ad58f35a3715231139d6c1 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Sat, 13 Jan 2024 23:04:57 +0100 Subject: [PATCH 4/4] review: doc --- cmd/zz_gen_cmd_dnshelp.go | 1 + docs/content/dns/zz_gen_gcloud.md | 1 + providers/dns/gcloud/gcloud.toml | 1 + 3 files changed, 3 insertions(+) diff --git a/cmd/zz_gen_cmd_dnshelp.go b/cmd/zz_gen_cmd_dnshelp.go index 263c446699..24245e02b3 100644 --- a/cmd/zz_gen_cmd_dnshelp.go +++ b/cmd/zz_gen_cmd_dnshelp.go @@ -1108,6 +1108,7 @@ func displayDNSHelp(w io.Writer, name string) error { ew.writeln(` - "GCE_POLLING_INTERVAL": Time between DNS propagation check`) ew.writeln(` - "GCE_PROPAGATION_TIMEOUT": Maximum waiting time for DNS propagation`) ew.writeln(` - "GCE_TTL": The TTL of the TXT record used for the DNS challenge`) + ew.writeln(` - "GCE_ZONE_ID": Allows to skip the automatic detection of the zone`) ew.writeln() ew.writeln(`More information: https://go-acme.github.io/lego/dns/gcloud`) diff --git a/docs/content/dns/zz_gen_gcloud.md b/docs/content/dns/zz_gen_gcloud.md index adde36d786..7149452823 100644 --- a/docs/content/dns/zz_gen_gcloud.md +++ b/docs/content/dns/zz_gen_gcloud.md @@ -58,6 +58,7 @@ More information [here]({{< ref "dns#configuration-and-credentials" >}}). | `GCE_POLLING_INTERVAL` | Time between DNS propagation check | | `GCE_PROPAGATION_TIMEOUT` | Maximum waiting time for DNS propagation | | `GCE_TTL` | The TTL of the TXT record used for the DNS challenge | +| `GCE_ZONE_ID` | Allows to skip the automatic detection of the zone | The environment variable names can be suffixed by `_FILE` to reference a file instead of a value. More information [here]({{< ref "dns#configuration-and-credentials" >}}). diff --git a/providers/dns/gcloud/gcloud.toml b/providers/dns/gcloud/gcloud.toml index c08824b9b8..261e35b919 100644 --- a/providers/dns/gcloud/gcloud.toml +++ b/providers/dns/gcloud/gcloud.toml @@ -21,6 +21,7 @@ GCE_PROJECT="gc-project-id" GCE_SERVICE_ACCOUNT_FILE="/path/to/svc/account/file. GCE_SERVICE_ACCOUNT = "Account" [Configuration.Additional] GCE_ALLOW_PRIVATE_ZONE = "Allows requested domain to be in private DNS zone, works only with a private ACME server (by default: false)" + GCE_ZONE_ID = "Allows to skip the automatic detection of the zone" GCE_POLLING_INTERVAL = "Time between DNS propagation check" GCE_PROPAGATION_TIMEOUT = "Maximum waiting time for DNS propagation" GCE_TTL = "The TTL of the TXT record used for the DNS challenge"