refact: consoliate to utils
Kim committed
Apr 2, 2026 at 10:19 UTC
d2302fddf781035bfc6dbb76232d7181938e6fca
13 files changed
+372
-413
cmd/relay-server/admin.go
+26
-76
@@ -2,12 +2,8 @@ package main
2
3
import (
4
"crypto/subtle"
5
- "encoding/json"
6
- "errors"
5
"net"
6
"net/http"
9
- "os"
10
- "path/filepath"
7
"strings"
8
"sync"
9
"time"
@@ -98,22 +94,13 @@ func (a *adminAuth) cleanupExpiredSessionsLocked() {
94
}
95
96
func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) {
101
- root, name, err := openSettingsRoot(path)
102
- if err != nil {
103
- return persistedAdminState{}, err
104
- }
105
- defer root.Close()
106
-
107
- data, err := root.ReadFile(name)
108
- if err != nil {
109
- if errors.Is(err, os.ErrNotExist) {
110
- return persistedAdminState{}, nil
111
- }
112
- return persistedAdminState{}, err
97
+ path = strings.TrimSpace(path)
98
+ if path == "" {
99
+ return persistedAdminState{}, nil
100
}
101
102
var payload persistedAdminState
116
- if err := json.Unmarshal(data, &payload); err != nil {
103
+ if _, err := utils.ReadJSONFileIfExists(path, &payload); err != nil {
104
return persistedAdminState{}, err
105
}
106
if err := payload.apply(runtime); err != nil {
@@ -208,7 +195,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
195
return
196
}
197
f.setLandingPageEnabled(req.Enabled)
211
- f.saveAdminState(runtime)
198
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
199
utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{
200
Enabled: f.isLandingPageEnabled(),
201
})
@@ -225,7 +212,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
212
return
213
}
214
runtime.SetUDPPolicy(req.Enabled, req.MaxLeases)
228
- f.saveAdminState(runtime)
215
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
216
utils.WriteAPIData(w, http.StatusOK, types.AdminUDPSettingsResponse{
217
Enabled: runtime.IsUDPEnabled(),
218
MaxLeases: runtime.UDPMaxLeases(),
@@ -242,7 +229,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
229
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidMode, "invalid mode (must be 'auto' or 'manual')")
230
return
231
}
245
- f.saveAdminState(runtime)
232
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
233
utils.WriteAPIData(w, http.StatusOK, types.AdminApprovalModeResponse{
234
ApprovalMode: string(runtime.Approver().Mode()),
235
})
@@ -287,7 +274,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
274
methodNotAllowed.Write(w)
275
return
276
}
290
- f.saveAdminState(runtime)
277
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
278
utils.WriteAPIEmpty(w, http.StatusOK)
279
case "bps":
280
switch r.Method {
@@ -307,7 +294,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
294
methodNotAllowed.Write(w)
295
return
296
}
310
- f.saveAdminState(runtime)
297
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
298
utils.WriteAPIEmpty(w, http.StatusOK)
299
case "approve":
300
approver := runtime.Approver()
@@ -321,7 +308,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
308
methodNotAllowed.Write(w)
309
return
310
}
324
- f.saveAdminState(runtime)
311
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
312
utils.WriteAPIEmpty(w, http.StatusOK)
313
case "deny":
314
approver := runtime.Approver()
@@ -334,7 +321,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
321
methodNotAllowed.Write(w)
322
return
323
}
337
- f.saveAdminState(runtime)
324
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
325
utils.WriteAPIEmpty(w, http.StatusOK)
326
default:
327
http.NotFound(w, r)
@@ -362,7 +349,7 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
349
methodNotAllowed.Write(w)
350
return
351
}
365
- f.saveAdminState(runtime)
352
+ saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
353
utils.WriteAPIEmpty(w, http.StatusOK)
354
default:
355
http.NotFound(w, r)
@@ -416,45 +403,16 @@ func (f *Frontend) isAuthenticated(r *http.Request) bool {
403
return f.auth.ValidateSession(cookie.Value)
404
}
405
419
-func (f *Frontend) saveAdminState(runtime *policy.Runtime) {
420
- if f == nil {
421
- return
422
- }
423
- saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
424
-}
425
-
406
func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) {
427
- payload := persistedStateFromRuntime(runtime, landingPageEnabled)
428
- data, err := json.MarshalIndent(payload, "", " ")
429
- if err != nil {
430
- return
431
- }
432
-
433
- root, name, err := openSettingsRoot(path)
434
- if err != nil {
407
+ path = strings.TrimSpace(path)
408
+ if path == "" {
409
return
410
}
437
- defer root.Close()
438
- _ = root.WriteFile(name, data, 0o600)
439
-}
440
-
441
-type persistedAdminState struct {
442
- ApprovalMode string `json:"approval_mode"`
443
- ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
444
- DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
445
- BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
446
- BannedIPs []string `json:"banned_ips,omitempty"`
447
- IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
448
- UDPEnabled *bool `json:"udp_enabled,omitempty"`
449
- UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
450
- LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
451
-}
411
453
-func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool) persistedAdminState {
412
approver := runtime.Approver()
413
udpEnabled := runtime.IsUDPEnabled()
414
udpMaxLeases := runtime.UDPMaxLeases()
457
- return persistedAdminState{
415
+ payload := persistedAdminState{
416
ApprovalMode: string(approver.Mode()),
417
ApprovedIdentityKeys: approver.ApprovedKeys(),
418
DeniedIdentityKeys: approver.DeniedKeys(),
@@ -465,13 +423,19 @@ func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool)
423
UDPMaxLeases: &udpMaxLeases,
424
LandingPageEnabled: &landingPageEnabled,
425
}
426
+ _ = utils.WriteJSONFile(path, payload, 0o600)
427
}
428
470
-func (s persistedAdminState) landingPageEnabled(defaultEnabled bool) bool {
471
- if s.LandingPageEnabled == nil {
472
- return defaultEnabled
473
- }
474
- return *s.LandingPageEnabled
429
+type persistedAdminState struct {
430
+ ApprovalMode string `json:"approval_mode"`
431
+ ApprovedIdentityKeys []string `json:"approved_identity_keys,omitempty"`
432
+ DeniedIdentityKeys []string `json:"denied_identity_keys,omitempty"`
433
+ BannedIdentityKeys []string `json:"banned_identity_keys,omitempty"`
434
+ BannedIPs []string `json:"banned_ips,omitempty"`
435
+ IdentityBPS map[string]int64 `json:"identity_bps,omitempty"`
436
+ UDPEnabled *bool `json:"udp_enabled,omitempty"`
437
+ UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
438
+ LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
439
}
440
441
func (s persistedAdminState) apply(runtime *policy.Runtime) error {
@@ -500,17 +464,3 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
464
}
465
return nil
466
}
503
-
504
-func openSettingsRoot(path string) (*os.Root, string, error) {
505
- dir := filepath.Dir(path)
506
- name := filepath.Base(path)
507
- if dir == "" {
508
- dir = "."
509
- }
510
-
511
- root, err := os.OpenRoot(dir)
512
- if err != nil {
513
- return nil, "", err
514
- }
515
- return root, name, nil
516
-}
cmd/relay-server/frontend.go
+5
-1
@@ -61,7 +61,11 @@ func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath st
61
auth: newAdminAuth(adminSecret),
62
adminSettingsPath: strings.TrimSpace(adminSettingsPath),
63
}
64
- frontend.setLandingPageEnabled(state.landingPageEnabled(defaultLandingPageEnabled))
64
+ landingPageEnabled := defaultLandingPageEnabled
65
+ if state.LandingPageEnabled != nil {
66
+ landingPageEnabled = *state.LandingPageEnabled
67
+ }
68
+ frontend.setLandingPageEnabled(landingPageEnabled)
69
return frontend, nil
70
}
71
portal/acme/acme.go
+113
-156
@@ -6,9 +6,7 @@ import (
6
"crypto/ecdsa"
7
"crypto/elliptic"
8
"crypto/rand"
9
- "crypto/rsa"
9
"crypto/x509"
11
- "encoding/json"
10
"encoding/pem"
11
"errors"
12
"fmt"
@@ -28,16 +26,17 @@ import (
26
)
27
28
const (
31
- fullChainFileName = "fullchain.pem"
32
- keyFileName = "privatekey.pem"
33
- accountKeyFileName = "acme-account.key"
34
- registrationFileName = "acme-registration.json"
35
- gaslessENSTXTPrefix = "ENS1 "
36
- defaultENSGaslessResolver = "0x238A8F792dFA6033814B18618aD4100654aeef01"
37
- defaultACMEEmailPrefix = "acme@"
38
- defaultRenewInterval = 24 * time.Hour
39
- defaultDNSSyncInterval = 10 * time.Minute
40
- defaultSyncTimeout = 2 * time.Minute
29
+ fullChainFileName = "fullchain.pem"
30
+ keyFileName = "privatekey.pem"
31
+ accountKeyFileName = "acme-account.key"
32
+ registrationFileName = "acme-registration.json"
33
+ ensGaslessHostnamesFileName = "ens-gasless-hostnames.json"
34
+ gaslessENSTXTPrefix = "ENS1 "
35
+ defaultENSGaslessResolver = "0x238A8F792dFA6033814B18618aD4100654aeef01"
36
+ defaultACMEEmailPrefix = "acme@"
37
+ defaultRenewInterval = 24 * time.Hour
38
+ defaultDNSSyncInterval = 10 * time.Minute
39
+ defaultSyncTimeout = 2 * time.Minute
40
)
41
42
type Config struct {
@@ -65,6 +64,7 @@ type Manager struct {
64
stopOnce sync.Once
65
dnssecLogOnce sync.Once
66
ensLogOnce sync.Once
67
+ trackedMu sync.Mutex
68
}
69
70
type acmeUser struct {
@@ -74,7 +74,7 @@ type acmeUser struct {
74
}
75
76
func NewManager(cfg Config) (*Manager, error) {
77
- cfg.BaseDomain = strings.TrimPrefix(utils.NormalizeHostname(cfg.BaseDomain), "*.")
77
+ cfg.BaseDomain = utils.NormalizeBaseDomain(cfg.BaseDomain)
78
cfg.KeyDir = strings.TrimSpace(cfg.KeyDir)
79
cfg.DNSProvider = strings.ToLower(strings.TrimSpace(cfg.DNSProvider))
80
cfg.ENSGaslessAddress = strings.TrimSpace(cfg.ENSGaslessAddress)
@@ -139,6 +139,9 @@ func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error)
139
}
140
return m.TLSFiles()
141
}
142
+ if err := m.reconcileTrackedENSGaslessHostnames(ctx); err != nil {
143
+ return "", "", err
144
+ }
145
certFile, keyFile, manual, err := m.manualCertificateOverride()
146
if err != nil {
147
return "", "", err
@@ -215,7 +218,7 @@ func (m *Manager) TLSFiles() (string, string, error) {
218
}
219
certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
220
keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
218
- if !fileExists(certFile) || !fileExists(keyFile) {
221
+ if !utils.FileExists(certFile) || !utils.FileExists(keyFile) {
222
return "", "", errors.New("relay certificate files do not exist")
223
}
224
return certFile, keyFile, nil
@@ -231,9 +234,9 @@ func (m *Manager) ensureManualCertificate() (string, string, error) {
234
return "", "", fmt.Errorf("manual certificate mode requires %s and %s in %s or configure ACME_DNS_PROVIDER", fullChainFileName, keyFileName, m.cfg.KeyDir)
235
}
236
234
- covered, err := m.manualCertificateCovered(certFile)
237
+ covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
238
if err != nil {
236
- return "", "", err
239
+ return "", "", fmt.Errorf("validate relay certificate: %w", err)
240
}
241
if !covered {
242
return "", "", fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
@@ -247,15 +250,15 @@ func (m *Manager) manualCertificateOverride() (string, string, bool, error) {
250
}
251
certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
252
keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
250
- if !fileExists(certFile) || !fileExists(keyFile) {
253
+ if !utils.FileExists(certFile) || !utils.FileExists(keyFile) {
254
return "", "", false, nil
255
}
256
var err error
254
- covered, err := m.manualCertificateCovered(certFile)
257
+ covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
258
if err != nil {
256
- return "", "", false, err
259
+ return "", "", false, fmt.Errorf("validate relay certificate: %w", err)
260
}
258
- hasACMEState := m.hasACMEState()
261
+ hasACMEState := utils.FileExists(filepath.Join(m.cfg.KeyDir, accountKeyFileName)) || utils.FileExists(filepath.Join(m.cfg.KeyDir, registrationFileName))
262
if !covered {
263
if !hasACMEState {
264
return "", "", false, fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
@@ -268,21 +271,6 @@ func (m *Manager) manualCertificateOverride() (string, string, bool, error) {
271
return certFile, keyFile, true, nil
272
}
273
271
-func (m *Manager) manualCertificateCovered(certFile string) (bool, error) {
272
- covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
273
- if err != nil {
274
- return false, fmt.Errorf("validate relay certificate: %w", err)
275
- }
276
- return covered, nil
277
-}
278
-
279
-func (m *Manager) hasACMEState() bool {
280
- if m == nil {
281
- return false
282
- }
283
- return fileExists(filepath.Join(m.cfg.KeyDir, accountKeyFileName)) || fileExists(filepath.Join(m.cfg.KeyDir, registrationFileName))
284
-}
285
-
274
func (m *Manager) provision(ctx context.Context) error {
275
keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
276
certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
@@ -291,7 +279,7 @@ func (m *Manager) provision(ctx context.Context) error {
279
domains := certificateDomains(m.cfg.BaseDomain)
280
281
for _, path := range []string{keyFile, certFile, accountKeyFile, registrationFile} {
294
- if err := ensureParentDir(path); err != nil {
282
+ if err := utils.EnsureParentDir(path); err != nil {
283
return err
284
}
285
}
@@ -315,10 +303,10 @@ func (m *Manager) provision(ctx context.Context) error {
303
return errors.New("acme obtain response missing certificate or private key")
304
}
305
318
- if err := writeFileAtomic(certFile, obtained.Certificate, 0o644); err != nil {
306
+ if err := utils.WriteFileAtomic(certFile, obtained.Certificate, 0o644); err != nil {
307
return fmt.Errorf("write certificate chain: %w", err)
308
}
321
- if err := writeFileAtomic(keyFile, obtained.PrivateKey, 0o600); err != nil {
309
+ if err := utils.WriteFileAtomic(keyFile, obtained.PrivateKey, 0o600); err != nil {
310
return fmt.Errorf("write private key: %w", err)
311
}
312
return nil
@@ -434,7 +422,7 @@ func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address
422
if hostname == "" {
423
return errors.New("hostname is required")
424
}
437
- if !hostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
425
+ if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
426
return fmt.Errorf("hostname %q is outside acme base domain %q", hostname, m.cfg.BaseDomain)
427
}
428
@@ -442,7 +430,12 @@ func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address
430
if err != nil {
431
return fmt.Errorf("normalize ens gasless address: %w", err)
432
}
445
- return m.dns.EnsureTXTRecord(ctx, hostname, gaslessENSTXTPrefix+defaultENSGaslessResolver+" "+strings.TrimSpace(address))
433
+ if err := m.dns.EnsureTXTRecord(ctx, hostname, gaslessENSTXTPrefix+defaultENSGaslessResolver+" "+strings.TrimSpace(address)); err != nil {
434
+ return err
435
+ }
436
+ return m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
437
+ return append(hostnames, hostname)
438
+ })
439
}
440
441
func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string) error {
@@ -457,19 +450,72 @@ func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string)
450
if hostname == "" {
451
return nil
452
}
460
- if !hostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
453
+ if !utils.HostnameMatchesBaseDomain(hostname, m.cfg.BaseDomain) {
454
return nil
455
}
463
- return m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix)
456
+ if hostname == m.cfg.BaseDomain {
457
+ return nil
458
+ }
459
+ if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
460
+ return err
461
+ }
462
+ return m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
463
+ filtered := hostnames[:0]
464
+ for _, tracked := range hostnames {
465
+ if tracked == hostname {
466
+ continue
467
+ }
468
+ filtered = append(filtered, tracked)
469
+ }
470
+ return filtered
471
+ })
472
}
473
466
-func hostnameMatchesBaseDomain(hostname, baseDomain string) bool {
467
- hostname = utils.NormalizeHostname(hostname)
468
- baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
469
- if hostname == "" || baseDomain == "" {
470
- return false
474
+func (m *Manager) reconcileTrackedENSGaslessHostnames(ctx context.Context) error {
475
+ if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) || m.dns == nil {
476
+ return nil
477
}
472
- return hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)
478
+
479
+ var cleanupErr error
480
+ if err := m.updateTrackedENSGaslessHostnames(func(hostnames []string) []string {
481
+ remaining := hostnames[:0]
482
+ for _, hostname := range hostnames {
483
+ if err := m.dns.DeleteTXTRecords(ctx, hostname, gaslessENSTXTPrefix); err != nil {
484
+ remaining = append(remaining, hostname)
485
+ cleanupErr = errors.Join(cleanupErr, fmt.Errorf("delete ens gasless txt for %s: %w", hostname, err))
486
+ }
487
+ }
488
+ return remaining
489
+ }); err != nil {
490
+ cleanupErr = errors.Join(cleanupErr, fmt.Errorf("persist ens gasless hostnames: %w", err))
491
+ }
492
+ return cleanupErr
493
+}
494
+
495
+func (m *Manager) updateTrackedENSGaslessHostnames(update func([]string) []string) error {
496
+ if m == nil {
497
+ return nil
498
+ }
499
+
500
+ m.trackedMu.Lock()
501
+ defer m.trackedMu.Unlock()
502
+
503
+ path := filepath.Join(m.cfg.KeyDir, ensGaslessHostnamesFileName)
504
+ var hostnames []string
505
+ if _, err := utils.ReadJSONFileIfExists(path, &hostnames); err != nil {
506
+ return err
507
+ }
508
+ hostnames = utils.NormalizeChildHostnames(hostnames, m.cfg.BaseDomain)
509
+ if update != nil {
510
+ hostnames = utils.NormalizeChildHostnames(update(hostnames), m.cfg.BaseDomain)
511
+ }
512
+ if len(hostnames) == 0 {
513
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
514
+ return err
515
+ }
516
+ return nil
517
+ }
518
+ return utils.WriteJSONFile(path, hostnames, 0o600)
519
}
520
521
func (m *Manager) shouldRenew() bool {
@@ -483,11 +529,7 @@ func certificateDomains(baseDomain string) []string {
529
}
530
531
func certNeedsRenewal(certFile string, domains []string) (bool, error) {
486
- certPEM, err := os.ReadFile(certFile)
487
- if err != nil {
488
- return false, err
489
- }
490
- cert, err := utils.ParseCertificatePEM(certPEM)
532
+ cert, err := loadCertificate(certFile)
533
if err != nil {
534
return false, err
535
}
@@ -498,15 +540,19 @@ func certNeedsRenewal(certFile string, domains []string) (bool, error) {
540
}
541
542
func certCoversDomains(certFile string, domains []string) (bool, error) {
501
- certPEM, err := os.ReadFile(certFile)
543
+ cert, err := loadCertificate(certFile)
544
if err != nil {
545
return false, err
546
}
505
- cert, err := utils.ParseCertificatePEM(certPEM)
547
+ return certificateCoversDomains(cert, domains), nil
548
+}
549
+
550
+func loadCertificate(certFile string) (*x509.Certificate, error) {
551
+ certPEM, err := os.ReadFile(certFile)
552
if err != nil {
507
- return false, err
553
+ return nil, err
554
}
509
- return certificateCoversDomains(cert, domains), nil
555
+ return utils.ParseCertificatePEM(certPEM)
556
}
557
558
func certificateCoversDomains(cert *x509.Certificate, domains []string) bool {
@@ -533,15 +579,19 @@ func newClient(ctx context.Context, email, accountKeyFile, registrationFile stri
579
if err != nil {
580
return nil, fmt.Errorf("load acme account key: %w", err)
581
}
536
- accountReg, err := loadRegistration(registrationFile)
537
- if err != nil {
582
+
583
+ var accountReg registration.Resource
584
+ accountRegPtr := (*registration.Resource)(nil)
585
+ if ok, err := utils.ReadJSONFileIfExists(registrationFile, &accountReg); err != nil {
586
return nil, fmt.Errorf("load acme registration: %w", err)
587
+ } else if ok {
588
+ accountRegPtr = &accountReg
589
}
590
591
user := &acmeUser{
592
Email: email,
593
Key: accountKey,
544
- Registration: accountReg,
594
+ Registration: accountRegPtr,
595
}
596
597
clientConfig := lego.NewConfig(user)
@@ -570,7 +620,7 @@ func newClient(ctx context.Context, email, accountKeyFile, registrationFile stri
620
return nil, fmt.Errorf("register acme account: %w", err)
621
}
622
user.Registration = reg
573
- if err := saveRegistration(registrationFile, reg); err != nil {
623
+ if err := utils.WriteJSONFile(registrationFile, reg, 0o600); err != nil {
624
return nil, fmt.Errorf("persist acme registration: %w", err)
625
}
626
}
@@ -585,7 +635,7 @@ func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.Key }
635
func loadOrCreateAccountKey(path string) (crypto.PrivateKey, error) {
636
keyPEM, err := os.ReadFile(path)
637
if err == nil {
588
- return parsePEMPrivateKey(keyPEM)
638
+ return utils.ParsePrivateKeyPEM(keyPEM)
639
}
640
if !errors.Is(err, os.ErrNotExist) {
641
return nil, err
@@ -600,101 +650,8 @@ func loadOrCreateAccountKey(path string) (crypto.PrivateKey, error) {
650
return nil, fmt.Errorf("marshal account key: %w", err)
651
}
652
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
603
- if err := writeFileAtomic(path, keyPEM, 0o600); err != nil {
653
+ if err := utils.WriteFileAtomic(path, keyPEM, 0o600); err != nil {
654
return nil, fmt.Errorf("persist account key: %w", err)
655
}
656
return key, nil
657
}
608
-
609
-func parsePEMPrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
610
- block, _ := pem.Decode(keyPEM)
611
- if block == nil {
612
- return nil, errors.New("invalid private key pem")
613
- }
614
- if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
615
- switch typed := key.(type) {
616
- case *ecdsa.PrivateKey:
617
- return typed, nil
618
- case *rsa.PrivateKey:
619
- return typed, nil
620
- }
621
- }
622
- if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
623
- return key, nil
624
- }
625
- if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
626
- return key, nil
627
- }
628
- return nil, errors.New("unsupported private key type")
629
-}
630
-
631
-func loadRegistration(path string) (*registration.Resource, error) {
632
- raw, err := os.ReadFile(path)
633
- if err != nil {
634
- if errors.Is(err, os.ErrNotExist) {
635
- return nil, nil
636
- }
637
- return nil, err
638
- }
639
- var reg registration.Resource
640
- if err := json.Unmarshal(raw, ®); err != nil {
641
- return nil, err
642
- }
643
- return ®, nil
644
-}
645
-
646
-func saveRegistration(path string, reg *registration.Resource) error {
647
- if reg == nil {
648
- return nil
649
- }
650
- raw, err := json.MarshalIndent(reg, "", " ")
651
- if err != nil {
652
- return err
653
- }
654
- return writeFileAtomic(path, raw, 0o600)
655
-}
656
-
657
-func ensureParentDir(path string) error {
658
- dir := filepath.Dir(path)
659
- if dir == "" || dir == "." {
660
- return nil
661
- }
662
- return os.MkdirAll(dir, 0o700)
663
-}
664
-
665
-func fileExists(path string) bool {
666
- if path == "" {
667
- return false
668
- }
669
- _, err := os.Stat(path)
670
- return err == nil
671
-}
672
-
673
-func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
674
- dir := filepath.Dir(path)
675
- if dir == "" {
676
- dir = "."
677
- }
678
- tmp, err := os.CreateTemp(dir, ".tmp-*")
679
- if err != nil {
680
- return err
681
- }
682
- tmpName := tmp.Name()
683
- defer func() { _ = os.Remove(tmpName) }()
684
-
685
- if _, err := tmp.Write(data); err != nil {
686
- _ = tmp.Close()
687
- return err
688
- }
689
- if err := tmp.Chmod(mode); err != nil {
690
- _ = tmp.Close()
691
- return err
692
- }
693
- if err := tmp.Close(); err != nil {
694
- return err
695
- }
696
- if err := os.Rename(tmpName, path); err != nil {
697
- return err
698
- }
699
- return os.Chmod(path, mode)
700
-}
portal/acme/cloudflare/provider.go
+2
-2
@@ -99,7 +99,7 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
99
if p == nil {
100
return errors.New("cloudflare provider is nil")
101
}
102
- baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
102
+ baseDomain = utils.NormalizeBaseDomain(baseDomain)
103
if baseDomain == "" {
104
return errors.New("base domain is required")
105
}
@@ -190,7 +190,7 @@ func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (types.D
190
if p == nil {
191
return types.DNSSECStatus{}, errors.New("cloudflare provider is nil")
192
}
193
- baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
193
+ baseDomain = utils.NormalizeBaseDomain(baseDomain)
194
if baseDomain == "" {
195
return types.DNSSECStatus{}, errors.New("base domain is required")
196
}
portal/acme/local.go
+6
-7
@@ -11,7 +11,6 @@ import (
11
"math/big"
12
"net"
13
"path/filepath"
14
- "strings"
14
"time"
15
16
"github.com/gosuda/portal/v2/utils"
@@ -24,17 +23,17 @@ func ensureLocalDevelopmentCertificate(keyDir, baseHost string) error {
23
keyFile := filepath.Join(keyDir, keyFileName)
24
certFile := filepath.Join(keyDir, fullChainFileName)
25
27
- if fileExists(keyFile) && fileExists(certFile) {
26
+ if utils.FileExists(keyFile) && utils.FileExists(certFile) {
27
covered, err := certCoversDomains(certFile, domains)
28
if err == nil && covered {
29
return nil
30
}
31
}
32
34
- if err := ensureParentDir(keyFile); err != nil {
33
+ if err := utils.EnsureParentDir(keyFile); err != nil {
34
return err
35
}
37
- if err := ensureParentDir(certFile); err != nil {
36
+ if err := utils.EnsureParentDir(certFile); err != nil {
37
return err
38
}
39
@@ -84,17 +83,17 @@ func ensureLocalDevelopmentCertificate(keyDir, baseHost string) error {
83
}
84
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
85
87
- if err := writeFileAtomic(certFile, certPEM, 0o644); err != nil {
86
+ if err := utils.WriteFileAtomic(certFile, certPEM, 0o644); err != nil {
87
return fmt.Errorf("write local dev certificate: %w", err)
88
}
90
- if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
89
+ if err := utils.WriteFileAtomic(keyFile, keyPEM, 0o600); err != nil {
90
return fmt.Errorf("write local dev private key: %w", err)
91
}
92
return nil
93
}
94
95
func localDevelopmentDomains(baseHost string) []string {
97
- baseHost = strings.TrimPrefix(utils.NormalizeHostname(baseHost), "*.")
96
+ baseHost = utils.NormalizeBaseDomain(baseHost)
97
domains := []string{"localhost", "*.localhost", "127.0.0.1", "::1"}
98
if baseHost != "" && baseHost != "localhost" {
99
domains = append(domains, baseHost)
portal/acme/route53/provider.go
+2
-2
@@ -84,7 +84,7 @@ func (p *Provider) EnsureARecords(ctx context.Context, baseDomain, publicIPv4 st
84
if p == nil {
85
return errors.New("route53 provider is nil")
86
}
87
- baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
87
+ baseDomain = utils.NormalizeBaseDomain(baseDomain)
88
if baseDomain == "" {
89
return errors.New("base domain is required")
90
}
@@ -170,7 +170,7 @@ func (p *Provider) EnsureDNSSEC(ctx context.Context, baseDomain string) (types.D
170
if p == nil {
171
return types.DNSSECStatus{}, errors.New("route53 provider is nil")
172
}
173
- baseDomain = strings.TrimPrefix(utils.NormalizeHostname(baseDomain), "*.")
173
+ baseDomain = utils.NormalizeBaseDomain(baseDomain)
174
if baseDomain == "" {
175
return types.DNSSECStatus{}, errors.New("base domain is required")
176
}
portal/auth/auth.go
+3
-27
@@ -2,7 +2,6 @@ package auth
2
3
import (
4
"crypto/sha256"
5
- "encoding/hex"
5
"errors"
6
"fmt"
7
"strings"
@@ -193,7 +192,7 @@ func VerifyRegisterChallengeMessage(messageText, signature, domain, nonce string
192
}
193
194
func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.Identity, ttl time.Duration) (string, LeaseAccessTokenClaims, error) {
196
- privateKeyBytes, err := decodePrivateKeyHex(privateKeyHex)
195
+ privateKey, _, err := utils.ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
196
if err != nil {
197
return "", LeaseAccessTokenClaims{}, err
198
}
@@ -206,7 +205,7 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.I
205
Algorithm: leaseTokenAlgorithm,
206
Key: &es256kOpaqueSigner{
207
keyID: strings.TrimSpace(keyID),
209
- privateKey: secp256k1.PrivKeyFromBytes(privateKeyBytes),
208
+ privateKey: privateKey,
209
},
210
}, (&jose.SignerOptions{}).WithType("JWT"))
211
if err != nil {
@@ -236,17 +235,7 @@ func IssueLeaseAccessToken(privateKeyHex, keyID, issuer string, identity types.I
235
}
236
237
func VerifyLeaseAccessToken(token, publicKeyHex, issuer string, identity types.Identity, now time.Time) (LeaseAccessTokenClaims, error) {
239
- pubKeyText := strings.TrimSpace(publicKeyHex)
240
- if pubKeyText == "" {
241
- return LeaseAccessTokenClaims{}, errors.New("public key is required")
242
- }
243
- pubKeyText = utils.TrimHexPrefix(pubKeyText)
244
-
245
- pubKeyBytes, err := hex.DecodeString(pubKeyText)
246
- if err != nil {
247
- return LeaseAccessTokenClaims{}, err
248
- }
249
- publicKey, err := secp256k1.ParsePubKey(pubKeyBytes)
238
+ publicKey, err := utils.ParseSecp256k1PublicKeyHex(publicKeyHex)
239
if err != nil {
240
return LeaseAccessTokenClaims{}, err
241
}
@@ -286,16 +275,3 @@ func VerifyLeaseAccessToken(token, publicKeyHex, issuer string, identity types.I
275
}
276
return claims, nil
277
}
289
-
290
-func decodePrivateKeyHex(privateKeyHex string) ([]byte, error) {
291
- trimmed := strings.TrimSpace(privateKeyHex)
292
- trimmed = utils.TrimHexPrefix(trimmed)
293
- decoded, err := hex.DecodeString(trimmed)
294
- if err != nil {
295
- return nil, err
296
- }
297
- if len(decoded) != secp256k1.PrivKeyBytesLen {
298
- return nil, errors.New("secp256k1 private key must be 32 bytes")
299
- }
300
- return decoded, nil
301
-}
portal/keyless/client.go
+1
-30
@@ -3,7 +3,6 @@ package keyless
3
import (
4
"context"
5
"crypto/tls"
6
- "crypto/x509"
6
"encoding/pem"
7
"errors"
8
"fmt"
@@ -86,41 +85,13 @@ func ResolveMaterials(ctx context.Context, endpoint, serverName string) ([]byte,
85
}
86
87
func VerifyCertificateHostname(certPEM []byte, hostname string) error {
89
- _, leaf, err := ParseCertificateChainPEM(certPEM)
88
+ leaf, err := utils.ParseCertificatePEM(certPEM)
89
if err != nil {
90
return err
91
}
92
return leaf.VerifyHostname(hostname)
93
}
94
96
-func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
97
- if len(certPEM) == 0 {
98
- return nil, nil, errors.New("certificate PEM is empty")
99
- }
100
-
101
- var chain [][]byte
102
- rest := certPEM
103
- for {
104
- block, next := pem.Decode(rest)
105
- if block == nil {
106
- break
107
- }
108
- if block.Type == "CERTIFICATE" {
109
- chain = append(chain, block.Bytes)
110
- }
111
- rest = next
112
- }
113
- if len(chain) == 0 {
114
- return nil, nil, errors.New("no certificate blocks found")
115
- }
116
-
117
- leaf, err := x509.ParseCertificate(chain[0])
118
- if err != nil {
119
- return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
120
- }
121
- return chain, leaf, nil
122
-}
123
-
95
func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName string) ([]byte, error) {
96
raw := strings.TrimSpace(endpoint)
97
if raw == "" {
portal/server.go
+11
@@ -284,6 +284,17 @@ func (s *Server) Shutdown(ctx context.Context) error {
284
285
for _, lease := range s.registry.CloseAll() {
286
if lease != nil {
287
+ if s.acmeManager != nil {
288
+ deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
289
+ if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname); err != nil {
290
+ log.Warn().
291
+ Err(err).
292
+ Str("hostname", lease.Hostname).
293
+ Str("address", lease.Address).
294
+ Msg("delete lease ens gasless txt during shutdown")
295
+ }
296
+ cancel()
297
+ }
298
lease.Close()
299
}
300
}
utils/crypto.go
+37
-38
@@ -70,20 +70,9 @@ func NormalizeEVMAddress(raw string) (string, error) {
70
}
71
72
func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
73
- publicKeyHex := strings.TrimSpace(rawPublicKey)
74
- if publicKeyHex == "" {
75
- return "", errors.New("public key is required")
76
- }
77
- publicKeyHex = TrimHexPrefix(publicKeyHex)
78
-
79
- decoded, err := hex.DecodeString(publicKeyHex)
80
- if err != nil {
81
- return "", errors.New("public key must be hex encoded")
82
- }
83
-
84
- publicKey, err := secp256k1.ParsePubKey(decoded)
73
+ publicKey, err := ParseSecp256k1PublicKeyHex(rawPublicKey)
74
if err != nil {
86
- return "", errors.New("invalid secp256k1 public key")
75
+ return "", err
76
}
77
78
uncompressed := publicKey.SerializeUncompressed()
@@ -99,12 +88,11 @@ func AddressFromCompressedPublicKeyHex(rawPublicKey string) (string, error) {
88
}
89
90
func SignEthereumPersonalMessage(message, privateKeyHex string) (string, error) {
102
- decoded, _, err := decodeSecp256k1PrivateKeyHex(privateKeyHex, false)
91
+ privateKey, _, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
92
if err != nil {
93
return "", err
94
}
95
107
- privateKey := secp256k1.PrivKeyFromBytes(decoded)
96
data := []byte(message)
97
prefix := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(data)))
98
hasher := sha3.NewLegacyKeccak256()
@@ -134,16 +122,11 @@ func ResolveSecp256k1Identity(rawPrivateKey string) (types.Identity, error) {
122
privateKeyHex = hex.EncodeToString(privateKey.Serialize())
123
}
124
137
- decoded, normalizedKeyHex, err := decodeSecp256k1PrivateKeyHex(privateKeyHex, true)
125
+ privateKey, normalizedKeyHex, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, true)
126
if err != nil {
127
return types.Identity{}, err
128
}
129
142
- privateKey := secp256k1.PrivKeyFromBytes(decoded)
143
- if privateKey == nil {
144
- return types.Identity{}, errors.New("invalid secp256k1 private key")
145
- }
146
-
130
publicKeyHex := hex.EncodeToString(privateKey.PubKey().SerializeCompressed())
131
address, err := AddressFromCompressedPublicKeyHex(publicKeyHex)
132
if err != nil {
@@ -158,31 +141,20 @@ func ResolveSecp256k1Identity(rawPrivateKey string) (types.Identity, error) {
141
}
142
143
func SignSHA256Secp256k1DER(payload []byte, privateKeyHex string) (string, error) {
161
- decoded, _, err := decodeSecp256k1PrivateKeyHex(privateKeyHex, false)
144
+ privateKey, _, err := ParseSecp256k1PrivateKeyHex(privateKeyHex, false)
145
if err != nil {
146
return "", err
147
}
148
149
hash := sha256.Sum256(payload)
167
- privateKey := secp256k1.PrivKeyFromBytes(decoded)
150
signature := secp256k1ecdsa.Sign(privateKey, hash[:])
151
return hex.EncodeToString(signature.Serialize()), nil
152
}
153
154
func VerifySHA256Secp256k1DER(payload []byte, publicKeyHex, signatureHex string) error {
173
- pubKeyText := strings.TrimSpace(publicKeyHex)
174
- if pubKeyText == "" {
175
- return errors.New("public key is required")
176
- }
177
- pubKeyText = TrimHexPrefix(pubKeyText)
178
-
179
- pubKeyBytes, err := hex.DecodeString(pubKeyText)
180
- if err != nil {
181
- return errors.New("public key must be hex encoded")
182
- }
183
- pubKey, err := secp256k1.ParsePubKey(pubKeyBytes)
155
+ pubKey, err := ParseSecp256k1PublicKeyHex(publicKeyHex)
156
if err != nil {
185
- return errors.New("invalid secp256k1 public key")
157
+ return err
158
}
159
160
sigText := strings.TrimSpace(signatureHex)
@@ -207,7 +179,26 @@ func VerifySHA256Secp256k1DER(payload []byte, publicKeyHex, signatureHex string)
179
return nil
180
}
181
210
-func decodeSecp256k1PrivateKeyHex(raw string, requireNonZero bool) ([]byte, string, error) {
182
+func ParseSecp256k1PublicKeyHex(raw string) (*secp256k1.PublicKey, error) {
183
+ publicKeyHex := strings.TrimSpace(raw)
184
+ if publicKeyHex == "" {
185
+ return nil, errors.New("public key is required")
186
+ }
187
+ publicKeyHex = TrimHexPrefix(publicKeyHex)
188
+
189
+ decoded, err := hex.DecodeString(publicKeyHex)
190
+ if err != nil {
191
+ return nil, errors.New("public key must be hex encoded")
192
+ }
193
+
194
+ publicKey, err := secp256k1.ParsePubKey(decoded)
195
+ if err != nil {
196
+ return nil, errors.New("invalid secp256k1 public key")
197
+ }
198
+ return publicKey, nil
199
+}
200
+
201
+func ParseSecp256k1PrivateKeyHex(raw string, requireNonZero bool) (*secp256k1.PrivateKey, string, error) {
202
privateKeyHex := strings.TrimSpace(raw)
203
if privateKeyHex == "" {
204
return nil, "", errors.New("private key is required")
@@ -222,7 +213,11 @@ func decodeSecp256k1PrivateKeyHex(raw string, requireNonZero bool) ([]byte, stri
213
return nil, "", fmt.Errorf("secp256k1 private key must be %d bytes", secp256k1.PrivKeyBytesLen)
214
}
215
if !requireNonZero {
225
- return decoded, privateKeyHex, nil
216
+ key := secp256k1.PrivKeyFromBytes(decoded)
217
+ if key == nil {
218
+ return nil, "", errors.New("invalid secp256k1 private key")
219
+ }
220
+ return key, privateKeyHex, nil
221
}
222
223
isZero := true
@@ -235,7 +230,11 @@ func decodeSecp256k1PrivateKeyHex(raw string, requireNonZero bool) ([]byte, stri
230
if isZero {
231
return nil, "", errors.New("secp256k1 private key must not be zero")
232
}
238
- return decoded, privateKeyHex, nil
233
+ key := secp256k1.PrivKeyFromBytes(decoded)
234
+ if key == nil {
235
+ return nil, "", errors.New("invalid secp256k1 private key")
236
+ }
237
+ return key, privateKeyHex, nil
238
}
239
240
func NormalizeWireGuardPrivateKey(raw string) (string, error) {
utils/file.go
new
+86
@@ -0,0 +1,86 @@
1
+package utils
2
+
3
+import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+)
9
+
10
+func EnsureParentDir(path string) error {
11
+ dir := filepath.Dir(strings.TrimSpace(path))
12
+ if dir == "" || dir == "." {
13
+ return nil
14
+ }
15
+ return os.MkdirAll(dir, 0o700)
16
+}
17
+
18
+func FileExists(path string) bool {
19
+ path = strings.TrimSpace(path)
20
+ if path == "" {
21
+ return false
22
+ }
23
+ _, err := os.Stat(path)
24
+ return err == nil
25
+}
26
+
27
+func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
28
+ path = strings.TrimSpace(path)
29
+ dir := filepath.Dir(path)
30
+ if dir == "" {
31
+ dir = "."
32
+ }
33
+ tmp, err := os.CreateTemp(dir, ".tmp-*")
34
+ if err != nil {
35
+ return err
36
+ }
37
+ tmpName := tmp.Name()
38
+ defer func() { _ = os.Remove(tmpName) }()
39
+
40
+ if _, err := tmp.Write(data); err != nil {
41
+ _ = tmp.Close()
42
+ return err
43
+ }
44
+ if err := tmp.Chmod(mode); err != nil {
45
+ _ = tmp.Close()
46
+ return err
47
+ }
48
+ if err := tmp.Close(); err != nil {
49
+ return err
50
+ }
51
+ if err := os.Rename(tmpName, path); err != nil {
52
+ return err
53
+ }
54
+ return os.Chmod(path, mode)
55
+}
56
+
57
+func ReadJSONFile(path string, out any) error {
58
+ raw, err := os.ReadFile(strings.TrimSpace(path))
59
+ if err != nil {
60
+ return err
61
+ }
62
+ return json.Unmarshal(raw, out)
63
+}
64
+
65
+func ReadJSONFileIfExists(path string, out any) (bool, error) {
66
+ err := ReadJSONFile(path, out)
67
+ switch {
68
+ case err == nil:
69
+ return true, nil
70
+ case os.IsNotExist(err):
71
+ return false, nil
72
+ default:
73
+ return false, err
74
+ }
75
+}
76
+
77
+func WriteJSONFile(path string, payload any, mode os.FileMode) error {
78
+ data, err := json.MarshalIndent(payload, "", " ")
79
+ if err != nil {
80
+ return err
81
+ }
82
+ if err := EnsureParentDir(path); err != nil {
83
+ return err
84
+ }
85
+ return WriteFileAtomic(path, data, mode)
86
+}
utils/identity.go
+5
-41
@@ -1,11 +1,9 @@
1
package utils
2
3
import (
4
- "encoding/json"
4
"errors"
5
"fmt"
6
"os"
8
- "path/filepath"
7
"strings"
8
9
"github.com/gosuda/portal/v2/types"
@@ -104,22 +102,12 @@ func SaveIdentity(path string, identity types.Identity) error {
102
if err != nil {
103
return err
104
}
107
- data, err := json.MarshalIndent(storedIdentity{
105
+ if err := WriteJSONFile(path, storedIdentity{
106
Name: normalized.Name,
107
Address: normalized.Address,
108
PublicKey: normalized.PublicKey,
109
PrivateKey: normalized.PrivateKey,
112
- }, "", " ")
113
- if err != nil {
114
- return err
115
- }
116
- dir := filepath.Dir(path)
117
- if dir != "" && dir != "." {
118
- if err := os.MkdirAll(dir, 0o700); err != nil {
119
- return fmt.Errorf("create identity directory: %w", err)
120
- }
121
- }
122
- if err := os.WriteFile(path, data, 0o600); err != nil {
110
+ }, 0o600); err != nil {
111
return fmt.Errorf("write identity file: %w", err)
112
}
113
return nil
@@ -130,13 +118,9 @@ func LoadIdentity(path string) (types.Identity, error) {
118
if path == "" {
119
return types.Identity{}, errors.New("identity path is required")
120
}
133
- data, err := os.ReadFile(path)
134
- if err != nil {
135
- return types.Identity{}, fmt.Errorf("read identity file: %w", err)
136
- }
121
var payload storedIdentity
138
- if err := json.Unmarshal(data, &payload); err != nil {
139
- return types.Identity{}, err
122
+ if err := ReadJSONFile(path, &payload); err != nil {
123
+ return types.Identity{}, fmt.Errorf("read identity file: %w", err)
124
}
125
return NormalizeStoredIdentity(types.Identity{
126
Name: payload.Name,
@@ -217,27 +201,7 @@ func NormalizeIdentityKey(raw string) string {
201
}
202
203
func NormalizeIdentityKeys(inputs []string) []string {
220
- if len(inputs) == 0 {
221
- return nil
222
- }
223
-
224
- seen := make(map[string]struct{}, len(inputs))
225
- out := make([]string, 0, len(inputs))
226
- for _, input := range inputs {
227
- key := NormalizeIdentityKey(input)
228
- if key == "" {
229
- continue
230
- }
231
- if _, ok := seen[key]; ok {
232
- continue
233
- }
234
- seen[key] = struct{}{}
235
- out = append(out, key)
236
- }
237
- if len(out) == 0 {
238
- return nil
239
- }
240
- return out
204
+ return normalizeUniqueStrings(inputs, NormalizeIdentityKey)
205
}
206
207
func NormalizeIdentityKeyBPS(inputs map[string]int64) map[string]int64 {
utils/utils.go
+75
-33
@@ -2,7 +2,10 @@ package utils
2
3
import (
4
"context"
5
+ "crypto"
6
+ "crypto/ecdsa"
7
"crypto/rand"
8
+ "crypto/rsa"
9
"crypto/x509"
10
"encoding/base64"
11
"encoding/hex"
@@ -184,6 +187,34 @@ func NormalizeHostname(host string) string {
187
return host
188
}
189
190
+func NormalizeBaseDomain(domain string) string {
191
+ return strings.TrimPrefix(NormalizeHostname(domain), "*.")
192
+}
193
+
194
+func HostnameMatchesBaseDomain(hostname, baseDomain string) bool {
195
+ hostname = NormalizeHostname(hostname)
196
+ baseDomain = NormalizeBaseDomain(baseDomain)
197
+ if hostname == "" || baseDomain == "" {
198
+ return false
199
+ }
200
+ return hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)
201
+}
202
+
203
+func NormalizeChildHostnames(inputs []string, baseDomain string) []string {
204
+ if len(inputs) == 0 {
205
+ return nil
206
+ }
207
+
208
+ baseDomain = NormalizeBaseDomain(baseDomain)
209
+ return normalizeUniqueStrings(inputs, func(input string) string {
210
+ hostname := NormalizeHostname(input)
211
+ if hostname == "" || hostname == baseDomain || !HostnameMatchesBaseDomain(hostname, baseDomain) {
212
+ return ""
213
+ }
214
+ return hostname
215
+ })
216
+}
217
+
218
// NormalizeURLPath canonicalizes URL paths to a rooted, slash-trimmed form.
219
func NormalizeURLPath(raw string) string {
220
clean := path.Clean(strings.TrimSpace(raw))
@@ -216,7 +247,7 @@ func NormalizeRelayURLs(inputs ...string) ([]string, error) {
247
}
248
}
249
219
- return uniqueURLs(out), nil
250
+ return normalizeUniqueStrings(out, strings.TrimSpace), nil
251
}
252
253
func FilterRelayURLs(inputs, excluded []string) []string {
@@ -334,30 +365,6 @@ func ExcludeLocalRelayURLs(inputs ...string) ([]string, error) {
365
return filtered, nil
366
}
367
337
-func uniqueURLs(inputs []string) []string {
338
- if len(inputs) == 0 {
339
- return nil
340
- }
341
-
342
- out := make([]string, 0, len(inputs))
343
- seen := make(map[string]struct{}, len(inputs))
344
- for _, input := range inputs {
345
- input = strings.TrimSpace(input)
346
- if input == "" {
347
- continue
348
- }
349
- if _, ok := seen[input]; ok {
350
- continue
351
- }
352
- seen[input] = struct{}{}
353
- out = append(out, input)
354
- }
355
- if len(out) == 0 {
356
- return nil
357
- }
358
- return out
359
-}
360
-
368
func LeaseHostname(name, rootHost string) (string, error) {
369
label, err := NormalizeDNSLabel(name)
370
if err != nil {
@@ -526,6 +533,28 @@ func ParseCertificatePEM(pemData []byte) (*x509.Certificate, error) {
533
return x509.ParseCertificate(block.Bytes)
534
}
535
536
+func ParsePrivateKeyPEM(keyPEM []byte) (crypto.PrivateKey, error) {
537
+ block, _ := pem.Decode(keyPEM)
538
+ if block == nil {
539
+ return nil, errors.New("invalid private key pem")
540
+ }
541
+ if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
542
+ switch typed := key.(type) {
543
+ case *ecdsa.PrivateKey:
544
+ return typed, nil
545
+ case *rsa.PrivateKey:
546
+ return typed, nil
547
+ }
548
+ }
549
+ if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
550
+ return key, nil
551
+ }
552
+ if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
553
+ return key, nil
554
+ }
555
+ return nil, errors.New("unsupported private key type")
556
+}
557
+
558
func SleepOrDone(ctx context.Context, d time.Duration) bool {
559
timer := time.NewTimer(d)
560
defer timer.Stop()
@@ -547,26 +576,39 @@ func RandomID(prefix string) string {
576
}
577
578
func NormalizeIPPrefixes(inputs []string) []string {
550
- if len(inputs) == 0 {
551
- return nil
552
- }
553
- seen := make(map[string]struct{}, len(inputs))
554
- out := make([]string, 0, len(inputs))
555
- for _, input := range inputs {
579
+ return normalizeUniqueStrings(inputs, func(input string) string {
580
input = strings.TrimSpace(input)
581
if input == "" {
558
- continue
582
+ return ""
583
}
584
prefix, err := netip.ParsePrefix(input)
585
if err != nil {
586
+ return ""
587
+ }
588
+ return prefix.String()
589
+ })
590
+}
591
+
592
+func normalizeUniqueStrings(inputs []string, normalize func(string) string) []string {
593
+ if len(inputs) == 0 {
594
+ return nil
595
+ }
596
+
597
+ out := make([]string, 0, len(inputs))
598
+ seen := make(map[string]struct{}, len(inputs))
599
+ for _, input := range inputs {
600
+ normalized := normalize(input)
601
+ if normalized == "" {
602
continue
603
}
564
- normalized := prefix.String()
604
if _, ok := seen[normalized]; ok {
605
continue
606
}
607
seen[normalized] = struct{}{}
608
out = append(out, normalized)
609
}
610
+ if len(out) == 0 {
611
+ return nil
612
+ }
613
return out
614
}