main
go 769 lines 20.7 KB
Raw
1 package gcloud
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "strconv"
9 "strings"
10 "time"
11
12 "cloud.google.com/go/compute/metadata"
13 "github.com/go-acme/lego/v4/challenge"
14 "github.com/go-acme/lego/v4/providers/dns/gcloud"
15 "golang.org/x/oauth2"
16 "golang.org/x/oauth2/google"
17 "google.golang.org/api/dns/v1"
18 "google.golang.org/api/option"
19
20 "github.com/gosuda/portal-tunnel/v2/utils"
21 )
22
23 const (
24 defaultRecordTTL = 60
25 defaultPollPeriod = 2 * time.Second
26 )
27
28 type Config struct {
29 ProjectID string
30 ManagedZone string
31 }
32
33 type Provider struct {
34 cfg Config
35 zones *utils.Snapshot[map[string]string]
36 }
37
38 type runtimeConfig struct {
39 ProjectID string
40 ManagedZone string
41 HTTPClient *http.Client
42 }
43
44 func New(cfg Config) *Provider {
45 return &Provider{
46 cfg: Config{
47 ProjectID: strings.TrimSpace(cfg.ProjectID),
48 ManagedZone: strings.TrimSpace(cfg.ManagedZone),
49 },
50 zones: utils.NewSnapshot(map[string]string{}, utils.CloneMap[string, string]),
51 }
52 }
53
54 func (p *Provider) Name() string {
55 return "gcloud"
56 }
57
58 func (p *Provider) ChallengeProvider(ctx context.Context) (challenge.Provider, error) {
59 if p == nil {
60 return nil, errors.New("gcloud provider is nil")
61 }
62
63 runtimeCfg, err := newRuntimeConfig(ctx, p.cfg)
64 if err != nil {
65 return nil, err
66 }
67
68 cfg := gcloud.NewDefaultConfig()
69 cfg.Project = runtimeCfg.ProjectID
70 cfg.ZoneID = runtimeCfg.ManagedZone
71 cfg.HTTPClient = runtimeCfg.HTTPClient
72
73 provider, err := gcloud.NewDNSProviderConfig(cfg)
74 if err != nil {
75 return nil, fmt.Errorf("create gcloud lego provider: %w", err)
76 }
77 return provider, nil
78 }
79
80 func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error {
81 if p == nil {
82 return errors.New("gcloud provider is nil")
83 }
84 baseDomain = utils.NormalizeBaseDomain(baseDomain)
85 if baseDomain == "" {
86 return errors.New("base domain is required")
87 }
88 if err := utils.ValidateIPv4(publicIPv4); err != nil {
89 return err
90 }
91
92 service, runtimeCfg, zone, err := p.newService(ctx, baseDomain)
93 if err != nil {
94 return err
95 }
96
97 for _, recordName := range []string{baseDomain, "*." + baseDomain} {
98 if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
99 Name: fqdn(recordName),
100 Type: "A",
101 Ttl: defaultRecordTTL,
102 Rrdatas: []string{strings.TrimSpace(publicIPv4)},
103 }); err != nil {
104 return fmt.Errorf("upsert gcloud A record %s: %w", recordName, err)
105 }
106 }
107 return nil
108 }
109
110 func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
111 if p == nil {
112 return errors.New("gcloud provider is nil")
113 }
114 name = utils.NormalizeHostname(name)
115 if name == "" {
116 return errors.New("record name is required")
117 }
118 if err := utils.ValidateIPv4(publicIPv4); err != nil {
119 return err
120 }
121
122 service, runtimeCfg, zone, err := p.newService(ctx, name)
123 if err != nil {
124 return err
125 }
126
127 if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
128 Name: fqdn(name),
129 Type: "A",
130 Ttl: defaultRecordTTL,
131 Rrdatas: []string{strings.TrimSpace(publicIPv4)},
132 }); err != nil {
133 return fmt.Errorf("upsert gcloud A record %s: %w", name, err)
134 }
135 return nil
136 }
137
138 func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
139 if p == nil {
140 return errors.New("gcloud provider is nil")
141 }
142 name = utils.NormalizeHostname(name)
143 if name == "" {
144 return errors.New("record name is required")
145 }
146
147 service, runtimeCfg, zone, err := p.newService(ctx, name)
148 if err != nil {
149 return err
150 }
151
152 existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "A")
153 if err != nil {
154 return fmt.Errorf("list gcloud A records %s: %w", name, err)
155 }
156 if len(existing) == 0 {
157 return nil
158 }
159 if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
160 Deletions: existing,
161 }); err != nil {
162 return fmt.Errorf("delete gcloud A record %s: %w", name, err)
163 }
164 return nil
165 }
166
167 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
168 if p == nil {
169 return errors.New("gcloud provider is nil")
170 }
171 name = utils.NormalizeHostname(name)
172 if name == "" {
173 return errors.New("record name is required")
174 }
175 value = strings.TrimSpace(value)
176 if value == "" {
177 return errors.New("txt record value is required")
178 }
179
180 service, runtimeCfg, zone, err := p.newService(ctx, name)
181 if err != nil {
182 return err
183 }
184
185 existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "TXT")
186 if err != nil {
187 return fmt.Errorf("list gcloud TXT records %s: %w", name, err)
188 }
189
190 values := make([]string, 0, len(existing)+1)
191 seen := make(map[string]struct{}, len(existing)+1)
192 for _, recordSet := range existing {
193 for _, raw := range recordSet.Rrdatas {
194 normalized := txtContent(raw)
195 if normalized == "" {
196 continue
197 }
198 if _, ok := seen[normalized]; ok {
199 continue
200 }
201 seen[normalized] = struct{}{}
202 values = append(values, normalized)
203 }
204 }
205 if _, ok := seen[value]; ok {
206 return nil
207 }
208 values = append(values, value)
209
210 if err := replaceRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, existing, &dns.ResourceRecordSet{
211 Name: fqdn(name),
212 Type: "TXT",
213 Ttl: recordTTL(existing),
214 Rrdatas: values,
215 }); err != nil {
216 return fmt.Errorf("upsert gcloud TXT record %s: %w", name, err)
217 }
218 return nil
219 }
220
221 func (p *Provider) DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error {
222 if p == nil {
223 return errors.New("gcloud provider is nil")
224 }
225 name = utils.NormalizeHostname(name)
226 if name == "" {
227 return errors.New("record name is required")
228 }
229 matchPrefix = strings.TrimSpace(matchPrefix)
230 if matchPrefix == "" {
231 return errors.New("txt record match prefix is required")
232 }
233
234 service, runtimeCfg, zone, err := p.newService(ctx, name)
235 if err != nil {
236 return err
237 }
238
239 existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "TXT")
240 if err != nil {
241 return fmt.Errorf("list gcloud TXT records %s: %w", name, err)
242 }
243 if len(existing) == 0 {
244 return nil
245 }
246
247 remaining := make([]string, 0, len(existing))
248 seen := make(map[string]struct{}, len(existing))
249 removed := false
250 for _, recordSet := range existing {
251 for _, raw := range recordSet.Rrdatas {
252 normalized := txtContent(raw)
253 if normalized == "" {
254 continue
255 }
256 if strings.HasPrefix(normalized, matchPrefix) {
257 removed = true
258 continue
259 }
260 if _, ok := seen[normalized]; ok {
261 continue
262 }
263 seen[normalized] = struct{}{}
264 remaining = append(remaining, normalized)
265 }
266 }
267 if !removed {
268 return nil
269 }
270
271 if len(remaining) == 0 {
272 if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
273 Deletions: existing,
274 }); err != nil {
275 return fmt.Errorf("delete gcloud TXT records %s: %w", name, err)
276 }
277 return nil
278 }
279
280 if err := replaceRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, existing, &dns.ResourceRecordSet{
281 Name: fqdn(name),
282 Type: "TXT",
283 Ttl: recordTTL(existing),
284 Rrdatas: remaining,
285 }); err != nil {
286 return fmt.Errorf("delete gcloud TXT records %s: %w", name, err)
287 }
288 return nil
289 }
290
291 func (p *Provider) EnsureHTTPSRecord(ctx context.Context, name string, _ uint16, _, _, content string) error {
292 if p == nil {
293 return errors.New("gcloud provider is nil")
294 }
295 name = utils.NormalizeHostname(name)
296 if name == "" {
297 return errors.New("record name is required")
298 }
299 content = strings.TrimSpace(content)
300 if content == "" {
301 return errors.New("https record content is required")
302 }
303
304 service, runtimeCfg, zone, err := p.newService(ctx, name)
305 if err != nil {
306 return err
307 }
308
309 if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
310 Name: fqdn(name),
311 Type: "HTTPS",
312 Ttl: defaultRecordTTL,
313 Rrdatas: []string{content},
314 }); err != nil {
315 return fmt.Errorf("upsert gcloud HTTPS record %s: %w", name, err)
316 }
317 return nil
318 }
319
320 func (p *Provider) DeleteHTTPSRecord(ctx context.Context, name string) error {
321 if p == nil {
322 return errors.New("gcloud provider is nil")
323 }
324 name = utils.NormalizeHostname(name)
325 if name == "" {
326 return errors.New("record name is required")
327 }
328
329 service, runtimeCfg, zone, err := p.newService(ctx, name)
330 if err != nil {
331 return err
332 }
333
334 existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "HTTPS")
335 if err != nil {
336 return fmt.Errorf("list gcloud HTTPS records %s: %w", name, err)
337 }
338 if len(existing) == 0 {
339 return nil
340 }
341 if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
342 Deletions: existing,
343 }); err != nil {
344 return fmt.Errorf("delete gcloud HTTPS record %s: %w", name, err)
345 }
346 return nil
347 }
348
349 func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (state, dsRecord, message string, err error) {
350 if p == nil {
351 return "", "", "", errors.New("gcloud provider is nil")
352 }
353 baseDomain = utils.NormalizeBaseDomain(baseDomain)
354 if baseDomain == "" {
355 return "", "", "", errors.New("base domain is required")
356 }
357
358 service, runtimeCfg, zone, err := p.newService(ctx, baseDomain)
359 if err != nil {
360 return "", "", "", err
361 }
362 managedZone := zone.Name
363 zone, err = service.ManagedZones.Get(runtimeCfg.ProjectID, managedZone).Context(ctx).Do()
364 if err != nil {
365 return "", "", "", fmt.Errorf("get gcloud managed zone %s: %w", managedZone, err)
366 }
367
368 currentState := strings.ToLower(strings.TrimSpace(dnssecState(zone)))
369 if currentState != "on" && currentState != "transfer" {
370 if err := enableDNSSEC(ctx, service, runtimeCfg.ProjectID, managedZone); err != nil {
371 return "", "", "", fmt.Errorf("enable gcloud dnssec: %w", err)
372 }
373 zone, err = service.ManagedZones.Get(runtimeCfg.ProjectID, managedZone).Context(ctx).Do()
374 if err != nil {
375 return "", "", "", fmt.Errorf("refresh gcloud managed zone %s: %w", managedZone, err)
376 }
377 }
378
379 keys, err := listDNSKeys(ctx, service, runtimeCfg.ProjectID, managedZone)
380 if err != nil {
381 return "", "", "", fmt.Errorf("list gcloud dnssec keys: %w", err)
382 }
383
384 return dnssecStatusFromZone(zone, keys)
385 }
386
387 func newRuntimeConfig(ctx context.Context, cfg Config) (runtimeConfig, error) {
388 creds, err := google.FindDefaultCredentials(ctx, dns.NdevClouddnsReadwriteScope)
389 if err != nil {
390 return runtimeConfig{}, fmt.Errorf("load gcloud credentials: %w", err)
391 }
392
393 projectID := strings.TrimSpace(cfg.ProjectID)
394 if projectID == "" {
395 projectID = strings.TrimSpace(creds.ProjectID)
396 }
397 if projectID == "" && metadata.OnGCE() {
398 if detected, err := metadata.ProjectIDWithContext(ctx); err == nil {
399 projectID = strings.TrimSpace(detected)
400 }
401 }
402 if projectID == "" {
403 return runtimeConfig{}, errors.New("gcloud project id is required")
404 }
405
406 return runtimeConfig{
407 ProjectID: projectID,
408 ManagedZone: strings.TrimSpace(cfg.ManagedZone),
409 HTTPClient: oauth2.NewClient(ctx, creds.TokenSource),
410 }, nil
411 }
412
413 func (p *Provider) newService(ctx context.Context, domain string) (*dns.Service, runtimeConfig, *dns.ManagedZone, error) {
414 runtimeCfg, err := newRuntimeConfig(ctx, p.cfg)
415 if err != nil {
416 return nil, runtimeConfig{}, nil, err
417 }
418
419 service, err := dns.NewService(ctx, option.WithHTTPClient(runtimeCfg.HTTPClient))
420 if err != nil {
421 return nil, runtimeConfig{}, nil, fmt.Errorf("create gcloud dns service: %w", err)
422 }
423
424 zone, err := p.findManagedZone(ctx, service, runtimeCfg.ProjectID, domain, runtimeCfg.ManagedZone)
425 if err != nil {
426 return nil, runtimeConfig{}, nil, err
427 }
428 return service, runtimeCfg, zone, nil
429 }
430
431 func (p *Provider) findManagedZone(ctx context.Context, service *dns.Service, projectID, domain, explicit string) (*dns.ManagedZone, error) {
432 if service == nil {
433 return nil, errors.New("gcloud dns service is nil")
434 }
435 domain = utils.NormalizeHostname(domain)
436 candidates := utils.DomainCandidates(domain)
437
438 zones := p.zones.Load()
439 for _, candidate := range candidates {
440 if zoneName := zones[candidate]; zoneName != "" {
441 return &dns.ManagedZone{Name: zoneName, DnsName: fqdn(candidate)}, nil
442 }
443 }
444
445 if explicit = strings.TrimSpace(explicit); explicit != "" {
446 zone, err := service.ManagedZones.Get(projectID, explicit).Context(ctx).Do()
447 if err != nil {
448 return nil, fmt.Errorf("get gcloud managed zone %q: %w", explicit, err)
449 }
450 if err := validateManagedZone(zone, domain, explicit); err != nil {
451 return nil, err
452 }
453 if zoneDomain := utils.NormalizeHostname(zone.DnsName); zoneDomain != "" && zone.Name != "" {
454 p.zones.UpdateCopy(func(zones *map[string]string) {
455 if *zones == nil {
456 *zones = make(map[string]string)
457 }
458 (*zones)[zoneDomain] = zone.Name
459 })
460 }
461 return zone, nil
462 }
463
464 for _, candidate := range candidates {
465 out, err := service.ManagedZones.List(projectID).DnsName(fqdn(candidate)).Context(ctx).Do()
466 if err != nil {
467 return nil, fmt.Errorf("list gcloud managed zones: %w", err)
468 }
469 for _, zone := range out.ManagedZones {
470 if !isPublicZone(zone) || utils.NormalizeHostname(zone.DnsName) != candidate {
471 continue
472 }
473 if zone.Name != "" {
474 p.zones.UpdateCopy(func(zones *map[string]string) {
475 if *zones == nil {
476 *zones = make(map[string]string)
477 }
478 (*zones)[candidate] = zone.Name
479 })
480 }
481 return zone, nil
482 }
483 }
484
485 return nil, fmt.Errorf("no gcloud public managed zone found for %s", domain)
486 }
487
488 func validateManagedZone(zone *dns.ManagedZone, domain, explicit string) error {
489 if zone == nil {
490 return fmt.Errorf("gcloud managed zone %q is nil", explicit)
491 }
492 if !isPublicZone(zone) {
493 return fmt.Errorf("gcloud managed zone %q is not public", explicit)
494 }
495 if zoneDomain := utils.NormalizeHostname(zone.DnsName); zoneDomain == "" || !utils.HostnameMatchesBaseDomain(domain, zoneDomain) {
496 return fmt.Errorf("gcloud managed zone %q does not cover %s", explicit, domain)
497 }
498 return nil
499 }
500
501 func ensureRecordSet(ctx context.Context, service *dns.Service, projectID, managedZone string, desired *dns.ResourceRecordSet) error {
502 existing, err := listRecordSets(ctx, service, projectID, managedZone, desired.Name, desired.Type)
503 if err != nil {
504 return err
505 }
506 if len(existing) == 1 && sameRecordSet(existing[0], desired) {
507 return nil
508 }
509 return replaceRecordSet(ctx, service, projectID, managedZone, existing, desired)
510 }
511
512 func replaceRecordSet(ctx context.Context, service *dns.Service, projectID, managedZone string, existing []*dns.ResourceRecordSet, desired *dns.ResourceRecordSet) error {
513 change := &dns.Change{
514 Additions: []*dns.ResourceRecordSet{desired},
515 }
516 if len(existing) > 0 {
517 change.Deletions = existing
518 }
519 return applyChange(ctx, service, projectID, managedZone, change)
520 }
521
522 func applyChange(ctx context.Context, service *dns.Service, projectID, managedZone string, change *dns.Change) error {
523 if service == nil {
524 return errors.New("gcloud dns service is nil")
525 }
526
527 result, err := service.Changes.Create(projectID, managedZone, change).Context(ctx).Do()
528 if err != nil {
529 return err
530 }
531 if strings.EqualFold(strings.TrimSpace(result.Status), "done") {
532 return nil
533 }
534
535 for {
536 if !utils.SleepOrDone(ctx, defaultPollPeriod) {
537 return ctx.Err()
538 }
539 result, err = service.Changes.Get(projectID, managedZone, result.Id).Context(ctx).Do()
540 if err != nil {
541 return err
542 }
543 if strings.EqualFold(strings.TrimSpace(result.Status), "done") {
544 return nil
545 }
546 }
547 }
548
549 func listRecordSets(ctx context.Context, service *dns.Service, projectID, managedZone, name, recordType string) ([]*dns.ResourceRecordSet, error) {
550 if service == nil {
551 return nil, errors.New("gcloud dns service is nil")
552 }
553
554 recordType = strings.ToUpper(strings.TrimSpace(recordType))
555 name = fqdn(name)
556 out, err := service.ResourceRecordSets.List(projectID, managedZone).Name(name).Type(recordType).Context(ctx).Do()
557 if err != nil {
558 return nil, err
559 }
560
561 filtered := make([]*dns.ResourceRecordSet, 0, len(out.Rrsets))
562 for _, recordSet := range out.Rrsets {
563 if !strings.EqualFold(strings.TrimSpace(recordSet.Name), name) || !strings.EqualFold(strings.TrimSpace(recordSet.Type), recordType) {
564 continue
565 }
566 filtered = append(filtered, recordSet)
567 }
568 return filtered, nil
569 }
570
571 func enableDNSSEC(ctx context.Context, service *dns.Service, projectID, managedZone string) error {
572 if service == nil {
573 return errors.New("gcloud dns service is nil")
574 }
575
576 operation, err := service.ManagedZones.Patch(projectID, managedZone, &dns.ManagedZone{
577 DnssecConfig: &dns.ManagedZoneDnsSecConfig{
578 State: "on",
579 },
580 }).Context(ctx).Do()
581 if err != nil {
582 return err
583 }
584 if strings.EqualFold(strings.TrimSpace(operation.Status), "done") {
585 return nil
586 }
587
588 for {
589 if !utils.SleepOrDone(ctx, defaultPollPeriod) {
590 return ctx.Err()
591 }
592 operation, err = service.ManagedZoneOperations.Get(projectID, managedZone, operation.Id).Context(ctx).Do()
593 if err != nil {
594 return err
595 }
596 if strings.EqualFold(strings.TrimSpace(operation.Status), "done") {
597 return nil
598 }
599 }
600 }
601
602 func listDNSKeys(ctx context.Context, service *dns.Service, projectID, managedZone string) ([]*dns.DnsKey, error) {
603 if service == nil {
604 return nil, errors.New("gcloud dns service is nil")
605 }
606
607 keys := make([]*dns.DnsKey, 0, 2)
608 err := service.DnsKeys.List(projectID, managedZone).DigestType("sha256,sha384,sha1").Pages(ctx, func(page *dns.DnsKeysListResponse) error {
609 keys = append(keys, page.DnsKeys...)
610 return nil
611 })
612 if err != nil {
613 return nil, err
614 }
615 return keys, nil
616 }
617
618 func dnssecStatusFromZone(zone *dns.ManagedZone, keys []*dns.DnsKey) (state, dsRecord, message string, err error) {
619 state = strings.TrimSpace(dnssecState(zone))
620 dsRecord = activeDSRecord(keys)
621 if dsRecord != "" {
622 message = "publish the DS record at the registrar after Cloud DNS zone signing is enabled"
623 } else if strings.EqualFold(state, "on") || strings.EqualFold(state, "transfer") {
624 message = "wait for the active Cloud DNS DS record before updating the registrar"
625 }
626 return state, dsRecord, message, nil
627 }
628
629 func activeDSRecord(keys []*dns.DnsKey) string {
630 for _, key := range keys {
631 if key == nil || !key.IsActive || !strings.EqualFold(strings.TrimSpace(key.Type), "keySigning") {
632 continue
633 }
634 if ds, ok := dnsKeyDSRecord(key); ok {
635 return ds
636 }
637 }
638 return ""
639 }
640
641 func dnsKeyDSRecord(key *dns.DnsKey) (string, bool) {
642 if key == nil {
643 return "", false
644 }
645
646 algorithm, ok := dnssecAlgorithmCode(key.Algorithm)
647 if !ok {
648 return "", false
649 }
650 digest, ok := preferredDigest(key.Digests)
651 if !ok {
652 return "", false
653 }
654 digestType, ok := dnssecDigestTypeCode(digest.Type)
655 if !ok {
656 return "", false
657 }
658
659 return fmt.Sprintf("%d %d %d %s", key.KeyTag, algorithm, digestType, strings.TrimSpace(digest.Digest)), true
660 }
661
662 func preferredDigest(digests []*dns.DnsKeyDigest) (*dns.DnsKeyDigest, bool) {
663 for _, candidate := range []string{"sha256", "sha384", "sha1"} {
664 for _, digest := range digests {
665 if digest == nil || !strings.EqualFold(strings.TrimSpace(digest.Type), candidate) || strings.TrimSpace(digest.Digest) == "" {
666 continue
667 }
668 return digest, true
669 }
670 }
671 return nil, false
672 }
673
674 func dnssecAlgorithmCode(raw string) (int, bool) {
675 switch strings.ToLower(strings.TrimSpace(raw)) {
676 case "rsasha1":
677 return 5, true
678 case "rsasha256":
679 return 8, true
680 case "rsasha512":
681 return 10, true
682 case "ecdsap256sha256":
683 return 13, true
684 case "ecdsap384sha384":
685 return 14, true
686 default:
687 return 0, false
688 }
689 }
690
691 func dnssecDigestTypeCode(raw string) (int, bool) {
692 switch strings.ToLower(strings.TrimSpace(raw)) {
693 case "sha1":
694 return 1, true
695 case "sha256":
696 return 2, true
697 case "sha384":
698 return 4, true
699 default:
700 return 0, false
701 }
702 }
703
704 func dnssecState(zone *dns.ManagedZone) string {
705 if zone == nil || zone.DnssecConfig == nil {
706 return ""
707 }
708 return zone.DnssecConfig.State
709 }
710
711 func sameRecordSet(current, desired *dns.ResourceRecordSet) bool {
712 if current == nil || desired == nil {
713 return false
714 }
715 if !strings.EqualFold(strings.TrimSpace(current.Name), strings.TrimSpace(desired.Name)) || !strings.EqualFold(strings.TrimSpace(current.Type), strings.TrimSpace(desired.Type)) || current.Ttl != desired.Ttl {
716 return false
717 }
718
719 currentValues := make(map[string]int, len(current.Rrdatas))
720 for _, value := range current.Rrdatas {
721 currentValues[txtContent(value)]++
722 }
723 for _, value := range desired.Rrdatas {
724 normalized := txtContent(value)
725 if currentValues[normalized] == 0 {
726 return false
727 }
728 currentValues[normalized]--
729 }
730 for _, remaining := range currentValues {
731 if remaining != 0 {
732 return false
733 }
734 }
735 return true
736 }
737
738 func recordTTL(recordSets []*dns.ResourceRecordSet) int64 {
739 for _, recordSet := range recordSets {
740 if recordSet != nil && recordSet.Ttl > 0 {
741 return recordSet.Ttl
742 }
743 }
744 return defaultRecordTTL
745 }
746
747 func isPublicZone(zone *dns.ManagedZone) bool {
748 if zone == nil {
749 return false
750 }
751 visibility := strings.ToLower(strings.TrimSpace(zone.Visibility))
752 return visibility == "" || visibility == "public"
753 }
754
755 func fqdn(name string) string {
756 normalized := utils.NormalizeHostname(name)
757 if normalized == "" {
758 return ""
759 }
760 return normalized + "."
761 }
762
763 func txtContent(raw string) string {
764 unquoted, err := strconv.Unquote(strings.TrimSpace(raw))
765 if err == nil {
766 return unquoted
767 }
768 return strings.Trim(strings.TrimSpace(raw), "\"")
769 }