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, &reg); err != nil {
641 - return nil, err
642 - }
643 - return &reg, 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 }