main
go 983 lines 27.4 KB
Raw
1 package acme
2
3 import (
4 "bytes"
5 "context"
6 "crypto"
7 "crypto/ecdsa"
8 "crypto/elliptic"
9 "crypto/rand"
10 "crypto/x509"
11 "encoding/base64"
12 "encoding/pem"
13 "errors"
14 "fmt"
15 "os"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "sync"
20 "time"
21
22 "github.com/go-acme/lego/v4/certcrypto"
23 "github.com/go-acme/lego/v4/certificate"
24 lego "github.com/go-acme/lego/v4/lego"
25 "github.com/go-acme/lego/v4/registration"
26 "github.com/rs/zerolog/log"
27
28 "github.com/gosuda/portal-tunnel/v2/portal/identity"
29 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
30 "github.com/gosuda/portal-tunnel/v2/types"
31 "github.com/gosuda/portal-tunnel/v2/utils"
32 )
33
34 const (
35 fullChainFileName = "fullchain.pem"
36 keyFileName = "privatekey.pem"
37 accountKeyFileName = "acme-account.key"
38 registrationFileName = "acme-registration.json"
39 ensGaslessHostnamesFileName = "ens-gasless-hostnames.json"
40 gaslessENSTXTPrefix = "ENS1 "
41 defaultENSGaslessResolver = "0x238A8F792dFA6033814B18618aD4100654aeef01"
42 defaultACMEEmailPrefix = "acme@"
43 defaultRenewInterval = 24 * time.Hour
44 defaultDNSSyncInterval = 10 * time.Minute
45 defaultSyncTimeout = 2 * time.Minute
46 )
47
48 type Config struct {
49 BaseDomain string
50 KeyDir string
51 DNSProvider string
52 ENSGaslessEnabled bool
53 ENSGaslessAddress string
54 CloudflareToken string
55 GCPProjectID string
56 GCPManagedZone string
57 HetznerAPIToken string
58 AWSAccessKeyID string
59 AWSSecretAccessKey string
60 AWSSessionToken string
61 AWSRegion string
62 AWSHostedZoneID string
63 AWSKMSKeyARN string
64 VultrAPIKey string
65 NjallaToken string
66 }
67
68 type Manager struct {
69 stopCh chan struct{}
70 cfg Config
71 wg sync.WaitGroup
72 dns DNSProvider
73 startOnce sync.Once
74 stopOnce sync.Once
75 dnssecLogOnce sync.Once
76 ensLogOnce sync.Once
77 ensStatus *utils.Snapshot[types.ENSStatus]
78 trackedMu sync.Mutex
79 echMu sync.Mutex
80 echRecords map[string]HTTPSRecord
81 }
82
83 type HTTPSRecord struct {
84 Priority uint16
85 Target string
86 Port int
87 ECHConfigList []byte
88 }
89
90 func (r HTTPSRecord) Normalized() (HTTPSRecord, error) {
91 target := strings.TrimSpace(r.Target)
92 if target == "" {
93 target = "."
94 }
95 if target != "." {
96 target = strings.TrimSuffix(target, ".")
97 if target == "" {
98 target = "."
99 } else {
100 target += "."
101 }
102 }
103 priority := r.Priority
104 if priority == 0 {
105 priority = 1
106 }
107 if len(r.ECHConfigList) == 0 {
108 return HTTPSRecord{}, errors.New("ech config list is required")
109 }
110 if r.Port < 0 || r.Port > 65535 {
111 return HTTPSRecord{}, errors.New("https record port must be between 0 and 65535")
112 }
113 return HTTPSRecord{
114 Priority: priority,
115 Target: target,
116 Port: r.Port,
117 ECHConfigList: bytes.Clone(r.ECHConfigList),
118 }, nil
119 }
120
121 func (r HTTPSRecord) Content() (string, error) {
122 normalized, err := r.Normalized()
123 if err != nil {
124 return "", err
125 }
126 return strings.Join([]string{
127 strconv.Itoa(int(normalized.Priority)),
128 normalized.Target,
129 normalized.SvcParams(),
130 }, " "), nil
131 }
132
133 func (r HTTPSRecord) SvcParams() string {
134 normalized, err := r.Normalized()
135 if err != nil {
136 return ""
137 }
138 params := []string{
139 `ech="` + base64.StdEncoding.EncodeToString(normalized.ECHConfigList) + `"`,
140 }
141 if normalized.Port > 0 && normalized.Port != 443 {
142 params = append(params, "port="+strconv.Itoa(normalized.Port))
143 }
144 return strings.Join(params, " ")
145 }
146
147 type acmeUser struct {
148 Key crypto.PrivateKey
149 Registration *registration.Resource
150 Email string
151 }
152
153 func NewManager(cfg Config) (*Manager, error) {
154 cfg.BaseDomain = utils.NormalizeBaseDomain(cfg.BaseDomain)
155 cfg.KeyDir = strings.TrimSpace(cfg.KeyDir)
156 cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
157 cfg.ENSGaslessAddress = strings.TrimSpace(cfg.ENSGaslessAddress)
158 cfg.CloudflareToken = strings.TrimSpace(cfg.CloudflareToken)
159 cfg.GCPProjectID = strings.TrimSpace(cfg.GCPProjectID)
160 cfg.GCPManagedZone = strings.TrimSpace(cfg.GCPManagedZone)
161 cfg.HetznerAPIToken = strings.TrimSpace(cfg.HetznerAPIToken)
162 cfg.AWSAccessKeyID = strings.TrimSpace(cfg.AWSAccessKeyID)
163 cfg.AWSSecretAccessKey = strings.TrimSpace(cfg.AWSSecretAccessKey)
164 cfg.AWSSessionToken = strings.TrimSpace(cfg.AWSSessionToken)
165 cfg.AWSRegion = strings.TrimSpace(cfg.AWSRegion)
166 cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
167 cfg.AWSKMSKeyARN = strings.TrimSpace(cfg.AWSKMSKeyARN)
168 cfg.VultrAPIKey = strings.TrimSpace(cfg.VultrAPIKey)
169 cfg.NjallaToken = strings.TrimSpace(cfg.NjallaToken)
170 if cfg.ENSGaslessEnabled {
171 if cfg.ENSGaslessAddress == "" {
172 return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
173 }
174 address, err := identity.NormalizeEVMAddress(cfg.ENSGaslessAddress)
175 if err != nil {
176 return nil, fmt.Errorf("normalize ens gasless address: %w", err)
177 }
178 cfg.ENSGaslessAddress = address
179 }
180
181 if cfg.KeyDir == "" {
182 return nil, errors.New("acme key directory is required")
183 }
184 if cfg.BaseDomain == "" {
185 return nil, errors.New("acme base domain is required")
186 }
187 if utils.IsLocalRelayHost(cfg.BaseDomain) {
188 return &Manager{
189 cfg: cfg,
190 stopCh: make(chan struct{}),
191 ensStatus: utils.NewSnapshot(newENSStatus(cfg, nil)),
192 }, nil
193 }
194
195 manager := &Manager{
196 cfg: cfg,
197 stopCh: make(chan struct{}),
198 ensStatus: utils.NewSnapshot(newENSStatus(cfg, nil)),
199 }
200
201 acmeDNS, err := NewDNSProvider(cfg.DNSProvider, cfg)
202 if err != nil {
203 return nil, fmt.Errorf("create acme dns provider: %w", err)
204 }
205 manager.dns = acmeDNS
206
207 if cfg.ENSGaslessEnabled && manager.dns == nil {
208 return nil, errors.New("ens gasless automation requires ACME_DNS_PROVIDER")
209 }
210
211 return manager, nil
212 }
213
214 func newENSStatus(cfg Config, dns DNSProvider) types.ENSStatus {
215 provider := strings.TrimSpace(cfg.DNSProvider)
216 if dns != nil {
217 provider = dns.Name()
218 }
219 return types.ENSStatus{
220 Enabled: cfg.ENSGaslessEnabled && !utils.IsLocalRelayHost(cfg.BaseDomain),
221 Provider: provider,
222 Address: strings.TrimSpace(cfg.ENSGaslessAddress),
223 }
224 }
225
226 func (m *Manager) ENSStatus() types.ENSStatus {
227 if m == nil {
228 return types.ENSStatus{}
229 }
230 status := types.ENSStatus{}
231 if m.ensStatus != nil {
232 status = m.ensStatus.Load()
233 }
234 if status.Provider == "" && m.dns != nil {
235 status.Provider = m.dns.Name()
236 }
237 return status
238 }
239
240 func (m *Manager) setENSStatus(state, record, message string, syncErr error) {
241 if m == nil {
242 return
243 }
244 status := newENSStatus(m.cfg, m.dns)
245 status.DNSSECState = strings.TrimSpace(state)
246 status.DSRecord = strings.TrimSpace(record)
247 status.Message = strings.TrimSpace(message)
248 status.Verified = syncErr == nil && ensDNSSECVerified(status.DNSSECState)
249 if syncErr != nil {
250 status.LastError = syncErr.Error()
251 }
252
253 if m.ensStatus == nil {
254 m.ensStatus = utils.NewSnapshot(status)
255 return
256 }
257 m.ensStatus.Store(status)
258 }
259
260 func ensDNSSECVerified(state string) bool {
261 switch strings.ToLower(strings.TrimSpace(state)) {
262 case "active", "on", "signing", "transfer":
263 return true
264 default:
265 return false
266 }
267 }
268
269 func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error) {
270 if m == nil {
271 return "", "", errors.New("acme manager is nil")
272 }
273
274 if utils.IsLocalRelayHost(m.cfg.BaseDomain) {
275 if err := ensureLocalDevelopmentCertificate(m.cfg.KeyDir, m.cfg.BaseDomain); err != nil {
276 return "", "", err
277 }
278 return m.TLSFiles()
279 }
280 if err := m.reconcileTrackedENSGaslessHostnames(ctx); err != nil {
281 return "", "", err
282 }
283 certFile, keyFile, manual, err := m.manualCertificateOverride()
284 if err != nil {
285 return "", "", err
286 }
287 if manual {
288 if err := m.syncENSGasless(ctx); err != nil {
289 return "", "", err
290 }
291 return certFile, keyFile, nil
292 }
293 if !m.managedACME() {
294 return m.ensureManualCertificate()
295 }
296
297 if err := m.syncDNS(ctx); err != nil {
298 return "", "", fmt.Errorf("ensure dns records: %w", err)
299 }
300
301 certFile, keyFile, err = m.TLSFiles()
302 if err == nil {
303 covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
304 if err == nil && covered {
305 return certFile, keyFile, nil
306 }
307 }
308
309 if err := m.provision(ctx); err != nil {
310 return "", "", err
311 }
312 return m.TLSFiles()
313 }
314
315 func (m *Manager) EnsureTLSMaterial(ctx context.Context) ([]byte, []byte, error) {
316 certFile, keyFile, err := m.EnsureCertificate(ctx)
317 if err != nil {
318 return nil, nil, err
319 }
320
321 certPEM, err := os.ReadFile(certFile)
322 if err != nil {
323 return nil, nil, fmt.Errorf("read api tls certificate: %w", err)
324 }
325 keyPEM, err := os.ReadFile(keyFile)
326 if err != nil {
327 return nil, nil, fmt.Errorf("read api tls private key: %w", err)
328 }
329 return certPEM, keyPEM, nil
330 }
331
332 func (m *Manager) Start(ctx context.Context) {
333 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) || (!m.cfg.ENSGaslessEnabled && !m.managedACME()) {
334 return
335 }
336
337 m.startOnce.Do(func() {
338 m.wg.Add(1)
339 go m.maintenanceLoop(ctx)
340 })
341 }
342
343 func (m *Manager) Stop() {
344 if m == nil {
345 return
346 }
347 m.stopOnce.Do(func() {
348 close(m.stopCh)
349 })
350 m.wg.Wait()
351 }
352
353 func (m *Manager) TLSFiles() (string, string, error) {
354 if m == nil {
355 return "", "", errors.New("acme manager is nil")
356 }
357 certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
358 keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
359 if !utils.FileExists(certFile) || !utils.FileExists(keyFile) {
360 return "", "", errors.New("relay certificate files do not exist")
361 }
362 return certFile, keyFile, nil
363 }
364
365 func (m *Manager) managedACME() bool {
366 return m != nil && m.dns != nil
367 }
368
369 func (m *Manager) ensureManualCertificate() (string, string, error) {
370 certFile, keyFile, err := m.TLSFiles()
371 if err != nil {
372 return "", "", fmt.Errorf("manual certificate mode requires %s and %s in %s or configure ACME_DNS_PROVIDER", fullChainFileName, keyFileName, m.cfg.KeyDir)
373 }
374
375 covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
376 if err != nil {
377 return "", "", fmt.Errorf("validate relay certificate: %w", err)
378 }
379 if !covered {
380 return "", "", fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
381 }
382 return certFile, keyFile, nil
383 }
384
385 func (m *Manager) manualCertificateOverride() (string, string, bool, error) {
386 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
387 return "", "", false, nil
388 }
389 certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
390 keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
391 if !utils.FileExists(certFile) || !utils.FileExists(keyFile) {
392 return "", "", false, nil
393 }
394 var err error
395 covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
396 if err != nil {
397 return "", "", false, fmt.Errorf("validate relay certificate: %w", err)
398 }
399 hasACMEState := utils.FileExists(filepath.Join(m.cfg.KeyDir, accountKeyFileName)) || utils.FileExists(filepath.Join(m.cfg.KeyDir, registrationFileName))
400 if !covered {
401 if !hasACMEState {
402 return "", "", false, fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
403 }
404 return "", "", false, nil
405 }
406 if hasACMEState {
407 return "", "", false, nil
408 }
409 return certFile, keyFile, true, nil
410 }
411
412 func (m *Manager) provision(ctx context.Context) error {
413 keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
414 certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
415 accountKeyFile := filepath.Join(m.cfg.KeyDir, accountKeyFileName)
416 registrationFile := filepath.Join(m.cfg.KeyDir, registrationFileName)
417 domains := certificateDomains(m.cfg.BaseDomain)
418
419 for _, path := range []string{keyFile, certFile, accountKeyFile, registrationFile} {
420 if err := utils.EnsureParentDir(path); err != nil {
421 return err
422 }
423 }
424 if err := ctx.Err(); err != nil {
425 return fmt.Errorf("acme provisioning canceled: %w", err)
426 }
427
428 client, err := newClient(ctx, defaultACMEEmailPrefix+m.cfg.BaseDomain, accountKeyFile, registrationFile, m.dns)
429 if err != nil {
430 return err
431 }
432
433 obtained, err := client.Certificate.Obtain(certificate.ObtainRequest{
434 Domains: domains,
435 Bundle: true,
436 })
437 if err != nil {
438 return fmt.Errorf("obtain certificate: %w", err)
439 }
440 if len(obtained.Certificate) == 0 || len(obtained.PrivateKey) == 0 {
441 return errors.New("acme obtain response missing certificate or private key")
442 }
443
444 if err := utils.WriteFileAtomic(certFile, obtained.Certificate, 0o644); err != nil {
445 return fmt.Errorf("write certificate chain: %w", err)
446 }
447 if err := utils.WriteFileAtomic(keyFile, obtained.PrivateKey, 0o600); err != nil {
448 return fmt.Errorf("write private key: %w", err)
449 }
450 return nil
451 }
452
453 func (m *Manager) maintenanceLoop(ctx context.Context) {
454 defer m.wg.Done()
455
456 renewTicker := time.NewTicker(defaultRenewInterval)
457 dnsTicker := time.NewTicker(defaultDNSSyncInterval)
458 defer renewTicker.Stop()
459 defer dnsTicker.Stop()
460
461 for {
462 select {
463 case <-ctx.Done():
464 return
465 case <-m.stopCh:
466 return
467 case <-dnsTicker.C:
468 syncCtx, cancel := context.WithTimeout(ctx, defaultSyncTimeout)
469 err := errors.Join(m.syncDNS(syncCtx), m.syncECHRecords(syncCtx))
470 cancel()
471 if err != nil {
472 log.Warn().Err(err).Str("base_domain", m.cfg.BaseDomain).Msg("sync dns records")
473 }
474 case <-renewTicker.C:
475 _, _, manual, err := m.manualCertificateOverride()
476 if err != nil || manual || !m.managedACME() || !m.shouldRenew() {
477 continue
478 }
479 renewCtx, cancel := context.WithTimeout(ctx, defaultSyncTimeout)
480 err = m.provision(renewCtx)
481 cancel()
482 if err != nil {
483 log.Warn().Err(err).Str("base_domain", m.cfg.BaseDomain).Msg("renew acme certificate")
484 }
485 }
486 }
487 }
488
489 func (m *Manager) syncDNS(ctx context.Context) error {
490 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
491 return nil
492 }
493 if err := m.syncENSGasless(ctx); err != nil {
494 return err
495 }
496 _, _, manual, err := m.manualCertificateOverride()
497 if err != nil {
498 return err
499 }
500 if manual || !m.managedACME() {
501 return nil
502 }
503
504 publicIP, err := utils.ResolvePublicIPv4(ctx)
505 if err != nil {
506 return fmt.Errorf("detect public ip: %w", err)
507 }
508
509 return m.dns.EnsureARecords(ctx, m.cfg.BaseDomain, publicIP)
510 }
511
512 func (m *Manager) SyncECHConfig(ctx context.Context, hostname string, echConfigList []byte, port int) error {
513 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
514 return nil
515 }
516 if m.dns == nil {
517 return nil
518 }
519 hostname = utils.NormalizeHostname(hostname)
520 if hostname == "" {
521 return errors.New("hostname is required")
522 }
523 if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
524 return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
525 }
526 echConfigList, err := keyless.NormalizeEncryptedClientHelloConfigList(echConfigList)
527 if err != nil {
528 return err
529 }
530 record := HTTPSRecord{
531 Priority: 1,
532 Target: ".",
533 Port: port,
534 ECHConfigList: echConfigList,
535 }
536 record, err = record.Normalized()
537 if err != nil {
538 return err
539 }
540 content, err := record.Content()
541 if err != nil {
542 return err
543 }
544 svcParams := record.SvcParams()
545
546 m.echMu.Lock()
547 if m.echRecords == nil {
548 m.echRecords = make(map[string]HTTPSRecord)
549 }
550 m.echRecords[hostname] = record
551 m.echMu.Unlock()
552
553 publicIP, err := utils.ResolvePublicIPv4(ctx)
554 if err != nil {
555 return fmt.Errorf("detect public ip for ECH hostname %s: %w", hostname, err)
556 }
557 if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
558 return fmt.Errorf("ensure ECH A record for %s: %w", hostname, err)
559 }
560
561 if err := m.dns.EnsureHTTPSRecord(ctx, hostname, record.Priority, record.Target, svcParams, content); err != nil {
562 return err
563 }
564 return nil
565 }
566
567 func (m *Manager) DeleteECHConfig(ctx context.Context, hostname string) error {
568 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
569 return nil
570 }
571 if m.dns == nil {
572 return nil
573 }
574 hostname = utils.NormalizeHostname(hostname)
575 if hostname == "" {
576 return nil
577 }
578 if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
579 return nil
580 }
581
582 if err := m.dns.DeleteHTTPSRecord(ctx, hostname); err != nil {
583 return err
584 }
585 if hostname != m.cfg.BaseDomain {
586 if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
587 return fmt.Errorf("delete ECH A record for %s: %w", hostname, err)
588 }
589 }
590
591 m.echMu.Lock()
592 delete(m.echRecords, hostname)
593 m.echMu.Unlock()
594 return nil
595 }
596
597 func (m *Manager) syncECHRecords(ctx context.Context) error {
598 if m == nil || m.dns == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
599 return nil
600 }
601
602 m.echMu.Lock()
603 records := make(map[string]HTTPSRecord, len(m.echRecords))
604 for hostname, record := range m.echRecords {
605 records[hostname] = record
606 }
607 m.echMu.Unlock()
608
609 var syncErr error
610 publicIP := ""
611 if len(records) > 0 {
612 var err error
613 publicIP, err = utils.ResolvePublicIPv4(ctx)
614 if err != nil {
615 return fmt.Errorf("detect public ip for ECH records: %w", err)
616 }
617 }
618 for hostname, record := range records {
619 if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
620 syncErr = errors.Join(syncErr, fmt.Errorf("ensure ECH A record for %s: %w", hostname, err))
621 continue
622 }
623 content, err := record.Content()
624 if err != nil {
625 syncErr = errors.Join(syncErr, fmt.Errorf("build ECH HTTPS record for %s: %w", hostname, err))
626 continue
627 }
628 if err := m.dns.EnsureHTTPSRecord(ctx, hostname, record.Priority, record.Target, record.SvcParams(), content); err != nil {
629 syncErr = errors.Join(syncErr, fmt.Errorf("ensure ECH HTTPS record for %s: %w", hostname, err))
630 }
631 }
632 return syncErr
633 }
634
635 func (m *Manager) syncENSGasless(ctx context.Context) error {
636 if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
637 return nil
638 }
639 if m.dns == nil {
640 return errors.New("ACME_DNS_PROVIDER is required")
641 }
642
643 state, dsRecord, message, err := m.dns.EnsureDNSSEC(ctx, m.cfg.BaseDomain)
644 if err != nil {
645 m.setENSStatus("", "", "", err)
646 return fmt.Errorf("ensure dnssec: %w", err)
647 }
648 m.dnssecLogOnce.Do(func() {
649 event := log.Info().
650 Str("provider", m.dns.Name()).
651 Str("base_domain", m.cfg.BaseDomain).
652 Str("state", strings.TrimSpace(state))
653 if strings.TrimSpace(dsRecord) != "" {
654 event = event.Str("ds_record", strings.TrimSpace(dsRecord))
655 }
656 if strings.TrimSpace(message) != "" {
657 event = event.Str("message", strings.TrimSpace(message))
658 }
659 event.Msg("dnssec configured")
660 })
661
662 if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
663 err = fmt.Errorf("ensure ens gasless txt: %w", err)
664 m.setENSStatus(state, dsRecord, message, err)
665 return err
666 }
667 if err := m.syncTrackedENSGaslessHostARecords(ctx); err != nil {
668 m.setENSStatus(state, dsRecord, message, err)
669 return err
670 }
671 m.setENSStatus(state, dsRecord, message, nil)
672 m.ensLogOnce.Do(func() {
673 log.Info().
674 Str("provider", m.dns.Name()).
675 Str("base_domain", m.cfg.BaseDomain).
676 Str("address", m.cfg.ENSGaslessAddress).
677 Msg("ens gasless dns import configured")
678 })
679 return nil
680 }
681
682 func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address string) error {
683 if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
684 return nil
685 }
686 if m.dns == nil {
687 return errors.New("ACME_DNS_PROVIDER is required")
688 }
689
690 hostname = utils.NormalizeHostname(hostname)
691 if hostname == "" {
692 return errors.New("hostname is required")
693 }
694 if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
695 return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
696 }
697
698 address, err := identity.NormalizeEVMAddress(address)
699 if err != nil {
700 return fmt.Errorf("normalize ens gasless address: %w", err)
701 }
702 if err := m.syncENSGaslessHostnameARecord(ctx, hostname); err != nil {
703 return err
704 }
705 if err := m.dns.EnsureTXTRecord(ctx, hostname, gaslessENSTXTPrefix+defaultENSGaslessResolver+" "+strings.TrimSpace(address)); err != nil {
706 return err
707 }
708 return m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
709 return append(hostnames, hostname)
710 })
711 }
712
713 func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string) error {
714 if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
715 return nil
716 }
717 if m.dns == nil {
718 return errors.New("ACME_DNS_PROVIDER is required")
719 }
720
721 hostname = utils.NormalizeHostname(hostname)
722 if hostname == "" {
723 return nil
724 }
725 if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
726 return nil
727 }
728 if hostname == m.cfg.BaseDomain {
729 return nil
730 }
731 if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
732 return err
733 }
734 if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
735 return err
736 }
737 return m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
738 filtered := hostnames[:0]
739 for _, tracked := range hostnames {
740 if tracked == hostname {
741 continue
742 }
743 filtered = append(filtered, tracked)
744 }
745 return filtered
746 })
747 }
748
749 func (m *Manager) reconcileTrackedENSGaslessHostnames(ctx context.Context) error {
750 if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) || m.dns == nil {
751 return nil
752 }
753
754 var cleanupErr error
755 if err := m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
756 remaining := hostnames[:0]
757 for _, hostname := range hostnames {
758 if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
759 remaining = append(remaining, hostname)
760 cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ens gasless txt for %s: %w", hostname, err))
761 continue
762 }
763 if err := m.dns.DeleteARecord(ctx, hostname); err != nil {
764 remaining = append(remaining, hostname)
765 cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ens gasless A record for %s: %w", hostname, err))
766 }
767 }
768 return remaining
769 }); err != nil {
770 cleanupErr = errors.Join(cleanupErr, fmt.Errorf("persist ens gasless hostnames: %w", err))
771 }
772 return cleanupErr
773 }
774
775 func (m *Manager) syncTrackedENSGaslessHostARecords(ctx context.Context) error {
776 hostnames, err := m.trackedENSGaslessHostnames()
777 if err != nil || len(hostnames) == 0 {
778 return err
779 }
780
781 publicIP, err := utils.ResolvePublicIPv4(ctx)
782 if err != nil {
783 return fmt.Errorf("detect public ip: %w", err)
784 }
785 for _, hostname := range hostnames {
786 if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
787 return fmt.Errorf("ensure ens gasless A record for %s: %w", hostname, err)
788 }
789 }
790 return nil
791 }
792
793 func (m *Manager) syncENSGaslessHostnameARecord(ctx context.Context, hostname string) error {
794 hostname = utils.NormalizeHostname(hostname)
795 if hostname == "" || hostname == m.cfg.BaseDomain {
796 return nil
797 }
798
799 publicIP, err := utils.ResolvePublicIPv4(ctx)
800 if err != nil {
801 return fmt.Errorf("detect public ip: %w", err)
802 }
803 if err := m.dns.EnsureARecord(ctx, hostname, publicIP); err != nil {
804 return fmt.Errorf("ensure ens gasless A record for %s: %w", hostname, err)
805 }
806 return nil
807 }
808
809 func (m *Manager) trackedENSGaslessHostnames() ([]string, error) {
810 if m == nil {
811 return nil, nil
812 }
813
814 path := filepath.Join(m.cfg.KeyDir, ensGaslessHostnamesFileName)
815 var hostnames []string
816 if _, err := utils.ReadJSONFileIfExists(path, &hostnames); err != nil {
817 return nil, err
818 }
819 return utils.NormalizeChildHostnames(hostnames, m.cfg.BaseDomain), nil
820 }
821
822 func (m *Manager) updateTrackedENSGaslessHostnames(update func([]string) []string) error {
823 if m == nil {
824 return nil
825 }
826
827 m.trackedMu.Lock()
828 defer m.trackedMu.Unlock()
829
830 path := filepath.Join(m.cfg.KeyDir, ensGaslessHostnamesFileName)
831 hostnames, err := m.trackedENSGaslessHostnames()
832 if err != nil {
833 return err
834 }
835 if update != nil {
836 hostnames = utils.NormalizeChildHostnames(update(hostnames), m.cfg.BaseDomain)
837 }
838 if len(hostnames) == 0 {
839 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
840 return err
841 }
842 return nil
843 }
844 return utils.WriteJSONFile(path, hostnames, 0o600)
845 }
846
847 func (m *Manager) shouldRenew() bool {
848 certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
849 needsRenewal, err := certNeedsRenewal(certFile, certificateDomains(m.cfg.BaseDomain))
850 return err == nil && needsRenewal
851 }
852
853 func certificateDomains(baseDomain string) []string {
854 return []string{baseDomain, "*." + baseDomain}
855 }
856
857 func certNeedsRenewal(certFile string, domains []string) (bool, error) {
858 cert, err := loadCertificate(certFile)
859 if err != nil {
860 return false, err
861 }
862 if time.Until(cert.NotAfter) < 30*24*time.Hour {
863 return true, nil
864 }
865 return !certificateCoversDomains(cert, domains), nil
866 }
867
868 func certCoversDomains(certFile string, domains []string) (bool, error) {
869 cert, err := loadCertificate(certFile)
870 if err != nil {
871 return false, err
872 }
873 return certificateCoversDomains(cert, domains), nil
874 }
875
876 func loadCertificate(certFile string) (*x509.Certificate, error) {
877 certPEM, err := os.ReadFile(certFile)
878 if err != nil {
879 return nil, err
880 }
881 return utils.ParseCertificatePEM(certPEM)
882 }
883
884 func certificateCoversDomains(cert *x509.Certificate, domains []string) bool {
885 for _, domain := range domains {
886 if wildcardDomain, ok := strings.CutPrefix(domain, "*."); ok {
887 if !certificateCoversHostname(cert, "probe."+wildcardDomain) {
888 return false
889 }
890 continue
891 }
892 if !certificateCoversHostname(cert, domain) {
893 return false
894 }
895 }
896 return true
897 }
898
899 func certificateCoversHostname(cert *x509.Certificate, hostname string) bool {
900 return cert != nil && cert.VerifyHostname(hostname) == nil
901 }
902
903 func newClient(ctx context.Context, email, accountKeyFile, registrationFile string, dnsProvider DNSProvider) (*lego.Client, error) {
904 accountKey, err := loadOrCreateAccountKey(accountKeyFile)
905 if err != nil {
906 return nil, fmt.Errorf("load acme account key: %w", err)
907 }
908
909 var accountReg registration.Resource
910 accountRegPtr := (*registration.Resource)(nil)
911 if ok, err := utils.ReadJSONFileIfExists(registrationFile, &accountReg); err != nil {
912 return nil, fmt.Errorf("load acme registration: %w", err)
913 } else if ok {
914 accountRegPtr = &accountReg
915 }
916
917 user := &acmeUser{
918 Email: email,
919 Key: accountKey,
920 Registration: accountRegPtr,
921 }
922
923 clientConfig := lego.NewConfig(user)
924 clientConfig.CADirURL = lego.LEDirectoryProduction
925 clientConfig.Certificate.KeyType = certcrypto.RSA2048
926
927 client, err := lego.NewClient(clientConfig)
928 if err != nil {
929 return nil, fmt.Errorf("create acme client: %w", err)
930 }
931
932 if dnsProvider == nil {
933 return nil, errors.New("ACME_DNS_PROVIDER is required")
934 }
935 challengeProvider, err := dnsProvider.ChallengeProvider(ctx)
936 if err != nil {
937 return nil, fmt.Errorf("create dns challenge provider: %w", err)
938 }
939 if err := client.Challenge.SetDNS01Provider(challengeProvider); err != nil {
940 return nil, fmt.Errorf("set dns01 provider: %w", err)
941 }
942
943 if user.Registration == nil {
944 reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
945 if err != nil {
946 return nil, fmt.Errorf("register acme account: %w", err)
947 }
948 user.Registration = reg
949 if err := utils.WriteJSONFile(registrationFile, reg, 0o600); err != nil {
950 return nil, fmt.Errorf("persist acme registration: %w", err)
951 }
952 }
953
954 return client, nil
955 }
956
957 func (u *acmeUser) GetEmail() string { return u.Email }
958 func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration }
959 func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.Key }
960
961 func loadOrCreateAccountKey(path string) (crypto.PrivateKey, error) {
962 keyPEM, err := os.ReadFile(path)
963 if err == nil {
964 return utils.ParsePrivateKeyPEM(keyPEM)
965 }
966 if !errors.Is(err, os.ErrNotExist) {
967 return nil, err
968 }
969
970 key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
971 if err != nil {
972 return nil, fmt.Errorf("generate account key: %w", err)
973 }
974 pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
975 if err != nil {
976 return nil, fmt.Errorf("marshal account key: %w", err)
977 }
978 keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
979 if err := utils.WriteFileAtomic(path, keyPEM, 0o600); err != nil {
980 return nil, fmt.Errorf("persist account key: %w", err)
981 }
982 return key, nil
983 }