feat: Refactor ECH DNS handling and introduce leaseRecord structure for improved management

Kim committed May 7, 2026 at 17:10 UTC 0ca35a80251ea2b592da3651f6b470e65577dc99
9 files changed +292 -403
portal/acme/acme.go
+4 -4
@@ -513,10 +513,6 @@ func (m *Manager) DeleteECHConfig(ctx context.Context, hostname string) error {
513 return nil
514 }
515
516 - m.echMu.Lock()
517 - delete(m.echRecords, hostname)
518 - m.echMu.Unlock()
519 -
516 if err := m.dns.DeleteHTTPSRecord(ctx, hostname); err != nil {
517 return err
518 }
@@ -525,6 +521,10 @@ func (m *Manager) DeleteECHConfig(ctx context.Context, hostname string) error {
521 return fmt.Errorf("delete ECH A record for %s: %w", hostname, err)
522 }
523 }
524 +
525 + m.echMu.Lock()
526 + delete(m.echRecords, hostname)
527 + m.echMu.Unlock()
528 return nil
529 }
530
portal/api_server.go
+33 -154
@@ -1,6 +1,7 @@
1 package portal
2
3 import (
4 + "context"
5 "crypto/tls"
6 "errors"
7 "fmt"
@@ -282,62 +283,20 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
283 writeAPIErrorResponse(w, err)
284 return
285 }
285 - if manager := s.acmeManager; manager != nil {
286 - if record.hasENSGaslessDNSRecord() {
287 - err := manager.SyncENSGaslessHostname(r.Context(), record.Hostname, record.Address)
288 - if err != nil {
289 - removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
290 - if removed == nil {
291 - record.Close()
292 - removed = record
293 - }
294 - if removed.hasENSGaslessDNSRecord() {
295 - deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
296 - if deleteErr != nil {
297 - log.Warn().
298 - Err(deleteErr).
299 - Str("hostname", removed.Hostname).
300 - Str("address", removed.Address).
301 - Msg("delete lease ens gasless hostname after sync failure")
302 - }
303 - }
304 - writeAPIErrorResponse(w, err)
305 - return
306 - }
307 - }
308 - if record.hasECHDNSRecord() {
309 - err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
310 - if err != nil {
311 - removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
312 - if removed == nil {
313 - record.Close()
314 - removed = record
315 - }
316 - if removed.hasENSGaslessDNSRecord() {
317 - deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
318 - if deleteErr != nil {
319 - log.Warn().
320 - Err(deleteErr).
321 - Str("hostname", removed.Hostname).
322 - Str("address", removed.Address).
323 - Msg("delete lease ens gasless hostname after ech sync failure")
324 - }
325 - }
326 - if removed.hasECHDNSRecord() {
327 - deleteErr := manager.DeleteECHConfig(r.Context(), removed.ECHDNSHostname)
328 - if deleteErr != nil {
329 - log.Warn().
330 - Err(deleteErr).
331 - Str("hostname", removed.ECHDNSHostname).
332 - Str("route_hostname", removed.Hostname).
333 - Str("address", removed.Address).
334 - Msg("delete lease ech dns record after sync failure")
335 - }
336 - }
337 - writeAPIErrorResponse(w, err)
338 - return
339 - }
286 + dnsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
287 + deleteECHOnRollback, err := record.syncDNS(dnsCtx, s.acmeManager, s.cfg.SNIPort)
288 + cancel()
289 + if err != nil {
290 + removed, _ := s.registry.Unregister(types.UnregisterRequest{AccessToken: resp.AccessToken})
291 + if removed == nil {
292 + record.Close()
293 + removed = record
294 }
295 + cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
296 + removed.deleteDNS(cleanupCtx, s.acmeManager, deleteECHOnRollback)
297 + cleanupCancel()
298 + writeAPIErrorResponse(w, err)
299 + return
300 }
301
302 utils.WriteAPIData(w, http.StatusCreated, resp)
@@ -432,29 +391,9 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
391 writeAPIErrorResponse(w, err)
392 return
393 }
435 - if manager := s.acmeManager; manager != nil {
436 - if record.hasENSGaslessDNSRecord() {
437 - err := manager.DeleteENSGaslessHostname(r.Context(), record.Hostname)
438 - if err != nil {
439 - log.Warn().
440 - Err(err).
441 - Str("hostname", record.Hostname).
442 - Str("address", record.Address).
443 - Msg("delete lease ens gasless hostname")
444 - }
445 - }
446 - if record.hasECHDNSRecord() {
447 - err := manager.DeleteECHConfig(r.Context(), record.ECHDNSHostname)
448 - if err != nil {
449 - log.Warn().
450 - Err(err).
451 - Str("hostname", record.ECHDNSHostname).
452 - Str("route_hostname", record.Hostname).
453 - Str("address", record.Address).
454 - Msg("delete lease ech dns record")
455 - }
456 - }
457 - }
394 + dnsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
395 + record.deleteDNS(dnsCtx, s.acmeManager, true)
396 + cancel()
397
398 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
399 }
@@ -493,29 +432,9 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
432 }
433 if r.Method == http.MethodDelete {
434 record := s.registry.DeleteHopRoute(&route)
496 - if manager := s.acmeManager; manager != nil {
497 - if record.hasENSGaslessDNSRecord() {
498 - err := manager.DeleteENSGaslessHostname(r.Context(), record.Hostname)
499 - if err != nil {
500 - log.Warn().
501 - Err(err).
502 - Str("hostname", record.Hostname).
503 - Str("address", record.Address).
504 - Msg("delete hop route ens gasless hostname")
505 - }
506 - }
507 - if record.hasECHDNSRecord() {
508 - err := manager.DeleteECHConfig(r.Context(), record.ECHDNSHostname)
509 - if err != nil {
510 - log.Warn().
511 - Err(err).
512 - Str("hostname", record.ECHDNSHostname).
513 - Str("route_hostname", record.Hostname).
514 - Str("address", record.Address).
515 - Msg("delete hop route ech dns record")
516 - }
517 - }
518 - }
435 + dnsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
436 + record.deleteDNS(dnsCtx, s.acmeManager, true)
437 + cancel()
438 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
439 return
440 }
@@ -548,60 +467,19 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
467 writeAPIErrorResponse(w, err)
468 return
469 }
551 - if manager := s.acmeManager; manager != nil {
552 - if record.hasENSGaslessDNSRecord() {
553 - err := manager.SyncENSGaslessHostname(r.Context(), record.Hostname, record.Address)
554 - if err != nil {
555 - removed := s.registry.DeleteHopRoute(&route)
556 - if removed == nil {
557 - removed = record
558 - }
559 - if removed.hasENSGaslessDNSRecord() {
560 - deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
561 - if deleteErr != nil {
562 - log.Warn().
563 - Err(deleteErr).
564 - Str("hostname", removed.Hostname).
565 - Str("address", removed.Address).
566 - Msg("delete hop route ens gasless hostname after sync failure")
567 - }
568 - }
569 - writeAPIErrorResponse(w, err)
570 - return
571 - }
572 - }
573 - if record.hasECHDNSRecord() {
574 - err := manager.SyncECHConfig(r.Context(), record.ECHDNSHostname, record.ECHConfigList, s.cfg.SNIPort)
575 - if err != nil {
576 - removed := s.registry.DeleteHopRoute(&route)
577 - if removed == nil {
578 - removed = record
579 - }
580 - if removed.hasENSGaslessDNSRecord() {
581 - deleteErr := manager.DeleteENSGaslessHostname(r.Context(), removed.Hostname)
582 - if deleteErr != nil {
583 - log.Warn().
584 - Err(deleteErr).
585 - Str("hostname", removed.Hostname).
586 - Str("address", removed.Address).
587 - Msg("delete hop route ens gasless hostname after ech sync failure")
588 - }
589 - }
590 - if removed.hasECHDNSRecord() {
591 - deleteErr := manager.DeleteECHConfig(r.Context(), removed.ECHDNSHostname)
592 - if deleteErr != nil {
593 - log.Warn().
594 - Err(deleteErr).
595 - Str("hostname", removed.ECHDNSHostname).
596 - Str("route_hostname", removed.Hostname).
597 - Str("address", removed.Address).
598 - Msg("delete hop route ech dns record after sync failure")
599 - }
600 - }
601 - writeAPIErrorResponse(w, err)
602 - return
603 - }
470 + dnsCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
471 + deleteECHOnRollback, err := record.syncDNS(dnsCtx, s.acmeManager, s.cfg.SNIPort)
472 + cancel()
473 + if err != nil {
474 + removed := s.registry.DeleteHopRoute(&route)
475 + if removed == nil {
476 + removed = record
477 }
478 + cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(r.Context()), defaultClaimTimeout)
479 + removed.deleteDNS(cleanupCtx, s.acmeManager, deleteECHOnRollback)
480 + cleanupCancel()
481 + writeAPIErrorResponse(w, err)
482 + return
483 }
484 var accessToken string
485 if record.isPublicEntry() {
@@ -613,6 +491,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
491 }
492 utils.WriteAPIData(w, http.StatusOK, types.HopRouteResponse{
493 AccessToken: accessToken,
494 + SNIPort: s.cfg.SNIPort,
495 })
496 }
497
portal/lease.go
+12 -117
@@ -2,7 +2,6 @@ package portal
2
3 import (
4 "bytes"
5 - "context"
5 "errors"
6 "fmt"
7 "net"
@@ -389,18 +388,18 @@ func (r *leaseRegistry) admitLeaseByToken(token string, requireDatagram bool) (*
388 return nil, errUnauthorized
389 }
390 r.mu.RLock()
392 - lease := r.recordByKey(claims.Identity.Key(), now)
391 + record := r.recordByKey(claims.Identity.Key(), now)
392 r.mu.RUnlock()
394 - if lease == nil {
393 + if record == nil {
394 return nil, errLeaseNotFound
395 }
397 - if !r.policy.IsIdentityRoutable(lease.Key()) {
396 + if !r.policy.IsIdentityRoutable(record.Key()) {
397 return nil, errLeaseRejected
398 }
400 - if lease.stream == nil || (requireDatagram && lease.datagram == nil) {
399 + if record.stream == nil || (requireDatagram && record.datagram == nil) {
400 return nil, errTransportMismatch
401 }
403 - return lease, nil
402 + return record, nil
403 }
404
405 func (r *leaseRegistry) Renew(req types.RenewRequest, clientIP string) (types.RenewResponse, error) {
@@ -652,6 +651,13 @@ func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeReques
651 if r == nil {
652 return types.RegisterChallengeResponse{}, errFeatureUnavailable
653 }
654 + if len(req.ECHConfigList) > 0 {
655 + echConfigList, err := keyless.NormalizeEncryptedClientHelloConfigList(req.ECHConfigList)
656 + if err != nil {
657 + return types.RegisterChallengeResponse{}, err
658 + }
659 + req.ECHConfigList = echConfigList
660 + }
661
662 now := time.Now().UTC()
663 challenge, err := auth.NewRegisterChallenge(req, domain, uri, now, defaultRegisterChallengeTTL)
@@ -896,114 +902,3 @@ func (r *leaseRegistry) publicLease(record *leaseRecord) types.Lease {
902 }
903 return lease
904 }
899 -
900 -type leaseRecord struct {
901 - types.Identity
902 - ExpiresAt time.Time
903 - FirstSeenAt time.Time
904 - LastSeenAt time.Time
905 - ClientIP string
906 - ReportedIP string
907 - Hostname string
908 - HostnameHash string
909 - ECHConfigList []byte
910 - ECHDNSHostname string
911 - Metadata types.LeaseMetadata
912 -
913 - hopToken string
914 - hopNextOverlayIPv4 string
915 - hopNextToken string
916 - registerChallenge *auth.RegisterChallenge
917 -
918 - datagram *transport.RelayDatagram
919 - udpPorts *transport.PortAllocator
920 - tcpPort *transport.RelayTCPPort
921 - tcpPorts *transport.PortAllocator
922 - stream *transport.RelayStream
923 -}
924 -
925 -func (r *leaseRecord) isPublicEntry() bool {
926 - return r != nil && r.hopToken == "" && r.Hostname != ""
927 -}
928 -
929 -func (r *leaseRecord) hasENSGaslessDNSRecord() bool {
930 - return r.isPublicEntry() && r.HostnameHash == ""
931 -}
932 -
933 -func (r *leaseRecord) hasECHDNSRecord() bool {
934 - return r.isPublicEntry() && len(r.ECHConfigList) > 0 && r.ECHDNSHostname != ""
935 -}
936 -
937 -func (r *leaseRecord) isHopMiddle() bool {
938 - _, _, hasNextHop := r.nextHop()
939 - return r != nil && r.Hostname == "" && r.hopToken != "" && hasNextHop
940 -}
941 -
942 -func (r *leaseRecord) isHopExit() bool {
943 - _, _, hasNextHop := r.nextHop()
944 - return r != nil && r.hopToken != "" && !hasNextHop
945 -}
946 -
947 -func (r *leaseRecord) routesOverlap(other *leaseRecord) bool {
948 - if r == nil || other == nil {
949 - return false
950 - }
951 - if r.Hostname != "" && other.Hostname != "" && r.Hostname == other.Hostname {
952 - return true
953 - }
954 - if r.HostnameHash != "" && other.HostnameHash != "" && r.HostnameHash == other.HostnameHash {
955 - return true
956 - }
957 - if r.Hostname != "" && other.HostnameHash != "" && utils.HostnameHash(r.Hostname) == other.HostnameHash {
958 - return true
959 - }
960 - return other.Hostname != "" && r.HostnameHash != "" && utils.HostnameHash(other.Hostname) == r.HostnameHash
961 -}
962 -
963 -func (r *leaseRecord) nextHop() (string, string, bool) {
964 - if r == nil {
965 - return "", "", false
966 - }
967 - overlayIPv4 := r.hopNextOverlayIPv4
968 - forwardToken := r.hopNextToken
969 - return overlayIPv4, forwardToken, overlayIPv4 != "" || forwardToken != ""
970 -}
971 -
972 -func (r *leaseRecord) isExpired(now time.Time) bool {
973 - return r != nil && !now.IsZero() && !now.Before(r.ExpiresAt)
974 -}
975 -
976 -func (r *leaseRecord) Start() error {
977 - if r.datagram != nil {
978 - if err := r.datagram.Start(context.Background()); err != nil {
979 - return err
980 - }
981 - }
982 - if r.tcpPort != nil {
983 - return r.tcpPort.Start(context.Background())
984 - }
985 - return nil
986 -}
987 -
988 -func (r *leaseRecord) Close() {
989 - if r == nil {
990 - return
991 - }
992 - if r.stream != nil {
993 - r.stream.Close()
994 - }
995 - if r.datagram != nil {
996 - port := r.datagram.UDPPort()
997 - r.datagram.Close()
998 - if port > 0 && r.udpPorts != nil {
999 - r.udpPorts.Release(port)
1000 - }
1001 - }
1002 - if r.tcpPort != nil {
1003 - port := r.tcpPort.TCPPort()
1004 - r.tcpPort.Close()
1005 - if port > 0 && r.tcpPorts != nil {
1006 - r.tcpPorts.Release(port)
1007 - }
1008 - }
1009 -}
portal/record.go new
+177
@@ -0,0 +1,177 @@
1 +package portal
2 +
3 +import (
4 + "context"
5 + "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/portal/acme"
8 + "github.com/gosuda/portal-tunnel/v2/portal/auth"
9 + "github.com/gosuda/portal-tunnel/v2/portal/transport"
10 + "github.com/gosuda/portal-tunnel/v2/types"
11 + "github.com/gosuda/portal-tunnel/v2/utils"
12 + "github.com/rs/zerolog/log"
13 +)
14 +
15 +type leaseRecord struct {
16 + types.Identity
17 + ExpiresAt time.Time
18 + FirstSeenAt time.Time
19 + LastSeenAt time.Time
20 + ClientIP string
21 + ReportedIP string
22 + Hostname string
23 + HostnameHash string
24 + ECHConfigList []byte
25 + ECHDNSHostname string
26 + Metadata types.LeaseMetadata
27 +
28 + hopToken string
29 + hopNextOverlayIPv4 string
30 + hopNextToken string
31 + registerChallenge *auth.RegisterChallenge
32 +
33 + datagram *transport.RelayDatagram
34 + udpPorts *transport.PortAllocator
35 + tcpPort *transport.RelayTCPPort
36 + tcpPorts *transport.PortAllocator
37 + stream *transport.RelayStream
38 +}
39 +
40 +func (r *leaseRecord) isPublicEntry() bool {
41 + return r != nil && r.hopToken == "" && r.Hostname != ""
42 +}
43 +
44 +func (r *leaseRecord) ensGaslessDNSHostname() string {
45 + if !r.isPublicEntry() {
46 + return ""
47 + }
48 + if len(r.ECHConfigList) > 0 && r.ECHDNSHostname != "" {
49 + return r.ECHDNSHostname
50 + }
51 + if r.HostnameHash == "" {
52 + return r.Hostname
53 + }
54 + return ""
55 +}
56 +
57 +func (r *leaseRecord) hasECHDNSRecord() bool {
58 + return r.isPublicEntry() && len(r.ECHConfigList) > 0 && r.ECHDNSHostname != ""
59 +}
60 +
61 +func (r *leaseRecord) isHopMiddle() bool {
62 + _, _, hasNextHop := r.nextHop()
63 + return r != nil && r.Hostname == "" && r.hopToken != "" && hasNextHop
64 +}
65 +
66 +func (r *leaseRecord) isHopExit() bool {
67 + _, _, hasNextHop := r.nextHop()
68 + return r != nil && r.hopToken != "" && !hasNextHop
69 +}
70 +
71 +func (r *leaseRecord) routesOverlap(other *leaseRecord) bool {
72 + if r == nil || other == nil {
73 + return false
74 + }
75 + if r.Hostname != "" && other.Hostname != "" && r.Hostname == other.Hostname {
76 + return true
77 + }
78 + if r.HostnameHash != "" && other.HostnameHash != "" && r.HostnameHash == other.HostnameHash {
79 + return true
80 + }
81 + if r.Hostname != "" && other.HostnameHash != "" && utils.HostnameHash(r.Hostname) == other.HostnameHash {
82 + return true
83 + }
84 + return other.Hostname != "" && r.HostnameHash != "" && utils.HostnameHash(other.Hostname) == r.HostnameHash
85 +}
86 +
87 +func (r *leaseRecord) nextHop() (string, string, bool) {
88 + if r == nil {
89 + return "", "", false
90 + }
91 + overlayIPv4 := r.hopNextOverlayIPv4
92 + forwardToken := r.hopNextToken
93 + return overlayIPv4, forwardToken, overlayIPv4 != "" || forwardToken != ""
94 +}
95 +
96 +func (r *leaseRecord) isExpired(now time.Time) bool {
97 + return r != nil && !now.IsZero() && !now.Before(r.ExpiresAt)
98 +}
99 +
100 +func (r *leaseRecord) Start() error {
101 + if r.datagram != nil {
102 + if err := r.datagram.Start(context.Background()); err != nil {
103 + return err
104 + }
105 + }
106 + if r.tcpPort != nil {
107 + return r.tcpPort.Start(context.Background())
108 + }
109 + return nil
110 +}
111 +
112 +func (r *leaseRecord) Close() {
113 + if r == nil {
114 + return
115 + }
116 + if r.stream != nil {
117 + r.stream.Close()
118 + }
119 + if r.datagram != nil {
120 + port := r.datagram.UDPPort()
121 + r.datagram.Close()
122 + if port > 0 && r.udpPorts != nil {
123 + r.udpPorts.Release(port)
124 + }
125 + }
126 + if r.tcpPort != nil {
127 + port := r.tcpPort.TCPPort()
128 + r.tcpPort.Close()
129 + if port > 0 && r.tcpPorts != nil {
130 + r.tcpPorts.Release(port)
131 + }
132 + }
133 +}
134 +
135 +func (r *leaseRecord) syncDNS(ctx context.Context, manager *acme.Manager, sniPort int) (bool, error) {
136 + if r == nil || manager == nil {
137 + return false, nil
138 + }
139 + if ensHostname := r.ensGaslessDNSHostname(); ensHostname != "" {
140 + if err := manager.SyncENSGaslessHostname(ctx, ensHostname, r.Address); err != nil {
141 + return false, err
142 + }
143 + }
144 + if r.hasECHDNSRecord() {
145 + if err := manager.SyncECHConfig(ctx, r.ECHDNSHostname, r.ECHConfigList, sniPort); err != nil {
146 + return true, err
147 + }
148 + }
149 + return false, nil
150 +}
151 +
152 +func (r *leaseRecord) deleteDNS(ctx context.Context, manager *acme.Manager, includeECH bool) {
153 + if r == nil || manager == nil {
154 + return
155 + }
156 + if ensHostname := r.ensGaslessDNSHostname(); ensHostname != "" {
157 + err := manager.DeleteENSGaslessHostname(ctx, ensHostname)
158 + if err != nil {
159 + log.Warn().
160 + Err(err).
161 + Str("hostname", ensHostname).
162 + Str("address", r.Address).
163 + Msg("delete ens gasless hostname")
164 + }
165 + }
166 + if includeECH && r.hasECHDNSRecord() {
167 + err := manager.DeleteECHConfig(ctx, r.ECHDNSHostname)
168 + if err != nil {
169 + log.Warn().
170 + Err(err).
171 + Str("hostname", r.ECHDNSHostname).
172 + Str("route_hostname", r.Hostname).
173 + Str("address", r.Address).
174 + Msg("delete ech dns record")
175 + }
176 + }
177 +}
portal/server.go
+6 -50
@@ -387,31 +387,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
387 s.cancel()
388 }
389
390 - leases := s.registry.CloseAll()
391 - if manager := s.acmeManager; manager != nil {
392 - for _, lease := range leases {
393 - if lease.hasENSGaslessDNSRecord() {
394 - err := manager.DeleteENSGaslessHostname(ctx, lease.Hostname)
395 - if err != nil {
396 - log.Warn().
397 - Err(err).
398 - Str("hostname", lease.Hostname).
399 - Str("address", lease.Address).
400 - Msg("delete lease ens gasless hostname during shutdown")
401 - }
402 - }
403 - if lease.hasECHDNSRecord() {
404 - err := manager.DeleteECHConfig(ctx, lease.ECHDNSHostname)
405 - if err != nil {
406 - log.Warn().
407 - Err(err).
408 - Str("hostname", lease.ECHDNSHostname).
409 - Str("route_hostname", lease.Hostname).
410 - Str("address", lease.Address).
411 - Msg("delete lease ech dns record during shutdown")
412 - }
413 - }
414 - }
390 + records := s.registry.CloseAll()
391 + for _, record := range records {
392 + record.deleteDNS(ctx, s.acmeManager, true)
393 }
394
395 if s.quicBackhaul != nil {
@@ -678,31 +656,9 @@ func (s *Server) runRegistryJanitor(ctx context.Context, interval time.Duration)
656 case <-ctx.Done():
657 return nil
658 case <-ticker.C:
681 - leases := s.registry.cleanupExpired(time.Now())
682 - if manager := s.acmeManager; manager != nil {
683 - for _, lease := range leases {
684 - if lease.hasENSGaslessDNSRecord() {
685 - err := manager.DeleteENSGaslessHostname(ctx, lease.Hostname)
686 - if err != nil {
687 - log.Warn().
688 - Err(err).
689 - Str("hostname", lease.Hostname).
690 - Str("address", lease.Address).
691 - Msg("delete expired lease ens gasless hostname")
692 - }
693 - }
694 - if lease.hasECHDNSRecord() {
695 - err := manager.DeleteECHConfig(ctx, lease.ECHDNSHostname)
696 - if err != nil {
697 - log.Warn().
698 - Err(err).
699 - Str("hostname", lease.ECHDNSHostname).
700 - Str("route_hostname", lease.Hostname).
701 - Str("address", lease.Address).
702 - Msg("delete expired lease ech dns record")
703 - }
704 - }
705 - }
659 + records := s.registry.cleanupExpired(time.Now())
660 + for _, record := range records {
661 + record.deleteDNS(ctx, s.acmeManager, true)
662 }
663 }
664 }
sdk/api_client.go
+42 -67
@@ -10,7 +10,6 @@ import (
10 "net"
11 "net/http"
12 "net/url"
13 - "slices"
13 "strconv"
14 "strings"
15 "time"
@@ -37,12 +36,11 @@ var errRelayIncompatible = errors.New("relay is incompatible")
36 // API call creates fresh TCP connections. Call this after detecting a system
37 // sleep/wake cycle where pooled connections are almost certainly dead.
38 func (l *listener) resetTransport() {
40 - if l.httpClient != nil {
41 - if transport, ok := l.httpClient.Transport.(*http.Transport); ok {
42 - transport.CloseIdleConnections()
43 - }
39 + if l.httpTransport != nil {
40 + l.httpTransport.CloseIdleConnections()
41 }
42 l.httpClient = nil
43 + l.httpTransport = nil
44 l.tlsConfig = nil
45 }
46
@@ -54,16 +52,14 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
52 bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
53 defer cancel()
54
57 - tlsConfig, httpClient, err := utils.NewHTTPTLSClient(bootstrapCtx, l.relayURL, l.requestTimeout)
55 + tlsConfig, httpClient, httpTransport, err := utils.NewHTTPTLSClient(bootstrapCtx, l.relayURL, l.requestTimeout)
56 if err != nil {
57 return err
58 }
59
60 var domainResp types.DomainResponse
61 if err := utils.HTTPDoAPIPath(ctx, httpClient, l.relayURL, http.MethodGet, types.PathSDKDomain, nil, nil, &domainResp); err != nil {
64 - if transport, ok := httpClient.Transport.(*http.Transport); ok {
65 - transport.CloseIdleConnections()
66 - }
62 + httpTransport.CloseIdleConnections()
63 err = fmt.Errorf("check relay compatibility: %w", err)
64 var netErr net.Error
65 var apiErr *types.APIRequestError
@@ -77,13 +73,12 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
73 }
74 protocolVersion := strings.TrimSpace(domainResp.ProtocolVersion)
75 if protocolVersion != types.SDKVersion {
80 - if transport, ok := httpClient.Transport.(*http.Transport); ok {
81 - transport.CloseIdleConnections()
82 - }
76 + httpTransport.CloseIdleConnections()
77 return fmt.Errorf("%w: relay sdk protocol version mismatch: relay=%q client=%q", errRelayIncompatible, protocolVersion, types.SDKVersion)
78 }
79
80 l.httpClient = httpClient
81 + l.httpTransport = httpTransport
82 l.tlsConfig = tlsConfig
83 return nil
84 }
@@ -243,108 +238,88 @@ func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration,
238 }
239
240 func (l *listener) unregisterLease(ctx context.Context, accessToken string, hopRoutes []types.HopRoute) error {
246 - var unregisterErr error
247 - if err := l.unregisterHopRoutes(ctx, hopRoutes); err != nil {
248 - unregisterErr = errors.Join(unregisterErr, err)
249 - }
241 + hopErr := l.unregisterHopRoutes(ctx, hopRoutes)
242 err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
243 AccessToken: accessToken,
244 }, nil, nil)
253 - return errors.Join(unregisterErr, err)
245 + return errors.Join(hopErr, err)
246 }
247
256 -func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, routes []types.HopRoute) (multihopAccessToken string, err error) {
257 - if len(routes) == 0 {
258 - return "", nil
259 - }
248 +func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, routes []types.HopRoute) (string, int, error) {
249 if l.relaySet == nil {
261 - return "", errors.New("multi-hop relay set is unavailable")
250 + return "", 0, errors.New("multi-hop relay set is unavailable")
251 }
252
264 - orderedRoutes := append([]types.HopRoute(nil), routes...)
253 now := time.Now().UTC()
266 - for i := range orderedRoutes {
267 - desc, ok := l.relaySet.OverlayRelayDescriptor(orderedRoutes[i].ForwardRelay.APIHTTPSAddr, now)
254 + for i := len(routes) - 1; i >= 0; i-- {
255 + route := routes[i]
256 + desc, ok := l.relaySet.OverlayRelayDescriptor(route.ForwardRelay.APIHTTPSAddr, now)
257 if !ok {
269 - return "", fmt.Errorf("multi-hop forward relay %d descriptor is unavailable", i)
258 + return "", 0, fmt.Errorf("multi-hop forward relay %d descriptor is unavailable", i)
259 }
271 - orderedRoutes[i].ForwardRelay = desc
272 - }
273 - slices.Reverse(orderedRoutes)
274 -
275 - for _, unsignedRoute := range orderedRoutes {
276 - unsignedRoute.FirstSeenAt = expiresAt.Add(-30 * time.Second)
277 - route, err := auth.SignHopRoute(http.MethodPost, unsignedRoute, l.identity, expiresAt)
260 + route.ForwardRelay = desc
261 + route.FirstSeenAt = expiresAt.Add(-30 * time.Second)
262 + route, err := auth.SignHopRoute(http.MethodPost, route, l.identity, expiresAt)
263 if err != nil {
279 - return "", err
264 + return "", 0, err
265 }
266 relayURL, err := url.Parse(route.RelayURL)
267 if err != nil {
283 - return "", fmt.Errorf("parse hop route relay url: %w", err)
268 + return "", 0, fmt.Errorf("parse hop route relay url: %w", err)
269 }
270
271 bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
287 - _, client, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
272 + _, client, transport, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
273 cancel()
274 if err != nil {
290 - return "", err
275 + return "", 0, err
276 }
292 - transport, _ := client.Transport.(*http.Transport)
293 - entryRoute := route.MatchToken == "" && route.RouteHostname != ""
277 var hopResp types.HopRouteResponse
295 - var out any
296 - if entryRoute {
297 - out = &hopResp
298 - }
299 - err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodPost, types.PathSDKHop, route, nil, out)
300 - if transport != nil {
301 - transport.CloseIdleConnections()
302 - }
278 + err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodPost, types.PathSDKHop, route, nil, &hopResp)
279 + transport.CloseIdleConnections()
280 if err != nil {
304 - return "", err
281 + return "", 0, err
282 }
306 - if !entryRoute {
283 + if route.MatchToken != "" || route.RouteHostname == "" {
284 continue
285 }
286 if hopResp.AccessToken == "" {
310 - return "", errors.New("entry relay did not return access token")
287 + return "", 0, errors.New("entry relay did not return access token")
288 + }
289 + if hopResp.SNIPort <= 0 {
290 + return "", 0, errors.New("entry relay did not return sni port")
291 }
312 - multihopAccessToken = hopResp.AccessToken
313 - return multihopAccessToken, nil
292 + return hopResp.AccessToken, hopResp.SNIPort, nil
293 }
315 - return "", errors.New("entry hop route did not return access token")
294 + return "", 0, errors.New("entry hop route did not return access token")
295 }
296
297 func (l *listener) unregisterHopRoutes(ctx context.Context, routes []types.HopRoute) error {
319 - var syncErr error
320 - for _, unsignedRoute := range routes {
321 - route, err := auth.SignHopRoute(http.MethodDelete, unsignedRoute, l.identity, time.Time{})
298 + var unregisterErr error
299 + for _, route := range routes {
300 + route, err := auth.SignHopRoute(http.MethodDelete, route, l.identity, time.Time{})
301 if err != nil {
323 - syncErr = errors.Join(syncErr, err)
302 + unregisterErr = errors.Join(unregisterErr, err)
303 continue
304 }
305 relayURL, err := url.Parse(route.RelayURL)
306 if err != nil {
328 - syncErr = errors.Join(syncErr, fmt.Errorf("parse hop route relay url: %w", err))
307 + unregisterErr = errors.Join(unregisterErr, fmt.Errorf("parse hop route relay url: %w", err))
308 continue
309 }
310
311 bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
333 - _, client, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
312 + _, client, transport, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
313 cancel()
314 if err != nil {
336 - syncErr = errors.Join(syncErr, err)
315 + unregisterErr = errors.Join(unregisterErr, err)
316 continue
317 }
339 - transport, _ := client.Transport.(*http.Transport)
318 err = utils.HTTPDoAPIPath(ctx, client, relayURL, http.MethodDelete, types.PathSDKHop, route, nil, nil)
341 - if transport != nil {
342 - transport.CloseIdleConnections()
343 - }
319 + transport.CloseIdleConnections()
320 if err != nil {
345 - syncErr = errors.Join(syncErr, err)
346 - continue
321 + unregisterErr = errors.Join(unregisterErr, err)
322 }
323 }
349 - return syncErr
324 + return unregisterErr
325 }
sdk/listener.go
+11 -5
@@ -70,8 +70,9 @@ type listener struct {
70 datagram *transport.ClientDatagram
71 mitmManager *mitmManager
72
73 - httpClient *http.Client
74 - tlsConfig *tls.Config
73 + httpClient *http.Client
74 + httpTransport *http.Transport
75 + tlsConfig *tls.Config
76
77 leaseMu sync.RWMutex
78 lease *listenerSnapshot
@@ -687,8 +688,9 @@ func (l *listener) renewLease(ctx context.Context) error {
688 return errors.New("relay did not return renewed access token")
689 }
690 multihopAccessToken := resp.AccessToken
691 + var entrySNIPort int
692 if len(lease.hopRoutes) > 0 {
691 - multihopAccessToken, err = l.registerHopRoutes(requestCtx, resp.ExpiresAt, lease.hopRoutes)
693 + multihopAccessToken, entrySNIPort, err = l.registerHopRoutes(requestCtx, resp.ExpiresAt, lease.hopRoutes)
694 if err != nil {
695 return err
696 }
@@ -702,6 +704,9 @@ func (l *listener) renewLease(ctx context.Context) error {
704 next.accessToken = resp.AccessToken
705 next.expiresAt = resp.ExpiresAt
706 next.multihopAccessToken = multihopAccessToken
707 + if entrySNIPort > 0 {
708 + next.sniPort = entrySNIPort
709 + }
710 l.lease = &next
711 l.leaseMu.Unlock()
712 return nil
@@ -732,8 +737,9 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
737 return errors.New("relay did not return sni port for udp transport")
738 }
739 multihopAccessToken := resp.AccessToken
740 + sniPort := resp.SNIPort
741 if len(hopRoutes) > 0 {
736 - multihopAccessToken, err = l.registerHopRoutes(ctx, resp.ExpiresAt, hopRoutes)
742 + multihopAccessToken, sniPort, err = l.registerHopRoutes(ctx, resp.ExpiresAt, hopRoutes)
743 if err != nil {
744 _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
745 return err
@@ -786,7 +792,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
792 tcpAddr: resp.TCPAddr,
793 accessToken: resp.AccessToken,
794 expiresAt: resp.ExpiresAt,
789 - sniPort: resp.SNIPort,
795 + sniPort: sniPort,
796 publicURLBase: publicURLBase,
797 tlsConfig: tlsConf,
798 tlsCloser: tenantTLSCloser,
types/api.go
+1
@@ -142,6 +142,7 @@ type HopRoute struct {
142
143 type HopRouteResponse struct {
144 AccessToken string `json:"access_token,omitempty"`
145 + SNIPort int `json:"sni_port,omitempty"`
146 }
147
148 func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
utils/tls.go
+6 -6
@@ -17,25 +17,25 @@ import (
17 "time"
18 )
19
20 -func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Duration) (*tls.Config, *http.Client, error) {
20 +func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Duration) (*tls.Config, *http.Client, *http.Transport, error) {
21 if relayURL == nil {
22 - return nil, nil, errors.New("relay url is required")
22 + return nil, nil, nil, errors.New("relay url is required")
23 }
24
25 serverName := relayURL.Hostname()
26 if serverName == "" {
27 - return nil, nil, errors.New("relay hostname is required")
27 + return nil, nil, nil, errors.New("relay hostname is required")
28 }
29
30 var rootCAs *x509.CertPool
31 if IsLocalRelayHost(serverName) {
32 rootCAPEM, err := FetchEndpointCertificateChain(ctx, relayURL.String(), serverName)
33 if err != nil {
34 - return nil, nil, fmt.Errorf("bootstrap localhost relay trust: %w", err)
34 + return nil, nil, nil, fmt.Errorf("bootstrap localhost relay trust: %w", err)
35 }
36 rootCAs = x509.NewCertPool()
37 if !rootCAs.AppendCertsFromPEM(rootCAPEM) {
38 - return nil, nil, errors.New("failed to parse relay root ca")
38 + return nil, nil, nil, errors.New("failed to parse relay root ca")
39 }
40 }
41
@@ -50,7 +50,7 @@ func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Durat
50 WithoutHTTP2(),
51 WithHTTPTimeout(timeout),
52 )
53 - return rawTLSConfig, httpClient, nil
53 + return rawTLSConfig, httpClient, mustTransportOf(httpClient), nil
54 }
55
56 func FetchEndpointCertificateChain(ctx context.Context, endpoint, serverName string) ([]byte, error) {