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