fix acme bugs

Kim committed Apr 2, 2026 at 12:01 UTC 5c482107922acc064ac616c938e9fc4a93caa805
6 files changed +244 -7
go.sum
-2
@@ -125,8 +125,6 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
125 golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
126 golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
127 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
128 -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
129 -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
128 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
129 golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
130 golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
portal/acme/acme.go
+63 -3
@@ -404,6 +404,9 @@ func (m *Manager) syncENSGasless(ctx context.Context) error {
404 if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
405 return fmt.Errorf("ensure ens gasless txt: %w", err)
406 }
407 + if err := m.syncTrackedENSGaslessHostARecords(ctx); err != nil {
408 + return err
409 + }
410 m.ensLogOnce.Do(func() {
411 log.Info().
412 Str("provider", m.dns.Name()).
@@ -434,6 +437,9 @@ func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address
437 if err != nil {
438 return fmt.Errorf("normalize ens gasless address: %w", err)
439 }
440 + if err := m.syncENSGaslessHostnameARecord(ctx, hostname); err != nil {
441 + return err
442 + }
443 if err := m.dns.EnsureTXTRecord(ctx, hostname, gaslessENSTXTPrefix+defaultENSGaslessResolver+" "+strings.TrimSpace(address)); err != nil {
444 return err
445 }
@@ -463,6 +469,9 @@ func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string)
469 if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
470 return err
471 }
472 + if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
473 + return err
474 + }
475 return m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
476 filtered := hostnames[:0]
477 for _, tracked := range hostnames {
@@ -487,6 +496,11 @@ func (m *Manager) reconcileTrackedENSGaslessHostnames(ctx context.Context) error
496 if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
497 remaining = append(remaining, hostname)
498 cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ens gasless txt for %s: %w", hostname, err))
499 + continue
500 + }
501 + if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
502 + remaining = append(remaining, hostname)
503 + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ens gasless A record for %s: %w", hostname, err))
504 }
505 }
506 return remaining
@@ -496,6 +510,53 @@ func (m *Manager) reconcileTrackedENSGaslessHostnames(ctx context.Context) error
510 return cleanupErr
511 }
512
513 +func (m *Manager) syncTrackedENSGaslessHostARecords(ctx context.Context) error {
514 + hostnames, err := m.trackedENSGaslessHostnames()
515 + if err != nil || len(hostnames) == 0 {
516 + return err
517 + }
518 +
519 + publicIP, err := utils.ResolvePublicIPv4(ctx)
520 + if err != nil {
521 + return fmt.Errorf("detect public ip: %w", err)
522 + }
523 + for _, hostname := range hostnames {
524 + if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
525 + return fmt.Errorf("ensure ens gasless A record for %s: %w", hostname, err)
526 + }
527 + }
528 + return nil
529 +}
530 +
531 +func (m *Manager) syncENSGaslessHostnameARecord(ctx context.Context, hostname string) error {
532 + hostname = utils.NormalizeHostname(hostname)
533 + if hostname == "" || hostname == m.cfg.BaseDomain {
534 + return nil
535 + }
536 +
537 + publicIP, err := utils.ResolvePublicIPv4(ctx)
538 + if err != nil {
539 + return fmt.Errorf("detect public ip: %w", err)
540 + }
541 + if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
542 + return fmt.Errorf("ensure ens gasless A record for %s: %w", hostname, err)
543 + }
544 + return nil
545 +}
546 +
547 +func (m *Manager) trackedENSGaslessHostnames() ([]string, error) {
548 + if m == nil {
549 + return nil, nil
550 + }
551 +
552 + path := filepath.Join(m.cfg.KeyDir, ensGaslessHostnamesFileName)
553 + var hostnames []string
554 + if _, err := utils.ReadJSONFileIfExists(path, &hostnames); err != nil {
555 + return nil, err
556 + }
557 + return utils.NormalizeChildHostnames(hostnames, m.cfg.BaseDomain), nil
558 +}
559 +
560 func (m *Manager) updateTrackedENSGaslessHostnames(update func([]string) []string) error {
561 if m == nil {
562 return nil
@@ -505,11 +566,10 @@ func (m *Manager) updateTrackedENSGaslessHostnames(update func([]string) []strin
566 defer m.trackedMu.Unlock()
567
568 path := filepath.Join(m.cfg.KeyDir, ensGaslessHostnamesFileName)
508 - var hostnames []string
509 - if _, err := utils.ReadJSONFileIfExists(path, &hostnames); err != nil {
569 + hostnames, err := m.trackedENSGaslessHostnames()
570 + if err != nil {
571 return err
572 }
512 - hostnames = utils.NormalizeChildHostnames(hostnames, m.cfg.BaseDomain)
573 if update != nil {
574 hostnames = utils.NormalizeChildHostnames(update(hostnames), m.cfg.BaseDomain)
575 }
portal/acme/cloudflare/provider.go
+58
@@ -124,6 +124,64 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
124 return nil
125 }
126
127 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
128 + if p == nil {
129 + return errors.New("cloudflare provider is nil")
130 + }
131 + name = utils.NormalizeHostname(name)
132 + if name == "" {
133 + return errors.New("record name is required")
134 + }
135 + if p.token == "" {
136 + return errors.New("cloudflare token is required")
137 + }
138 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
139 + return err
140 + }
141 + publicIPv4 = strings.TrimSpace(publicIPv4)
142 +
143 + zoneID, err := findZoneID(ctx, p.token, name)
144 + if err != nil {
145 + return fmt.Errorf("find cloudflare zone: %w", err)
146 + }
147 + if err := ensureDNSRecord(ctx, p.token, zoneID, name, "A", publicIPv4); err != nil {
148 + return fmt.Errorf("ensure A record for %s: %w", name, err)
149 + }
150 + return nil
151 +}
152 +
153 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
154 + if p == nil {
155 + return errors.New("cloudflare provider is nil")
156 + }
157 + name = utils.NormalizeHostname(name)
158 + if name == "" {
159 + return errors.New("record name is required")
160 + }
161 + if p.token == "" {
162 + return errors.New("cloudflare token is required")
163 + }
164 +
165 + zoneID, err := findZoneID(ctx, p.token, name)
166 + if err != nil {
167 + return fmt.Errorf("find cloudflare zone: %w", err)
168 + }
169 +
170 + records, err := listDNSRecords(ctx, p.token, zoneID, name, "A")
171 + if err != nil {
172 + return err
173 + }
174 + for _, record := range records {
175 + if !strings.EqualFold(record.Name, name) {
176 + continue
177 + }
178 + if err := deleteDNSRecord(ctx, p.token, zoneID, record.ID); err != nil {
179 + return fmt.Errorf("delete A record %s: %w", name, err)
180 + }
181 + }
182 + return nil
183 +}
184 +
185 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
186 if p == nil {
187 return errors.New("cloudflare provider is nil")
portal/acme/gcloud/provider.go
+57
@@ -106,6 +106,63 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
106 return nil
107 }
108
109 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
110 + if p == nil {
111 + return errors.New("gcloud provider is nil")
112 + }
113 + name = utils.NormalizeHostname(name)
114 + if name == "" {
115 + return errors.New("record name is required")
116 + }
117 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
118 + return err
119 + }
120 +
121 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
122 + if err != nil {
123 + return err
124 + }
125 +
126 + if err := ensureRecordSet(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.ResourceRecordSet{
127 + Name: fqdn(name),
128 + Type: "A",
129 + Ttl: defaultRecordTTL,
130 + Rrdatas: []string{strings.TrimSpace(publicIPv4)},
131 + }); err != nil {
132 + return fmt.Errorf("upsert gcloud A record %s: %w", name, err)
133 + }
134 + return nil
135 +}
136 +
137 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
138 + if p == nil {
139 + return errors.New("gcloud provider is nil")
140 + }
141 + name = utils.NormalizeHostname(name)
142 + if name == "" {
143 + return errors.New("record name is required")
144 + }
145 +
146 + service, runtimeCfg, zone, err := newService(ctx, p.cfg, name)
147 + if err != nil {
148 + return err
149 + }
150 +
151 + existing, err := listRecordSets(ctx, service, runtimeCfg.ProjectID, zone.Name, name, "A")
152 + if err != nil {
153 + return fmt.Errorf("list gcloud A records %s: %w", name, err)
154 + }
155 + if len(existing) == 0 {
156 + return nil
157 + }
158 + if err := applyChange(ctx, service, runtimeCfg.ProjectID, zone.Name, &dns.Change{
159 + Deletions: existing,
160 + }); err != nil {
161 + return fmt.Errorf("delete gcloud A record %s: %w", name, err)
162 + }
163 + return nil
164 +}
165 +
166 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
167 if p == nil {
168 return errors.New("gcloud provider is nil")
portal/acme/provider.go
+2
@@ -23,6 +23,8 @@ type DNSProvider interface {
23 Name() string
24 ChallengeProvider(ctx context.Context) (challenge.Provider, error)
25 EnsureARecords(ctx context.Context, baseDomain, publicIPv4 string) error
26 + EnsureARecord(ctx context.Context, name, publicIPv4 string) error
27 + DeleteARecord(ctx context.Context, name string) error
28 EnsureTXTRecord(ctx context.Context, name, value string) error
29 DeleteTXTRecords(ctx context.Context, name, matchPrefix string) error
30 EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error)
portal/acme/route53/provider.go
+64 -2
@@ -109,6 +109,64 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
109 return nil
110 }
111
112 +func (p *Provider) EnsureARecord(ctx context.Context, name, publicIPv4 string) error {
113 + if p == nil {
114 + return errors.New("route53 provider is nil")
115 + }
116 + name = utils.NormalizeHostname(name)
117 + if name == "" {
118 + return errors.New("record name is required")
119 + }
120 + if err := utils.ValidateIPv4(publicIPv4); err != nil {
121 + return err
122 + }
123 +
124 + client, err := newClient(ctx, p.cfg)
125 + if err != nil {
126 + return err
127 + }
128 +
129 + hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
130 + if err != nil {
131 + return err
132 + }
133 + if err := upsertARecord(ctx, client, hostedZoneID, name, publicIPv4); err != nil {
134 + return fmt.Errorf("upsert route53 A record %s: %w", name, err)
135 + }
136 + return nil
137 +}
138 +
139 +func (p *Provider) DeleteARecord(ctx context.Context, name string) error {
140 + if p == nil {
141 + return errors.New("route53 provider is nil")
142 + }
143 + name = utils.NormalizeHostname(name)
144 + if name == "" {
145 + return errors.New("record name is required")
146 + }
147 +
148 + client, err := newClient(ctx, p.cfg)
149 + if err != nil {
150 + return err
151 + }
152 +
153 + hostedZoneID, err := findHostedZoneID(ctx, client, name, p.cfg.HostedZoneID)
154 + if err != nil {
155 + return err
156 + }
157 + recordSet, err := getRecordSet(ctx, client, hostedZoneID, name, route53types.RRTypeA)
158 + if err != nil {
159 + return err
160 + }
161 + if recordSet == nil {
162 + return nil
163 + }
164 + if err := deleteRecordSet(ctx, client, hostedZoneID, recordSet, "Managed by Portal ENS cleanup"); err != nil {
165 + return fmt.Errorf("delete route53 A record %s: %w", name, err)
166 + }
167 + return nil
168 +}
169 +
170 func (p *Provider) EnsureTXTRecord(ctx context.Context, name, value string) error {
171 if p == nil {
172 return errors.New("route53 provider is nil")
@@ -396,6 +454,10 @@ func route53TXTContent(value string) string {
454 }
455
456 func getTXTRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneID, name string) (*route53types.ResourceRecordSet, error) {
457 + return getRecordSet(ctx, client, hostedZoneID, name, route53types.RRTypeTxt)
458 +}
459 +
460 +func getRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneID, name string, recordType route53types.RRType) (*route53types.ResourceRecordSet, error) {
461 if client == nil {
462 return nil, errors.New("route53 client is nil")
463 }
@@ -407,7 +469,7 @@ func getTXTRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneI
469 out, err := client.ListResourceRecordSets(ctx, &awsroute53.ListResourceRecordSetsInput{
470 HostedZoneId: aws.String(hostedZoneID),
471 StartRecordName: aws.String(fqdn),
410 - StartRecordType: route53types.RRTypeTxt,
472 + StartRecordType: recordType,
473 MaxItems: aws.Int32(1),
474 })
475 if err != nil {
@@ -417,7 +479,7 @@ func getTXTRecordSet(ctx context.Context, client *awsroute53.Client, hostedZoneI
479 return nil, nil
480 }
481 recordSet := out.ResourceRecordSets[0]
420 - if !strings.EqualFold(strings.TrimSpace(aws.ToString(recordSet.Name)), fqdn) || recordSet.Type != route53types.RRTypeTxt {
482 + if !strings.EqualFold(strings.TrimSpace(aws.ToString(recordSet.Name)), fqdn) || recordSet.Type != recordType {
483 return nil, nil
484 }
485 return &recordSet, nil