feat: implement multi-hop relay support in overlay
Kim committed
Apr 16, 2026 at 17:03 UTC
41b7c8f19656ea67d64310f26fd4d566b8f27253
18 files changed
+1317
-166
cmd/portal-tunnel/main.go
+4
@@ -34,6 +34,7 @@ func main() {
34
35
type exposeFlags struct {
36
relayCSV string
37
+ multiHopCSV string
38
discovery bool
39
banMITM bool
40
identityPath string
@@ -57,6 +58,7 @@ func runExposeCommand(args []string) error {
58
fs := utils.NewFlagSet("expose", printExposeUsage)
59
60
utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
61
+ utils.StringFlagEnv(fs, &flags.multiHopCSV, "multi-hop", "", "Ordered multi-hop relay API URLs, comma-separated", "MULTI_HOP")
62
utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
63
utils.BoolFlagEnv(fs, &flags.banMITM, "ban-mitm", true, "Ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
64
utils.StringFlagEnv(fs, &flags.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
@@ -110,6 +112,7 @@ func runExposeCommand(args []string) error {
112
UDPAddr: flags.udpAddr,
113
UDPEnabled: flags.udp,
114
TCPEnabled: flags.tcp,
115
+ MultiHop: utils.SplitCSV(flags.multiHopCSV),
116
BanMITM: flags.banMITM,
117
MaxActiveRelays: flags.maxActiveRelays,
118
Metadata: types.LeaseMetadata{
@@ -207,6 +210,7 @@ func printExposeUsage(w io.Writer) {
210
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
211
"portal expose 3000 --ban-mitm",
212
"portal expose 3000 --relays https://portal.example.com --discovery=false",
213
+ "portal expose 3000 --multi-hop https://entry.example.com,https://transit.example.com,https://exit.example.com",
214
},
215
)
216
}
go.mod
+1
@@ -14,6 +14,7 @@ require (
14
github.com/go-jose/go-jose/v4 v4.1.4
15
github.com/go-rod/rod v0.116.2
16
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
17
+ github.com/hashicorp/yamux v0.1.2
18
github.com/quic-go/quic-go v0.59.0
19
github.com/rs/zerolog v1.34.0
20
github.com/spruceid/siwe-go v0.2.1
go.sum
+2
@@ -83,6 +83,8 @@ github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ
83
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
84
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
85
github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
86
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
87
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
88
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
89
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
90
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
portal/api_server.go
+100
-18
@@ -382,6 +382,35 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
382
Path: types.PathSDKRegister,
383
}).String()
384
385
+ if len(req.MultiHop) > 0 && (s.hops == nil || s.overlay == nil || s.relaySet == nil) {
386
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
387
+ return
388
+ }
389
+ if len(req.MultiHop) > 0 {
390
+ if len(req.MultiHop) < 2 {
391
+ utils.InvalidRequestError(errors.New("multi-hop path requires entry and exit relays")).Write(w)
392
+ return
393
+ }
394
+
395
+ multiHop := make([]types.RelayDescriptor, 0, len(req.MultiHop))
396
+ for i, hop := range req.MultiHop {
397
+ verified, err := auth.VerifyRelayDescriptor(hop)
398
+ if err != nil {
399
+ utils.InvalidRequestError(fmt.Errorf("hop %d: %w", i, err)).Write(w)
400
+ return
401
+ }
402
+ multiHop = append(multiHop, verified)
403
+ }
404
+ if multiHop[0].APIHTTPSAddr == s.cfg.PortalURL {
405
+ utils.InvalidRequestError(errors.New("entry relay must differ from exit relay")).Write(w)
406
+ return
407
+ }
408
+ if multiHop[len(multiHop)-1].APIHTTPSAddr != s.cfg.PortalURL {
409
+ utils.InvalidRequestError(errors.New("exit relay must match registration relay")).Write(w)
410
+ return
411
+ }
412
+ req.MultiHop = multiHop
413
+ }
414
if req.UDPEnabled && (!s.cfg.UDPEnabled || s.group != nil && s.quicTunnel == nil) {
415
utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
416
return
@@ -430,6 +459,10 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
459
writeAPIErrorResponse(w, err)
460
return
461
}
462
+ if err := s.hops.renew(record); err != nil {
463
+ utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, err.Error())
464
+ return
465
+ }
466
nextAccessToken, _, err := auth.IssueLeaseAccessToken(s.identity.PrivateKey, s.identity.Address, s.cfg.PortalURL, record.Copy(), ttl)
467
if err != nil {
468
utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
@@ -462,18 +495,19 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
495
writeAPIErrorResponse(w, err)
496
return
497
}
465
- deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
466
- defer cancel()
467
- if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
468
- log.Warn().
469
- Err(err).
470
- Str("hostname", record.Hostname).
471
- Str("address", record.Address).
472
- Msg("delete lease ens gasless txt")
473
- }
474
- if record != nil {
475
- record.Close()
498
+ s.hops.delete(record)
499
+ if record.isDirect() && s.acmeManager != nil {
500
+ deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
501
+ if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
502
+ log.Warn().
503
+ Err(err).
504
+ Str("hostname", record.Hostname).
505
+ Str("address", record.Address).
506
+ Msg("delete lease remote state")
507
+ }
508
+ cancel()
509
}
510
+ record.Close()
511
512
utils.WriteAPIData(w, http.StatusOK, map[string]any{})
513
}
@@ -602,9 +636,9 @@ func (s *Server) admitLeaseByToken(token string, requireDatagram bool) (*leaseRe
636
if err != nil {
637
return nil, errUnauthorized
638
}
605
- lease, err := s.registry.Find(claims.Identity)
606
- if err != nil {
607
- return nil, err
639
+ lease, ok := s.registry.RecordByKey(claims.Identity.Key(), time.Now())
640
+ if !ok {
641
+ return nil, errLeaseNotFound
642
}
643
if !s.registry.policy.IsIdentityRoutable(lease.Key()) {
644
return nil, errLeaseRejected
@@ -661,6 +695,16 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
695
}
696
issuedAt := claims.IssuedAt.Time().UTC()
697
expiresAt := claims.Expiry.Time().UTC()
698
+ if len(req.MultiHop) > 0 && (s.hops == nil || s.overlay == nil || s.relaySet == nil) {
699
+ return types.RegisterResponse{}, errFeatureUnavailable
700
+ }
701
+ multiHop := append([]types.RelayDescriptor(nil), req.MultiHop...)
702
+ if len(multiHop) > 0 {
703
+ hostname, err = utils.LeaseHostname(identity.Name, utils.PortalRootHost(multiHop[0].APIHTTPSAddr))
704
+ if err != nil {
705
+ return types.RegisterResponse{}, err
706
+ }
707
+ }
708
identityKey := identity.Key()
709
stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
710
record := &leaseRecord{
@@ -674,8 +718,14 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
718
ReportedIP: utils.SanitizeReportedIP(reportedIP),
719
UDPEnabled: req.UDPEnabled,
720
TCPEnabled: req.TCPEnabled,
721
+ multiHop: multiHop,
722
stream: stream,
723
}
724
+ if record.isDirect() && s.hops != nil {
725
+ if _, ok := s.hops.routeForHostname(record.Hostname, time.Now()); ok {
726
+ return types.RegisterResponse{}, errHostnameConflict
727
+ }
728
+ }
729
if req.UDPEnabled {
730
if s.udpPorts == nil {
731
return types.RegisterResponse{}, errors.New("udp port allocation not available")
@@ -709,17 +759,46 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
759
return types.RegisterResponse{}, err
760
}
761
712
- if err := s.registry.Register(record); err != nil {
762
+ replaced, err := s.registry.Register(record)
763
+ if err != nil {
764
record.Close()
765
return types.RegisterResponse{}, err
766
}
716
- syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
717
- defer cancel()
718
- if err := s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address); err != nil {
767
+ s.hops.delete(replaced)
768
+ if record.isDirect() && s.hops != nil {
769
+ if _, ok := s.hops.routeForHostname(record.Hostname, time.Now()); ok {
770
+ _, _ = s.registry.Unregister(record.Copy())
771
+ record.Close()
772
+ return types.RegisterResponse{}, errHostnameConflict
773
+ }
774
+ }
775
+ if !record.isDirect() {
776
+ now := time.Now().UTC()
777
+ for i := 0; i < len(record.multiHop)-1; i++ {
778
+ if err := s.relaySet.InsertAnnounced(record.multiHop[i], now); err != nil {
779
+ _, _ = s.registry.Unregister(record.Copy())
780
+ record.Close()
781
+ return types.RegisterResponse{}, fmt.Errorf("sync hop %d peer: %w", i, err)
782
+ }
783
+ }
784
+ if err := s.overlay.Sync(s.relaySet.OverlayPeerStates()); err != nil {
785
+ log.Warn().Err(err).Msg("sync wireguard multi-hop peers")
786
+ }
787
+ }
788
+ if err := s.hops.install(record); err != nil {
789
_, _ = s.registry.Unregister(record.Copy())
790
record.Close()
791
return types.RegisterResponse{}, err
792
}
793
+ if record.isDirect() {
794
+ syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
795
+ defer cancel()
796
+ if err := s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address); err != nil {
797
+ _, _ = s.registry.Unregister(record.Copy())
798
+ record.Close()
799
+ return types.RegisterResponse{}, err
800
+ }
801
+ }
802
803
resp := types.RegisterResponse{
804
Identity: record.Copy(),
@@ -729,6 +808,9 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
808
UDPEnabled: record.UDPEnabled,
809
TCPEnabled: record.TCPEnabled,
810
}
811
+ if !record.isDirect() {
812
+ resp.KeylessURL = record.multiHop[0].APIHTTPSAddr
813
+ }
814
if record.datagram != nil {
815
resp.SNIPort = s.cfg.SNIPort
816
resp.UDPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.datagram.UDPPort())
portal/auth/register_challenge.go
+6
@@ -48,12 +48,18 @@ func NewRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string
48
return nil, fmt.Errorf("build siwe message: %w", err)
49
}
50
51
+ var multiHop []types.RelayDescriptor
52
+ if len(req.MultiHop) > 0 {
53
+ multiHop = append([]types.RelayDescriptor{}, req.MultiHop...)
54
+ }
55
+
56
normalizedRequest := types.RegisterChallengeRequest{
57
Identity: normalizedIdentity,
58
Metadata: req.Metadata.Copy(),
59
TTL: req.TTL,
60
UDPEnabled: req.UDPEnabled,
61
TCPEnabled: req.TCPEnabled,
62
+ MultiHop: multiHop,
63
}
64
65
return &RegisterChallenge{
portal/hop.go
new
+436
@@ -0,0 +1,436 @@
1
+package portal
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net"
8
+ "strings"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/rs/zerolog/log"
13
+ "golang.org/x/sync/errgroup"
14
+
15
+ "github.com/gosuda/portal-tunnel/v2/portal/overlay"
16
+ "github.com/gosuda/portal-tunnel/v2/utils"
17
+)
18
+
19
+var (
20
+ errHopRouteConflict = errors.New("hop route conflict")
21
+ errHopRouteNotFound = errors.New("hop route not found")
22
+)
23
+
24
+type hopRoute struct {
25
+ matchHostname string
26
+ matchToken string
27
+ forwardOverlayIPv4 string
28
+ forwardToken string
29
+ identityKey string
30
+ expiresAt time.Time
31
+}
32
+
33
+type hopManager struct {
34
+ mux *overlay.HopMux
35
+ registry *leaseRegistry
36
+ proxy *proxy
37
+ routesByID map[string]hopRoute
38
+ mu sync.RWMutex
39
+}
40
+
41
+func newHopManager(ov *overlay.Overlay, registry *leaseRegistry, relayProxy *proxy) (*hopManager, error) {
42
+ mux, err := overlay.NewHopMux(ov)
43
+ if err != nil {
44
+ return nil, err
45
+ }
46
+ return &hopManager{
47
+ mux: mux,
48
+ registry: registry,
49
+ proxy: relayProxy,
50
+ routesByID: make(map[string]hopRoute),
51
+ }, nil
52
+}
53
+
54
+func (h *hopManager) install(record *leaseRecord) error {
55
+ if record.isDirect() {
56
+ return nil
57
+ }
58
+ if h == nil || h.mux == nil {
59
+ return errFeatureUnavailable
60
+ }
61
+ if len(record.multiHop) < 2 {
62
+ return errors.New("multi-hop lease path is invalid")
63
+ }
64
+
65
+ rawHopID, err := utils.RandomHex(32)
66
+ if err != nil {
67
+ return err
68
+ }
69
+ hopID := "hpr_" + rawHopID
70
+ tokens := make([]string, len(record.multiHop)-1)
71
+ for i := range tokens {
72
+ token, err := utils.RandomHex(32)
73
+ if err != nil {
74
+ return err
75
+ }
76
+ tokens[i] = "hpt_" + token
77
+ }
78
+ record.hopID = hopID
79
+
80
+ exitRouteID := fmt.Sprintf("%s_%d", record.hopID, len(record.multiHop)-1)
81
+ if err := h.installRoute(exitRouteID, hopRoute{
82
+ matchToken: tokens[len(tokens)-1],
83
+ identityKey: record.Key(),
84
+ expiresAt: record.ExpiresAt,
85
+ }, time.Now()); err != nil {
86
+ return err
87
+ }
88
+
89
+ type remoteRoute struct {
90
+ overlayIPv4 string
91
+ routeID string
92
+ }
93
+ installedRemoteRoutes := make([]remoteRoute, 0, len(record.multiHop)-1)
94
+ rollback := true
95
+ defer func() {
96
+ if !rollback {
97
+ return
98
+ }
99
+ h.deleteRoute(exitRouteID)
100
+ for _, route := range installedRemoteRoutes {
101
+ _ = h.mux.Control(context.Background(), route.overlayIPv4, overlay.HopControl{
102
+ Action: overlay.HopControlDelete,
103
+ Route: overlay.HopRouteSpec{
104
+ RouteID: route.routeID,
105
+ },
106
+ })
107
+ }
108
+ }()
109
+
110
+ ctx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
111
+ defer cancel()
112
+
113
+ for i := len(record.multiHop) - 2; i >= 0; i-- {
114
+ current := record.multiHop[i]
115
+ next := record.multiHop[i+1]
116
+ routeID := fmt.Sprintf("%s_%d", record.hopID, i)
117
+ route := overlay.HopRouteSpec{
118
+ RouteID: routeID,
119
+ ForwardOverlayIPv4: next.OverlayIPv4,
120
+ ForwardToken: tokens[i],
121
+ ExpiresAt: record.ExpiresAt,
122
+ }
123
+ if i == 0 {
124
+ route.MatchHostname = record.Hostname
125
+ } else {
126
+ route.MatchToken = tokens[i-1]
127
+ }
128
+ if err := h.mux.Control(ctx, current.OverlayIPv4, overlay.HopControl{
129
+ Action: overlay.HopControlInstall,
130
+ Route: route,
131
+ }); err != nil {
132
+ return fmt.Errorf("install hop %d route: %w", i, err)
133
+ }
134
+ installedRemoteRoutes = append(installedRemoteRoutes, remoteRoute{
135
+ overlayIPv4: current.OverlayIPv4,
136
+ routeID: routeID,
137
+ })
138
+ }
139
+
140
+ rollback = false
141
+ return nil
142
+}
143
+
144
+func (h *hopManager) renew(record *leaseRecord) error {
145
+ if record.isDirect() {
146
+ return nil
147
+ }
148
+ if h == nil || h.mux == nil {
149
+ return errFeatureUnavailable
150
+ }
151
+ ctx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
152
+ defer cancel()
153
+
154
+ if len(record.multiHop) < 2 || record.hopID == "" {
155
+ return errors.New("multi-hop lease path is invalid")
156
+ }
157
+ if err := h.renewRoute(fmt.Sprintf("%s_%d", record.hopID, len(record.multiHop)-1), record.ExpiresAt, time.Now()); err != nil {
158
+ return err
159
+ }
160
+ for i := len(record.multiHop) - 2; i >= 0; i-- {
161
+ if err := h.mux.Control(ctx, record.multiHop[i].OverlayIPv4, overlay.HopControl{
162
+ Action: overlay.HopControlRenew,
163
+ Route: overlay.HopRouteSpec{
164
+ RouteID: fmt.Sprintf("%s_%d", record.hopID, i),
165
+ ExpiresAt: record.ExpiresAt,
166
+ },
167
+ }); err != nil {
168
+ return fmt.Errorf("renew hop %d route: %w", i, err)
169
+ }
170
+ }
171
+ return nil
172
+}
173
+
174
+func (h *hopManager) delete(record *leaseRecord) {
175
+ if record == nil || record.isDirect() {
176
+ return
177
+ }
178
+ if h == nil || h.mux == nil {
179
+ return
180
+ }
181
+ h.deleteRoute(fmt.Sprintf("%s_%d", record.hopID, len(record.multiHop)-1))
182
+ ctx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
183
+ defer cancel()
184
+ for i := 0; i < len(record.multiHop)-1; i++ {
185
+ _ = h.mux.Control(ctx, record.multiHop[i].OverlayIPv4, overlay.HopControl{
186
+ Action: overlay.HopControlDelete,
187
+ Route: overlay.HopRouteSpec{
188
+ RouteID: fmt.Sprintf("%s_%d", record.hopID, i),
189
+ },
190
+ })
191
+ }
192
+}
193
+
194
+func (h *hopManager) run(ctx context.Context) error {
195
+ if h == nil || h.mux == nil {
196
+ <-ctx.Done()
197
+ return nil
198
+ }
199
+ group, groupCtx := errgroup.WithContext(ctx)
200
+ group.Go(func() error {
201
+ return h.mux.Serve(groupCtx)
202
+ })
203
+ group.Go(func() error {
204
+ for {
205
+ stream, err := h.mux.Accept(groupCtx)
206
+ if err != nil {
207
+ if groupCtx.Err() != nil {
208
+ return nil
209
+ }
210
+ return err
211
+ }
212
+ if stream.Control != nil {
213
+ _ = stream.Respond(h.handleControl(*stream.Control))
214
+ continue
215
+ }
216
+ go h.handleStream(groupCtx, stream.Conn, stream.Token)
217
+ }
218
+ })
219
+ return group.Wait()
220
+}
221
+
222
+func (h *hopManager) handleStream(ctx context.Context, conn net.Conn, token string) {
223
+ if h == nil || h.registry == nil {
224
+ _ = conn.Close()
225
+ return
226
+ }
227
+ route, record, ok := h.routeForToken(token, time.Now())
228
+ if !ok {
229
+ _ = conn.Close()
230
+ return
231
+ }
232
+ if record != nil {
233
+ if record.stream == nil || !h.registry.policy.IsIdentityRoutable(record.Key()) {
234
+ _ = conn.Close()
235
+ return
236
+ }
237
+ claimCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
238
+ session, err := record.stream.Claim(claimCtx)
239
+ cancel()
240
+ if err != nil {
241
+ _ = conn.Close()
242
+ return
243
+ }
244
+ h.proxy.bridge(conn, session, record.Key(), h.registry.policy.BPSManager())
245
+ return
246
+ }
247
+ if route.forwardOverlayIPv4 == "" || route.forwardToken == "" || h.mux == nil {
248
+ _ = conn.Close()
249
+ return
250
+ }
251
+ next, err := h.mux.OpenStream(ctx, route.forwardOverlayIPv4, route.forwardToken)
252
+ if err != nil {
253
+ _ = conn.Close()
254
+ log.Warn().Err(err).Str("next_overlay_ipv4", route.forwardOverlayIPv4).Msg("open next hop stream")
255
+ return
256
+ }
257
+ h.proxy.bridge(conn, next, "", nil)
258
+}
259
+
260
+func (h *hopManager) handleControl(control overlay.HopControl) error {
261
+ if h == nil || h.registry == nil {
262
+ return errFeatureUnavailable
263
+ }
264
+ switch strings.TrimSpace(control.Action) {
265
+ case overlay.HopControlInstall:
266
+ return h.installRoute(control.Route.RouteID, hopRoute{
267
+ matchHostname: control.Route.MatchHostname,
268
+ matchToken: control.Route.MatchToken,
269
+ forwardOverlayIPv4: control.Route.ForwardOverlayIPv4,
270
+ forwardToken: control.Route.ForwardToken,
271
+ expiresAt: control.Route.ExpiresAt,
272
+ }, time.Now())
273
+ case overlay.HopControlRenew:
274
+ return h.renewRoute(control.Route.RouteID, control.Route.ExpiresAt, time.Now())
275
+ case overlay.HopControlDelete:
276
+ h.deleteRoute(control.Route.RouteID)
277
+ return nil
278
+ default:
279
+ return errors.New("unknown hop control action")
280
+ }
281
+}
282
+
283
+func (h *hopManager) forwardHostname(ctx context.Context, conn net.Conn, hostname string) bool {
284
+ if h == nil || h.registry == nil || h.mux == nil {
285
+ return false
286
+ }
287
+ route, ok := h.routeForHostname(hostname, time.Now())
288
+ if !ok || route.forwardOverlayIPv4 == "" || route.forwardToken == "" {
289
+ return false
290
+ }
291
+ next, err := h.mux.OpenStream(ctx, route.forwardOverlayIPv4, route.forwardToken)
292
+ if err != nil {
293
+ log.Warn().Err(err).Str("next_overlay_ipv4", route.forwardOverlayIPv4).Msg("open next hop stream")
294
+ return false
295
+ }
296
+ h.proxy.bridge(conn, next, "", nil)
297
+ return true
298
+}
299
+
300
+func (h *hopManager) close() error {
301
+ if h == nil || h.mux == nil {
302
+ return nil
303
+ }
304
+ return h.mux.Close()
305
+}
306
+
307
+func (h *hopManager) routeForHostname(hostname string, now time.Time) (hopRoute, bool) {
308
+ hostname = utils.NormalizeHostname(hostname)
309
+ if h == nil || hostname == "" {
310
+ return hopRoute{}, false
311
+ }
312
+
313
+ h.mu.RLock()
314
+ defer h.mu.RUnlock()
315
+
316
+ for _, route := range h.routesByID {
317
+ if route.matchHostname == hostname && now.Before(route.expiresAt) {
318
+ return route, true
319
+ }
320
+ }
321
+ return hopRoute{}, false
322
+}
323
+
324
+func (h *hopManager) routeForToken(token string, now time.Time) (hopRoute, *leaseRecord, bool) {
325
+ token = strings.TrimSpace(token)
326
+ if h == nil || token == "" {
327
+ return hopRoute{}, nil, false
328
+ }
329
+
330
+ h.mu.RLock()
331
+ defer h.mu.RUnlock()
332
+
333
+ for _, route := range h.routesByID {
334
+ if route.matchToken != token || !now.Before(route.expiresAt) {
335
+ continue
336
+ }
337
+ if route.identityKey == "" {
338
+ return route, nil, true
339
+ }
340
+ record, ok := h.registry.RecordByKey(route.identityKey, now)
341
+ if !ok {
342
+ return hopRoute{}, nil, false
343
+ }
344
+ return route, record, true
345
+ }
346
+ return hopRoute{}, nil, false
347
+}
348
+
349
+func (h *hopManager) installRoute(routeID string, route hopRoute, now time.Time) error {
350
+ routeID = strings.TrimSpace(routeID)
351
+ route.matchHostname = utils.NormalizeHostname(route.matchHostname)
352
+ route.matchToken = strings.TrimSpace(route.matchToken)
353
+ route.forwardOverlayIPv4 = strings.TrimSpace(route.forwardOverlayIPv4)
354
+ route.forwardToken = strings.TrimSpace(route.forwardToken)
355
+ route.identityKey = strings.TrimSpace(route.identityKey)
356
+ route.expiresAt = route.expiresAt.UTC()
357
+
358
+ switch {
359
+ case h == nil:
360
+ return errFeatureUnavailable
361
+ case routeID == "":
362
+ return errors.New("route id is required")
363
+ case !route.expiresAt.After(now):
364
+ return errors.New("route expiry must be in the future")
365
+ case route.matchHostname == "" && route.matchToken == "":
366
+ return errors.New("hostname or token matcher is required")
367
+ case route.matchHostname != "" && route.matchToken != "":
368
+ return errors.New("hostname and token matchers are mutually exclusive")
369
+ case route.identityKey == "" && route.forwardOverlayIPv4 == "":
370
+ return errors.New("forward overlay ipv4 is required")
371
+ case route.identityKey == "" && route.forwardToken == "":
372
+ return errors.New("forward token is required")
373
+ }
374
+ if route.matchHostname != "" {
375
+ if record, ok := h.registry.Lookup(route.matchHostname); ok && record != nil && now.Before(record.ExpiresAt) {
376
+ return errHostnameConflict
377
+ }
378
+ }
379
+
380
+ h.mu.Lock()
381
+ defer h.mu.Unlock()
382
+
383
+ if h.routesByID == nil {
384
+ h.routesByID = make(map[string]hopRoute)
385
+ }
386
+ for existingID, existing := range h.routesByID {
387
+ if existingID == routeID {
388
+ continue
389
+ }
390
+ if !now.Before(existing.expiresAt) {
391
+ delete(h.routesByID, existingID)
392
+ continue
393
+ }
394
+ if route.matchHostname != "" && existing.matchHostname == route.matchHostname {
395
+ return errHopRouteConflict
396
+ }
397
+ if route.matchToken != "" && existing.matchToken == route.matchToken {
398
+ return errHopRouteConflict
399
+ }
400
+ }
401
+ h.routesByID[routeID] = route
402
+ return nil
403
+}
404
+
405
+func (h *hopManager) renewRoute(routeID string, expiresAt, now time.Time) error {
406
+ routeID = strings.TrimSpace(routeID)
407
+ expiresAt = expiresAt.UTC()
408
+ if routeID == "" {
409
+ return errors.New("route id is required")
410
+ }
411
+ if !expiresAt.After(now) {
412
+ return errors.New("route expiry must be in the future")
413
+ }
414
+
415
+ h.mu.Lock()
416
+ defer h.mu.Unlock()
417
+
418
+ route, ok := h.routesByID[routeID]
419
+ if !ok {
420
+ return errHopRouteNotFound
421
+ }
422
+ route.expiresAt = expiresAt
423
+ h.routesByID[routeID] = route
424
+ return nil
425
+}
426
+
427
+func (h *hopManager) deleteRoute(routeID string) {
428
+ routeID = strings.TrimSpace(routeID)
429
+ if h == nil || routeID == "" {
430
+ return
431
+ }
432
+
433
+ h.mu.Lock()
434
+ delete(h.routesByID, routeID)
435
+ h.mu.Unlock()
436
+}
portal/lease.go
+45
-22
@@ -18,7 +18,7 @@ import (
18
const defaultRegisterChallengeTTL = 2 * time.Minute
19
20
type leaseRegistry struct {
21
- routes map[string]string
21
+ leasesByHostname map[string]string
22
leasesByKey map[string]*leaseRecord
23
registerChallenges map[string]*auth.RegisterChallenge
24
policy *policy.Runtime
@@ -32,7 +32,7 @@ func newLeaseRegistry(udpEnabled, tcpPortEnabled bool, trustProxyHeaders bool, r
32
}
33
34
return &leaseRegistry{
35
- routes: make(map[string]string),
35
+ leasesByHostname: make(map[string]string),
36
leasesByKey: make(map[string]*leaseRecord),
37
registerChallenges: make(map[string]*auth.RegisterChallenge),
38
policy: runtime,
@@ -48,7 +48,7 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
48
out = append(out, record)
49
r.policy.ForgetIdentity(record.Key())
50
}
51
- r.routes = make(map[string]string)
51
+ r.leasesByHostname = make(map[string]string)
52
r.leasesByKey = make(map[string]*leaseRecord)
53
r.registerChallenges = make(map[string]*auth.RegisterChallenge)
54
return out
@@ -63,13 +63,13 @@ func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
63
r.mu.RLock()
64
defer r.mu.RUnlock()
65
66
- key, ok := r.routes[host]
66
+ key, ok := r.leasesByHostname[host]
67
if !ok {
68
parts := strings.Split(host, ".")
69
if len(parts) < 3 {
70
return nil, false
71
}
72
- key, ok = r.routes["*."+strings.Join(parts[1:], ".")]
72
+ key, ok = r.leasesByHostname["*."+strings.Join(parts[1:], ".")]
73
if !ok {
74
return nil, false
75
}
@@ -78,43 +78,47 @@ func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
78
return record, ok && record != nil
79
}
80
81
-func (r *leaseRegistry) Register(record *leaseRecord) error {
81
+func (r *leaseRegistry) Register(record *leaseRecord) (*leaseRecord, error) {
82
if record == nil {
83
- return errors.New("lease record is required")
83
+ return nil, errors.New("lease record is required")
84
}
85
86
key := record.Key()
87
if key == "" {
88
- return errors.New("lease identity is required")
88
+ return nil, errors.New("lease identity is required")
89
}
90
hostname := utils.NormalizeHostname(record.Hostname)
91
if hostname == "" {
92
- return errors.New("lease hostname is required")
92
+ return nil, errors.New("lease hostname is required")
93
}
94
95
r.mu.Lock()
96
97
- if existingKey, ok := r.routes[hostname]; ok && existingKey != key {
97
+ if existingKey, ok := r.leasesByHostname[hostname]; ok && existingKey != key {
98
r.mu.Unlock()
99
- return errHostnameConflict
99
+ return nil, errHostnameConflict
100
}
101
102
var replaced *leaseRecord
103
if existing, ok := r.leasesByKey[key]; ok && existing != nil {
104
replaced = existing
105
- delete(r.routes, utils.NormalizeHostname(existing.Hostname))
105
+ if existing != record && existing.isDirect() {
106
+ delete(r.leasesByHostname, utils.NormalizeHostname(existing.Hostname))
107
+ }
108
r.policy.ForgetIdentity(existing.Key())
109
}
110
record.Hostname = hostname
111
r.leasesByKey[key] = record
110
- r.routes[hostname] = key
112
+ if record.isDirect() {
113
+ r.leasesByHostname[hostname] = key
114
+ }
115
r.policy.IPFilter().RegisterIdentityIP(key, record.ClientIP)
116
r.mu.Unlock()
117
118
if replaced != nil && replaced != record {
119
replaced.Close()
120
}
117
- return nil
121
+ return replaced, nil
122
}
123
124
func (r *leaseRegistry) Renew(identity types.Identity, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
@@ -127,7 +131,8 @@ func (r *leaseRegistry) Renew(identity types.Identity, ttl time.Duration, client
131
}
132
133
now := time.Now()
130
- record.ExpiresAt = now.Add(ttl)
134
+ expiresAt := now.Add(ttl)
135
+ record.ExpiresAt = expiresAt
136
record.LastSeenAt = now
137
if strings.TrimSpace(clientIP) != "" {
138
record.ClientIP = clientIP
@@ -150,23 +155,33 @@ func (r *leaseRegistry) Unregister(identity types.Identity) (*leaseRecord, error
155
}
156
157
delete(r.leasesByKey, key)
153
- delete(r.routes, utils.NormalizeHostname(record.Hostname))
158
+ if record.isDirect() {
159
+ delete(r.leasesByHostname, utils.NormalizeHostname(record.Hostname))
160
+ }
161
r.policy.ForgetIdentity(key)
162
return record, nil
163
}
164
158
-func (r *leaseRegistry) Find(identity types.Identity) (*leaseRecord, error) {
165
+func (r *leaseRegistry) RecordByKey(key string, now time.Time) (*leaseRecord, bool) {
166
+ key = strings.TrimSpace(key)
167
+ if key == "" {
168
+ return nil, false
169
+ }
170
+
171
r.mu.RLock()
172
defer r.mu.RUnlock()
173
162
- record, ok := r.leasesByKey[identity.Key()]
163
- if !ok || time.Now().After(record.ExpiresAt) {
164
- return nil, errLeaseNotFound
174
+ record := r.leasesByKey[key]
175
+ if record == nil || now.After(record.ExpiresAt) {
176
+ return nil, false
177
}
166
- return record, nil
178
+ return record, true
179
}
180
181
func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string) (types.RegisterChallengeResponse, error) {
182
+ if len(req.MultiHop) > 0 && (req.UDPEnabled || req.TCPEnabled) {
183
+ return types.RegisterChallengeResponse{}, errTransportMismatch
184
+ }
185
if req.UDPEnabled {
186
if !r.policy.IsUDPEnabled() {
187
return types.RegisterChallengeResponse{}, errUDPDisabled
@@ -251,7 +266,9 @@ func (r *leaseRegistry) cleanupExpired(now time.Time) []*leaseRecord {
266
if now.After(record.ExpiresAt) {
267
expired = append(expired, record)
268
delete(r.leasesByKey, key)
254
- delete(r.routes, utils.NormalizeHostname(record.Hostname))
269
+ if record.isDirect() {
270
+ delete(r.leasesByHostname, utils.NormalizeHostname(record.Hostname))
271
+ }
272
r.policy.ForgetIdentity(key)
273
}
274
}
@@ -361,6 +378,8 @@ type leaseRecord struct {
378
UDPEnabled bool
379
TCPEnabled bool
380
Metadata types.LeaseMetadata
381
+ multiHop []types.RelayDescriptor
382
+ hopID string
383
datagram *transport.RelayDatagram
384
udpPorts *transport.PortAllocator
385
tcpPort *transport.RelayTCPPort
@@ -370,6 +389,10 @@ type leaseRecord struct {
389
startOnce sync.Once
390
}
391
392
+func (r *leaseRecord) isDirect() bool {
393
+ return len(r.multiHop) == 0
394
+}
395
+
396
func (r *leaseRegistry) AdminSnapshot(record *leaseRecord) types.AdminLease {
397
clientIP := record.ClientIP
398
identityKey := record.Key()
portal/lease_test.go
+5
-5
@@ -36,7 +36,7 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
36
stream: transport.NewRelayStream("addr-1", time.Minute, 1),
37
}
38
39
- if err := registry.Register(record); err != nil {
39
+ if _, err := registry.Register(record); err != nil {
40
t.Fatalf("Register() error = %v", err)
41
}
42
@@ -85,7 +85,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
85
ExpiresAt: time.Now().Add(30 * time.Second),
86
stream: transport.NewRelayStream("addr-wildcard", time.Minute, 1),
87
}
88
- if err := registry.Register(wildcardLease); err != nil {
88
+ if _, err := registry.Register(wildcardLease); err != nil {
89
t.Fatalf("Register(wildcard) error = %v", err)
90
}
91
@@ -105,7 +105,7 @@ func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
105
ExpiresAt: time.Now().Add(30 * time.Second),
106
stream: transport.NewRelayStream("addr-conflict", time.Minute, 1),
107
}
108
- err := registry.Register(conflict)
108
+ _, err := registry.Register(conflict)
109
if !errors.Is(err, errHostnameConflict) {
110
t.Fatalf("Register(conflict) error = %v, want hostname conflict", err)
111
}
@@ -129,7 +129,7 @@ func TestLeaseRegistrySnapshotAndRoutableUsePolicy(t *testing.T) {
129
ClientIP: "203.0.113.20",
130
stream: transport.NewRelayStream("addr-policy", time.Minute, 1),
131
}
132
- if err := registry.Register(record); err != nil {
132
+ if _, err := registry.Register(record); err != nil {
133
t.Fatalf("Register() error = %v", err)
134
}
135
@@ -169,7 +169,7 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
169
ExpiresAt: time.Now().Add(-time.Second),
170
stream: transport.NewRelayStream("addr-expired", time.Minute, 1),
171
}
172
- if err := registry.Register(record); err != nil {
172
+ if _, err := registry.Register(record); err != nil {
173
t.Fatalf("Register() error = %v", err)
174
}
175
portal/overlay/hop_mux.go
new
+470
@@ -0,0 +1,470 @@
1
+package overlay
2
+
3
+import (
4
+ "context"
5
+ "encoding/binary"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io"
10
+ "net"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/hashicorp/yamux"
16
+ "github.com/rs/zerolog/log"
17
+)
18
+
19
+const (
20
+ hopProtocolVersion = 1
21
+ hopPrefaceLimit = 4 << 10
22
+ hopControlPayloadLimit = 16 << 10
23
+ hopIncomingBuffer = 128
24
+ defaultPrefaceTimeout = 2 * time.Second
25
+ defaultControlTimeout = 10 * time.Second
26
+)
27
+
28
+const (
29
+ hopModeTLSStream = "tls-stream"
30
+ hopModeControl = "control"
31
+
32
+ HopControlInstall = "install"
33
+ HopControlRenew = "renew"
34
+ HopControlDelete = "delete"
35
+)
36
+
37
+type HopMux struct {
38
+ listener net.Listener
39
+ overlay *Overlay
40
+ incoming chan HopStream
41
+
42
+ mu sync.Mutex
43
+ sessions map[string]*yamux.Session
44
+ active map[*yamux.Session]struct{}
45
+ closed bool
46
+}
47
+
48
+type hopPreface struct {
49
+ Version int `json:"version"`
50
+ Mode string `json:"mode"`
51
+ Token string `json:"token"`
52
+}
53
+
54
+type HopControl struct {
55
+ Action string `json:"action"`
56
+ Route HopRouteSpec `json:"route"`
57
+}
58
+
59
+type HopRouteSpec struct {
60
+ RouteID string `json:"route_id"`
61
+ MatchHostname string `json:"match_hostname,omitempty"`
62
+ MatchToken string `json:"match_token,omitempty"`
63
+ ForwardOverlayIPv4 string `json:"forward_overlay_ipv4,omitempty"`
64
+ ForwardToken string `json:"forward_token,omitempty"`
65
+ ExpiresAt time.Time `json:"expires_at"`
66
+}
67
+
68
+type hopControlResponse struct {
69
+ OK bool `json:"ok"`
70
+ Error string `json:"error,omitempty"`
71
+}
72
+
73
+type HopStream struct {
74
+ Conn net.Conn
75
+ Token string
76
+ Control *HopControl
77
+}
78
+
79
+func NewHopMux(overlay *Overlay) (*HopMux, error) {
80
+ if overlay == nil || overlay.stack == nil {
81
+ return nil, errors.New("overlay is required for multi-hop mux")
82
+ }
83
+ listener, err := overlay.stack.ListenTCP(DefaultPeerYamuxPort)
84
+ if err != nil {
85
+ return nil, fmt.Errorf("listen hop yamux: %w", err)
86
+ }
87
+ return &HopMux{
88
+ listener: listener,
89
+ overlay: overlay,
90
+ incoming: make(chan HopStream, hopIncomingBuffer),
91
+ sessions: make(map[string]*yamux.Session),
92
+ active: make(map[*yamux.Session]struct{}),
93
+ }, nil
94
+}
95
+
96
+func (m *HopMux) Serve(ctx context.Context) error {
97
+ if m == nil || m.listener == nil {
98
+ <-ctx.Done()
99
+ return nil
100
+ }
101
+ go func() {
102
+ <-ctx.Done()
103
+ _ = m.Close()
104
+ }()
105
+
106
+ for {
107
+ conn, err := m.listener.Accept()
108
+ switch {
109
+ case err == nil:
110
+ go m.serveSession(ctx, conn)
111
+ case errors.Is(err, net.ErrClosed):
112
+ return nil
113
+ default:
114
+ if ctx.Err() != nil {
115
+ return nil
116
+ }
117
+ return fmt.Errorf("accept hop mux connection: %w", err)
118
+ }
119
+ }
120
+}
121
+
122
+func (m *HopMux) Accept(ctx context.Context) (HopStream, error) {
123
+ if m == nil {
124
+ <-ctx.Done()
125
+ return HopStream{}, ctx.Err()
126
+ }
127
+ select {
128
+ case stream := <-m.incoming:
129
+ return stream, nil
130
+ case <-ctx.Done():
131
+ return HopStream{}, ctx.Err()
132
+ }
133
+}
134
+
135
+func (m *HopMux) Close() error {
136
+ if m == nil {
137
+ return nil
138
+ }
139
+
140
+ m.mu.Lock()
141
+ if m.closed {
142
+ m.mu.Unlock()
143
+ return nil
144
+ }
145
+ m.closed = true
146
+ seen := make(map[*yamux.Session]struct{}, len(m.sessions)+len(m.active))
147
+ sessions := make([]*yamux.Session, 0, len(m.sessions)+len(m.active))
148
+ for _, session := range m.sessions {
149
+ if _, ok := seen[session]; ok {
150
+ continue
151
+ }
152
+ seen[session] = struct{}{}
153
+ sessions = append(sessions, session)
154
+ }
155
+ for session := range m.active {
156
+ if _, ok := seen[session]; ok {
157
+ continue
158
+ }
159
+ seen[session] = struct{}{}
160
+ sessions = append(sessions, session)
161
+ }
162
+ m.sessions = make(map[string]*yamux.Session)
163
+ m.active = make(map[*yamux.Session]struct{})
164
+ listener := m.listener
165
+ m.mu.Unlock()
166
+
167
+ var closeErr error
168
+ if listener != nil {
169
+ closeErr = errors.Join(closeErr, listener.Close())
170
+ }
171
+ for _, session := range sessions {
172
+ closeErr = errors.Join(closeErr, session.Close())
173
+ }
174
+ return closeErr
175
+}
176
+
177
+func (m *HopMux) OpenStream(ctx context.Context, overlayIPv4, token string) (net.Conn, error) {
178
+ token = strings.TrimSpace(token)
179
+ if token == "" {
180
+ return nil, errors.New("next hop token is required")
181
+ }
182
+
183
+ stream, err := m.openYamuxStream(ctx, overlayIPv4)
184
+ if err != nil {
185
+ return nil, err
186
+ }
187
+ preface := hopPreface{
188
+ Version: hopProtocolVersion,
189
+ Mode: hopModeTLSStream,
190
+ Token: token,
191
+ }
192
+ if err := writeFramedJSON(stream, preface, hopPrefaceLimit); err != nil {
193
+ _ = stream.Close()
194
+ return nil, err
195
+ }
196
+ return stream, nil
197
+}
198
+
199
+func (m *HopMux) Control(ctx context.Context, overlayIPv4 string, req HopControl) error {
200
+ req.Action = strings.TrimSpace(req.Action)
201
+ if req.Action == "" {
202
+ return errors.New("hop control action is required")
203
+ }
204
+
205
+ stream, err := m.openYamuxStream(ctx, overlayIPv4)
206
+ if err != nil {
207
+ return err
208
+ }
209
+ defer stream.Close()
210
+
211
+ deadline := time.Now().Add(defaultControlTimeout)
212
+ if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
213
+ deadline = ctxDeadline
214
+ }
215
+ _ = stream.SetDeadline(deadline)
216
+
217
+ preface := hopPreface{
218
+ Version: hopProtocolVersion,
219
+ Mode: hopModeControl,
220
+ }
221
+ if err := writeFramedJSON(stream, preface, hopPrefaceLimit); err != nil {
222
+ return err
223
+ }
224
+ if err := writeFramedJSON(stream, req, hopControlPayloadLimit); err != nil {
225
+ return err
226
+ }
227
+ var resp hopControlResponse
228
+ if err := readFramedJSON(stream, &resp, hopControlPayloadLimit); err != nil {
229
+ return err
230
+ }
231
+ if !resp.OK {
232
+ if strings.TrimSpace(resp.Error) == "" {
233
+ return errors.New("hop control request failed")
234
+ }
235
+ return errors.New(resp.Error)
236
+ }
237
+ return nil
238
+}
239
+
240
+func (m *HopMux) openYamuxStream(ctx context.Context, overlayIPv4 string) (*yamux.Stream, error) {
241
+ overlayIPv4 = strings.TrimSpace(overlayIPv4)
242
+ if overlayIPv4 == "" {
243
+ return nil, errors.New("next hop overlay ipv4 is required")
244
+ }
245
+
246
+ session, err := m.session(ctx, overlayIPv4)
247
+ if err != nil {
248
+ return nil, err
249
+ }
250
+ stream, err := session.OpenStream()
251
+ if err != nil {
252
+ m.forgetSession(overlayIPv4, session)
253
+ _ = session.Close()
254
+ return nil, err
255
+ }
256
+ return stream, nil
257
+}
258
+
259
+func (m *HopMux) session(ctx context.Context, overlayIPv4 string) (*yamux.Session, error) {
260
+ m.mu.Lock()
261
+ if m.closed {
262
+ m.mu.Unlock()
263
+ return nil, net.ErrClosed
264
+ }
265
+ if session := m.sessions[overlayIPv4]; session != nil {
266
+ if !session.IsClosed() {
267
+ m.mu.Unlock()
268
+ return session, nil
269
+ }
270
+ delete(m.active, session)
271
+ }
272
+ delete(m.sessions, overlayIPv4)
273
+ m.mu.Unlock()
274
+
275
+ if m.overlay == nil || m.overlay.stack == nil {
276
+ return nil, errors.New("overlay is not initialized")
277
+ }
278
+ addr := net.JoinHostPort(overlayIPv4, fmt.Sprintf("%d", DefaultPeerYamuxPort))
279
+ conn, err := m.overlay.stack.DialContext(ctx, "tcp", addr)
280
+ if err != nil {
281
+ return nil, err
282
+ }
283
+
284
+ session, err := yamux.Client(conn, hopYamuxConfig())
285
+ if err != nil {
286
+ _ = conn.Close()
287
+ return nil, err
288
+ }
289
+
290
+ m.mu.Lock()
291
+ if m.closed {
292
+ m.mu.Unlock()
293
+ _ = session.Close()
294
+ return nil, net.ErrClosed
295
+ }
296
+ if current := m.sessions[overlayIPv4]; current != nil && !current.IsClosed() {
297
+ m.mu.Unlock()
298
+ _ = session.Close()
299
+ return current, nil
300
+ }
301
+ m.sessions[overlayIPv4] = session
302
+ m.active[session] = struct{}{}
303
+ m.mu.Unlock()
304
+ return session, nil
305
+}
306
+
307
+func (m *HopMux) forgetSession(overlayIPv4 string, session *yamux.Session) {
308
+ m.mu.Lock()
309
+ defer m.mu.Unlock()
310
+ if m.sessions[overlayIPv4] == session {
311
+ delete(m.sessions, overlayIPv4)
312
+ }
313
+ delete(m.active, session)
314
+}
315
+
316
+func (m *HopMux) serveSession(ctx context.Context, conn net.Conn) {
317
+ session, err := yamux.Server(conn, hopYamuxConfig())
318
+ if err != nil {
319
+ _ = conn.Close()
320
+ log.Warn().Err(err).Msg("create hop yamux session")
321
+ return
322
+ }
323
+ m.mu.Lock()
324
+ if m.closed {
325
+ m.mu.Unlock()
326
+ _ = session.Close()
327
+ return
328
+ }
329
+ m.active[session] = struct{}{}
330
+ m.mu.Unlock()
331
+ defer func() {
332
+ m.mu.Lock()
333
+ delete(m.active, session)
334
+ m.mu.Unlock()
335
+ _ = session.Close()
336
+ }()
337
+
338
+ for {
339
+ stream, err := session.AcceptStream()
340
+ if err != nil {
341
+ return
342
+ }
343
+ go m.handleStream(ctx, stream)
344
+ }
345
+}
346
+
347
+func (m *HopMux) handleStream(ctx context.Context, stream *yamux.Stream) {
348
+ _ = stream.SetReadDeadline(time.Now().Add(defaultPrefaceTimeout))
349
+ var preface hopPreface
350
+ err := readFramedJSON(stream, &preface, hopPrefaceLimit)
351
+ _ = stream.SetReadDeadline(time.Time{})
352
+ if err != nil {
353
+ _ = stream.Close()
354
+ return
355
+ }
356
+ if preface.Version != hopProtocolVersion {
357
+ _ = stream.Close()
358
+ return
359
+ }
360
+ switch preface.Mode {
361
+ case hopModeTLSStream:
362
+ if strings.TrimSpace(preface.Token) == "" {
363
+ _ = stream.Close()
364
+ return
365
+ }
366
+ m.deliver(ctx, HopStream{
367
+ Conn: stream,
368
+ Token: preface.Token,
369
+ })
370
+ case hopModeControl:
371
+ m.readControlStream(ctx, stream)
372
+ default:
373
+ _ = stream.Close()
374
+ }
375
+}
376
+
377
+func (m *HopMux) readControlStream(ctx context.Context, stream net.Conn) {
378
+ _ = stream.SetDeadline(time.Now().Add(defaultControlTimeout))
379
+
380
+ var req HopControl
381
+ var err error
382
+ if err = readFramedJSON(stream, &req, hopControlPayloadLimit); err == nil {
383
+ req.Action = strings.TrimSpace(req.Action)
384
+ }
385
+ if err != nil {
386
+ _ = stream.Close()
387
+ return
388
+ }
389
+ m.deliver(ctx, HopStream{
390
+ Conn: stream,
391
+ Control: &req,
392
+ })
393
+}
394
+
395
+func (m *HopMux) deliver(ctx context.Context, stream HopStream) {
396
+ select {
397
+ case m.incoming <- stream:
398
+ case <-ctx.Done():
399
+ _ = stream.Conn.Close()
400
+ }
401
+}
402
+
403
+func (s HopStream) Respond(err error) error {
404
+ if s.Conn == nil {
405
+ return nil
406
+ }
407
+ defer s.Conn.Close()
408
+ resp := hopControlResponse{OK: err == nil}
409
+ if err != nil {
410
+ resp.Error = err.Error()
411
+ }
412
+ return writeFramedJSON(s.Conn, resp, hopControlPayloadLimit)
413
+}
414
+
415
+func hopYamuxConfig() *yamux.Config {
416
+ cfg := yamux.DefaultConfig()
417
+ cfg.Logger = nil
418
+ cfg.MaxStreamWindowSize = 16 * 1024 * 1024
419
+ cfg.StreamOpenTimeout = 75 * time.Second
420
+ cfg.StreamCloseTimeout = 5 * time.Minute
421
+ return cfg
422
+}
423
+
424
+func writeFramedJSON(w io.Writer, value any, limit int) error {
425
+ payload, err := json.Marshal(value)
426
+ if err != nil {
427
+ return err
428
+ }
429
+ if len(payload) == 0 || len(payload) > limit {
430
+ return errors.New("frame size is invalid")
431
+ }
432
+ var size [4]byte
433
+ binary.BigEndian.PutUint32(size[:], uint32(len(payload)))
434
+ if err := writeAll(w, size[:]); err != nil {
435
+ return err
436
+ }
437
+ return writeAll(w, payload)
438
+}
439
+
440
+func readFramedJSON(r io.Reader, dst any, limit int) error {
441
+ var size [4]byte
442
+ if _, err := io.ReadFull(r, size[:]); err != nil {
443
+ return err
444
+ }
445
+ n := binary.BigEndian.Uint32(size[:])
446
+ if n == 0 || n > uint32(limit) {
447
+ return errors.New("frame size is invalid")
448
+ }
449
+ payload := make([]byte, n)
450
+ if _, err := io.ReadFull(r, payload); err != nil {
451
+ return err
452
+ }
453
+ return json.Unmarshal(payload, dst)
454
+}
455
+
456
+func writeAll(w io.Writer, p []byte) error {
457
+ for len(p) > 0 {
458
+ n, err := w.Write(p)
459
+ if n > 0 {
460
+ p = p[n:]
461
+ }
462
+ if err != nil {
463
+ return err
464
+ }
465
+ if n == 0 {
466
+ return io.ErrShortWrite
467
+ }
468
+ }
469
+ return nil
470
+}
portal/overlay/stack.go
+13
@@ -22,6 +22,7 @@ const (
22
DefaultMTU = 1420
23
DefaultListenPort = 51820
24
DefaultPeerAPIHTTPPort = 7777
25
+ DefaultPeerYamuxPort = 7778
26
DefaultPersistentKeepalive = 25
27
defaultEndpointResolveTTL = 3 * time.Second
28
)
@@ -31,6 +32,7 @@ type stack struct {
32
net *netstack.Net
33
overlayIP netip.Addr
34
35
+ applyMu sync.Mutex
36
mu sync.Mutex
37
closed bool
38
peerEndpoints map[string]string
@@ -124,6 +126,14 @@ func (s *stack) ApplyPeers(peers []desiredPeer) error {
126
if s == nil || s.device == nil {
127
return errors.New("wireguard is not initialized")
128
}
129
+ s.applyMu.Lock()
130
+ defer s.applyMu.Unlock()
131
+ s.mu.Lock()
132
+ if s.closed {
133
+ s.mu.Unlock()
134
+ return net.ErrClosed
135
+ }
136
+ s.mu.Unlock()
137
138
var builder strings.Builder
139
builder.WriteString("replace_peers=true\n")
@@ -232,6 +242,9 @@ func (s *stack) Close() error {
242
return nil
243
}
244
245
+ s.applyMu.Lock()
246
+ defer s.applyMu.Unlock()
247
+
248
s.mu.Lock()
249
if s.closed {
250
s.mu.Unlock()
portal/server.go
+44
-20
@@ -120,6 +120,7 @@ type Server struct {
120
relaySet *discovery.RelaySet
121
announceLimiter *discovery.AnnounceLimiter
122
registry *leaseRegistry
123
+ hops *hopManager
124
udpPorts *transport.PortAllocator
125
tcpPorts *transport.PortAllocator
126
}
@@ -174,15 +175,19 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
175
var sniListener net.Listener
176
var apiServer *http.Server
177
var apiCloser io.Closer
177
- var overlay *overlay.Overlay
178
+ var hops *hopManager
179
+ var ov *overlay.Overlay
180
var quicTunnel *quic.Listener
181
defer func() {
182
if started {
183
return
184
}
185
acmeManager.Stop()
184
- if overlay != nil {
185
- _ = overlay.Shutdown(context.Background())
186
+ if hops != nil {
187
+ _ = hops.close()
188
+ }
189
+ if ov != nil {
190
+ _ = ov.Shutdown(context.Background())
191
}
192
if apiServer != nil {
193
_ = apiServer.Close()
@@ -216,7 +221,11 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
221
}
222
223
if s.relaySet != nil && strings.TrimSpace(s.identity.WireGuardPrivateKey) != "" {
219
- overlay, err = s.startOverlay()
224
+ ov, err = s.startOverlay()
225
+ if err != nil {
226
+ return err
227
+ }
228
+ hops, err = newHopManager(ov, s.registry, &s.proxy)
229
if err != nil {
230
return err
231
}
@@ -236,7 +245,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
245
s.acmeManager = acmeManager
246
s.cancel = cancel
247
s.group = group
239
- s.overlay = overlay
248
+ s.overlay = ov
249
+ s.hops = hops
250
s.quicTunnel = quicTunnel
251
started = true
252
@@ -249,6 +259,9 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
259
if s.overlay != nil {
260
group.Go(s.overlay.Serve)
261
}
262
+ if s.hops != nil {
263
+ group.Go(func() error { return s.hops.run(groupCtx) })
264
+ }
265
if s.quicTunnel != nil {
266
group.Go(s.runQUICTunnelListener)
267
}
@@ -269,6 +282,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
282
Int("max_port", s.cfg.MaxPort).
283
Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
284
Bool("wireguard_enabled", s.overlay != nil).
285
+ Bool("multihop_enabled", s.hops != nil).
286
Bool("udp_enabled", s.quicTunnel != nil).
287
Bool("tcp_enabled", s.cfg.TCPEnabled)
288
if s.quicTunnel != nil {
@@ -306,14 +320,15 @@ func (s *Server) Shutdown(ctx context.Context) error {
320
321
for _, lease := range s.registry.CloseAll() {
322
if lease != nil {
309
- if s.acmeManager != nil {
323
+ s.hops.delete(lease)
324
+ if lease.isDirect() && s.acmeManager != nil {
325
deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
326
if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname); err != nil {
327
log.Warn().
328
Err(err).
329
Str("hostname", lease.Hostname).
330
Str("address", lease.Address).
316
- Msg("delete lease ens gasless txt during shutdown")
331
+ Msg("delete lease remote state during shutdown")
332
}
333
cancel()
334
}
@@ -334,6 +349,11 @@ func (s *Server) Shutdown(ctx context.Context) error {
349
shutdownErr = err
350
}
351
}
352
+ if s.hops != nil {
353
+ if err := s.hops.close(); err != nil && shutdownErr == nil && !errors.Is(err, net.ErrClosed) {
354
+ shutdownErr = err
355
+ }
356
+ }
357
if s.overlay != nil {
358
if err := s.overlay.Shutdown(ctx); err != nil && shutdownErr == nil {
359
shutdownErr = err
@@ -465,19 +485,20 @@ func (s *Server) runSNIListener(ctx context.Context) error {
485
486
record, ok := s.registry.Lookup(serverName)
487
if !ok || record == nil || time.Now().After(record.ExpiresAt) || record.stream == nil || !s.registry.policy.IsIdentityRoutable(record.Key()) {
468
- _ = wrappedConn.Close()
488
+ if s.hops == nil || !s.hops.forwardHostname(ctx, wrappedConn, serverName) {
489
+ _ = wrappedConn.Close()
490
+ return
491
+ }
492
return
493
}
494
495
claimCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
473
- defer cancel()
474
-
496
session, err := record.stream.Claim(claimCtx)
497
+ cancel()
498
if err != nil {
499
_ = wrappedConn.Close()
500
return
501
}
480
-
502
s.proxy.bridge(wrappedConn, session, record.Key(), s.registry.policy.BPSManager())
503
}(conn)
504
case errors.Is(err, net.ErrClosed):
@@ -505,15 +526,18 @@ func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) er
526
return nil
527
case <-ticker.C:
528
for _, lease := range s.registry.cleanupExpired(time.Now()) {
508
- deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
509
- err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname)
510
- cancel()
511
- if err != nil {
512
- log.Warn().
513
- Err(err).
514
- Str("hostname", lease.Hostname).
515
- Str("address", lease.Address).
516
- Msg("delete expired lease ens gasless txt")
529
+ s.hops.delete(lease)
530
+ if lease.isDirect() && s.acmeManager != nil {
531
+ deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
532
+ err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname)
533
+ cancel()
534
+ if err != nil {
535
+ log.Warn().
536
+ Err(err).
537
+ Str("hostname", lease.Hostname).
538
+ Str("address", lease.Address).
539
+ Msg("delete expired lease remote state")
540
+ }
541
}
542
lease.Close()
543
}
portal/server_test.go
+8
-8
@@ -239,7 +239,7 @@ func TestRegisterLeaseOmitsSNIPortWithoutUDP(t *testing.T) {
239
t.Fatalf("registerLease() error = %v", err)
240
}
241
t.Cleanup(func() {
242
- if record, err := server.registry.Find(resp.Identity); err == nil {
242
+ if record, ok := server.registry.RecordByKey(resp.Identity.Key(), time.Now()); ok {
243
record.Close()
244
}
245
})
@@ -341,9 +341,9 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
341
t.Fatalf("registerLease() hostname = %q, want %q", resp.Hostname, wantHostname)
342
}
343
344
- record, err := server.registry.Find(resp.Identity)
345
- if err != nil {
346
- t.Fatalf("registry.Find() error = %v, want registered lease", err)
344
+ record, ok := server.registry.RecordByKey(resp.Identity.Key(), time.Now())
345
+ if !ok {
346
+ t.Fatal("registry.RecordByKey() = false, want registered lease")
347
}
348
snapshot := server.registry.Snapshot(record)
349
if snapshot.Name != "demo-app" {
@@ -380,14 +380,14 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
380
t.Fatalf("registerLease() error = %v", err)
381
}
382
t.Cleanup(func() {
383
- if record, err := server.registry.Find(resp.Identity); err == nil {
383
+ if record, ok := server.registry.RecordByKey(resp.Identity.Key(), time.Now()); ok {
384
record.Close()
385
}
386
})
387
388
- record, err := server.registry.Find(resp.Identity)
389
- if err != nil {
390
- t.Fatalf("registry.Find() error = %v, want registered lease", err)
388
+ record, ok := server.registry.RecordByKey(resp.Identity.Key(), time.Now())
389
+ if !ok {
390
+ t.Fatal("registry.RecordByKey() = false, want registered lease")
391
}
392
if record.stream == nil {
393
t.Fatal("stream = nil, want stream runtime")
sdk/api_client.go
+47
@@ -82,6 +82,11 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
82
}
83
84
func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, error) {
85
+ multiHop, err := l.multiHopDescriptors()
86
+ if err != nil {
87
+ return types.RegisterResponse{}, err
88
+ }
89
+
90
var challenge types.RegisterChallengeResponse
91
if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
92
Identity: l.identity,
@@ -89,6 +94,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
94
TTL: int(ttl / time.Second),
95
UDPEnabled: udpEnabled,
96
TCPEnabled: tcpEnabled,
97
+ MultiHop: multiHop,
98
}, nil, &challenge); err != nil {
99
return types.RegisterResponse{}, err
100
}
@@ -110,6 +116,47 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
116
return resp, nil
117
}
118
119
+func (l *listener) multiHopDescriptors() ([]types.RelayDescriptor, error) {
120
+ if l == nil || len(l.multiHop) == 0 {
121
+ return nil, nil
122
+ }
123
+ if l.relaySet == nil {
124
+ return nil, errors.New("multi-hop relay set is unavailable")
125
+ }
126
+
127
+ states := l.relaySet.AggregateRelays()
128
+ byURL := make(map[string]types.RelayDescriptor, len(states))
129
+ for _, state := range states {
130
+ desc := state.Descriptor
131
+ if desc.APIHTTPSAddr != "" {
132
+ byURL[desc.APIHTTPSAddr] = desc
133
+ }
134
+ }
135
+
136
+ now := time.Now().UTC()
137
+ path := make([]types.RelayDescriptor, 0, len(l.multiHop))
138
+ seen := make(map[string]struct{}, len(l.multiHop))
139
+ for i, relayURL := range l.multiHop {
140
+ if _, ok := seen[relayURL]; ok {
141
+ return nil, errors.New("multi-hop relay urls must be unique")
142
+ }
143
+ seen[relayURL] = struct{}{}
144
+
145
+ desc, ok := byURL[relayURL]
146
+ if !ok {
147
+ return nil, fmt.Errorf("multi-hop relay %d descriptor was not discovered", i)
148
+ }
149
+ if !desc.SupportsOverlayPeer || desc.WireGuardPublicKey == "" || desc.WireGuardEndpoint == "" || desc.OverlayIPv4 == "" {
150
+ return nil, fmt.Errorf("multi-hop relay %d does not expose WireGuard overlay metadata", i)
151
+ }
152
+ if !desc.ExpiresAt.After(now) {
153
+ return nil, fmt.Errorf("multi-hop relay %d descriptor is expired", i)
154
+ }
155
+ path = append(path, desc)
156
+ }
157
+ return path, nil
158
+}
159
+
160
func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) {
161
var resp types.RenewResponse
162
if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
sdk/expose.go
+67
-30
@@ -31,6 +31,7 @@ type Exposure struct {
31
UDPAddr string
32
udpEnabled bool
33
tcpEnabled bool
34
+ multiHop []string
35
banMITM bool
36
maxActiveRelays int
37
metadata types.LeaseMetadata
@@ -47,15 +48,18 @@ type Exposure struct {
48
}
49
50
type ExposeConfig struct {
50
- RelayURLs []string
51
- Discovery bool
52
- IdentityPath string
53
- IdentityJSON string
54
- Name string
55
- TargetAddr string
56
- UDPAddr string
57
- UDPEnabled bool
58
- TCPEnabled bool
51
+ RelayURLs []string
52
+ Discovery bool
53
+ IdentityPath string
54
+ IdentityJSON string
55
+ Name string
56
+ TargetAddr string
57
+ UDPAddr string
58
+ UDPEnabled bool
59
+ TCPEnabled bool
60
+ // MultiHop is the caller-selected ordered relay URL path. The first URL is
61
+ // the public entry relay and the last URL is the exit relay the SDK registers with.
62
+ MultiHop []string
63
BanMITM bool
64
MaxActiveRelays int
65
Metadata types.LeaseMetadata
@@ -68,9 +72,27 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
72
if err != nil {
73
return nil, err
74
}
71
- relayURLs, err := utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, cfg.Discovery)
75
+ multiHop, err := utils.NormalizeRelayURLs(cfg.MultiHop...)
76
if err != nil {
73
- return nil, err
77
+ return nil, fmt.Errorf("normalize multi-hop relay urls: %w", err)
78
+ }
79
+ if len(multiHop) > 0 && len(multiHop) < 2 {
80
+ return nil, errors.New("multi-hop path requires entry and exit relays")
81
+ }
82
+ if len(multiHop) > 0 && (cfg.UDPEnabled || cfg.TCPEnabled) {
83
+ return nil, errors.New("multi-hop currently supports only the default SNI TLS stream transport")
84
+ }
85
+ listenerRelayURLs := []string(nil)
86
+ relaySetURLs := []string(nil)
87
+ if len(multiHop) > 0 {
88
+ listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
89
+ relaySetURLs = append([]string(nil), multiHop...)
90
+ } else {
91
+ listenerRelayURLs, err = utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, cfg.Discovery)
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ relaySetURLs = listenerRelayURLs
96
}
97
98
identity, createdIdentity, err := utils.ResolveListenerIdentity(
@@ -104,21 +126,30 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
126
cancel: cancel,
127
done: exposureCtx.Done(),
128
identity: identity,
107
- explicitRelays: append([]string(nil), explicitRelayURLs...),
129
+ explicitRelays: explicitRelayURLs,
130
TargetAddr: targetAddr,
131
UDPAddr: udpAddr,
132
udpEnabled: cfg.UDPEnabled,
133
tcpEnabled: cfg.TCPEnabled,
134
+ multiHop: multiHop,
135
banMITM: cfg.BanMITM,
136
maxActiveRelays: cfg.MaxActiveRelays,
137
metadata: cfg.Metadata,
115
- accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
116
- datagrams: make(chan types.DatagramFrame, max(len(relayURLs)*32, 1)),
117
- relaySet: discovery.NewRelaySet(relayURLs),
118
- relayListeners: make(map[string]*listener, len(relayURLs)),
138
+ accepted: make(chan net.Conn, max(len(listenerRelayURLs)*defaultReadyTarget*2, 1)),
139
+ datagrams: make(chan types.DatagramFrame, max(len(listenerRelayURLs)*32, 1)),
140
+ relaySet: discovery.NewRelaySet(relaySetURLs),
141
+ relayListeners: make(map[string]*listener, len(listenerRelayURLs)),
142
+ }
143
+
144
+ if len(multiHop) > 0 {
145
+ refresher := discovery.NewRefresher(exposure.relaySet, nil)
146
+ if err := refresher.Refresh(ctx, nil); err != nil {
147
+ _ = exposure.Close()
148
+ return nil, fmt.Errorf("discover multi-hop relays: %w", err)
149
+ }
150
}
151
121
- if len(relayURLs) > 0 {
152
+ if len(listenerRelayURLs) > 0 {
153
if err := exposure.reconcileRelayListeners(true); err != nil {
154
_ = exposure.Close()
155
return nil, err
@@ -366,21 +397,26 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
397
}
398
399
func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
369
- clientState := discovery.ClientState{
370
- ActiveRelayURLs: e.ActiveRelayURLs(),
371
- ExplicitRelayURLs: append([]string(nil), e.explicitRelays...),
372
- MaxActiveRelays: e.maxActiveRelays,
373
- RequireUDP: e.udpEnabled,
374
- RequireTCP: e.tcpEnabled,
400
+ listenerRelayURLs := []string(nil)
401
+ var multiHop []string
402
+ if len(e.multiHop) > 0 {
403
+ listenerRelayURLs = []string{e.multiHop[len(e.multiHop)-1]}
404
+ multiHop = append([]string(nil), e.multiHop...)
405
+ } else {
406
+ listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
407
+ ActiveRelayURLs: e.ActiveRelayURLs(),
408
+ ExplicitRelayURLs: append([]string(nil), e.explicitRelays...),
409
+ MaxActiveRelays: e.maxActiveRelays,
410
+ RequireUDP: e.udpEnabled,
411
+ RequireTCP: e.tcpEnabled,
412
+ })
413
}
414
377
- desiredRelayURLs := e.relaySet.PriorityRelays(clientState)
378
-
415
e.listenerMu.Lock()
416
staleRelayListeners := make(map[string]*listener)
417
removedRelayURLs := make([]string, 0)
418
for relayURL, listener := range e.relayListeners {
383
- if slices.Contains(desiredRelayURLs, relayURL) {
419
+ if slices.Contains(listenerRelayURLs, relayURL) {
420
continue
421
}
422
staleRelayListeners[relayURL] = listener
@@ -388,8 +424,8 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
424
delete(e.relayListeners, relayURL)
425
}
426
391
- missingRelayURLs := make([]string, 0, len(desiredRelayURLs))
392
- for _, relayURL := range desiredRelayURLs {
427
+ missingRelayURLs := make([]string, 0, len(listenerRelayURLs))
428
+ for _, relayURL := range listenerRelayURLs {
429
if _, ok := e.relayListeners[relayURL]; ok {
430
continue
431
}
@@ -411,13 +447,14 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
447
}
448
for _, relayURL := range missingRelayURLs {
449
retryCount := 10
414
- if slices.Contains(e.explicitRelays, relayURL) {
450
+ if len(e.multiHop) > 0 || slices.Contains(e.explicitRelays, relayURL) {
451
retryCount = 0
452
}
453
listener, err := newListener(context.Background(), relayURL, listenerConfig{
454
Identity: e.identity,
455
UDPEnabled: e.udpEnabled,
456
TCPEnabled: e.tcpEnabled,
457
+ MultiHop: multiHop,
458
BanMITM: e.banMITM,
459
RetryCount: retryCount,
460
Metadata: e.metadata,
@@ -455,7 +492,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
492
log.Info().
493
Strs("added_relays", addedRelayURLs).
494
Strs("removed_relays", removedRelayURLs).
458
- Strs("desired_relays", desiredRelayURLs).
495
+ Strs("listener_relays", listenerRelayURLs).
496
Msg("reconciled relay listeners")
497
}
498
return nil
sdk/listener.go
+45
-31
@@ -40,6 +40,7 @@ type listenerConfig struct {
40
ReadyTarget int
41
RetryCount int
42
RetryWait time.Duration
43
+ MultiHop []string
44
relaySet *discovery.RelaySet
45
}
46
@@ -54,6 +55,7 @@ type listener struct {
55
identity types.Identity
56
metadata types.LeaseMetadata
57
relaySet *discovery.RelaySet
58
+ multiHop []string
59
udpEnabled bool
60
tcpEnabled bool
61
dialTimeout time.Duration
@@ -105,6 +107,7 @@ func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*lis
107
identity: cfg.Identity,
108
metadata: cfg.Metadata,
109
relaySet: cfg.relaySet,
110
+ multiHop: cfg.MultiHop,
111
udpEnabled: cfg.UDPEnabled,
112
tcpEnabled: cfg.TCPEnabled,
113
dialTimeout: dialTimeout,
@@ -168,7 +171,10 @@ func (l *listener) run(ctx context.Context) {
171
}
172
173
retries = 0
171
- publicURL := l.publicURL()
174
+ publicURL := ""
175
+ if lease, ok := l.leaseSnapshot(); ok {
176
+ publicURL = l.publicURLForLease(lease)
177
+ }
178
event := log.Info().Str("address", l.identity.Address)
179
if publicURL != "" {
180
event.Msg("service ready at " + publicURL)
@@ -237,13 +243,14 @@ func (l *listener) Close() error {
243
}
244
245
type listenerLease struct {
240
- hostname string
241
- udpAddr string
242
- accessToken string
243
- expiresAt time.Time
244
- sniPort int
245
- tlsConfig *tls.Config
246
- tlsCloser io.Closer
246
+ hostname string
247
+ udpAddr string
248
+ accessToken string
249
+ expiresAt time.Time
250
+ sniPort int
251
+ publicURLBase *url.URL
252
+ tlsConfig *tls.Config
253
+ tlsCloser io.Closer
254
}
255
256
func (l *listener) clearLease(reason string) *listenerLease {
@@ -353,33 +360,29 @@ func (l *listener) datagramReady() (string, bool, bool) {
360
return udpAddr, ready, pending
361
}
362
356
-func (l *listener) publicURL() string {
357
- lease, ok := l.leaseSnapshot()
358
- if !ok {
359
- return ""
363
+func (l *listener) publicURLForLease(lease listenerLease) string {
364
+ baseURL := lease.publicURLBase
365
+ if baseURL == nil {
366
+ baseURL = l.relayURL
367
}
361
- return l.publicURLForHostname(lease.hostname)
362
-}
363
-
364
-func (l *listener) publicURLForHostname(hostname string) string {
365
- if l.relayURL == nil {
368
+ if baseURL == nil {
369
return ""
370
}
368
- if hostname == "" {
371
+ if lease.hostname == "" {
372
return ""
373
}
374
372
- if l.relayURL.Scheme == "" {
373
- return "https://" + hostname
375
+ if baseURL.Scheme == "" {
376
+ return "https://" + lease.hostname
377
}
378
376
- host := hostname
377
- if port := l.relayURL.Port(); port != "" {
378
- host = net.JoinHostPort(hostname, port)
379
+ host := lease.hostname
380
+ if port := baseURL.Port(); port != "" {
381
+ host = net.JoinHostPort(lease.hostname, port)
382
}
383
384
return (&url.URL{
382
- Scheme: l.relayURL.Scheme,
385
+ Scheme: baseURL.Scheme,
386
Host: host,
387
}).String()
388
}
@@ -759,7 +762,17 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
762
_ = l.unregisterLease(context.Background(), resp.AccessToken)
763
return errors.New("relay did not return sni port for udp transport")
764
}
762
- tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(l.relayURL.String(), []string{resp.Hostname})
765
+ keylessURL := strings.TrimSpace(resp.KeylessURL)
766
+ if keylessURL == "" {
767
+ keylessURL = l.relayURL.String()
768
+ }
769
+ publicURLBase := l.relayURL
770
+ if normalizedKeylessURL, err := utils.NormalizeRelayURL(keylessURL); err == nil {
771
+ if parsedKeylessURL, parseErr := url.Parse(normalizedKeylessURL); parseErr == nil {
772
+ publicURLBase = parsedKeylessURL
773
+ }
774
+ }
775
+ tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname})
776
if err != nil {
777
_ = l.unregisterLease(context.Background(), resp.AccessToken)
778
return err
@@ -773,12 +786,13 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
786
return ctx.Err()
787
}
788
next := &listenerLease{
776
- hostname: resp.Hostname,
777
- udpAddr: resp.UDPAddr,
778
- accessToken: resp.AccessToken,
779
- expiresAt: resp.ExpiresAt,
780
- tlsConfig: tlsConf,
781
- tlsCloser: tenantTLSCloser,
789
+ hostname: resp.Hostname,
790
+ udpAddr: resp.UDPAddr,
791
+ accessToken: resp.AccessToken,
792
+ expiresAt: resp.ExpiresAt,
793
+ publicURLBase: publicURLBase,
794
+ tlsConfig: tlsConf,
795
+ tlsCloser: tenantTLSCloser,
796
}
797
if l.udpEnabled {
798
next.sniPort = resp.SNIPort
sdk/mitm.go
+1
-1
@@ -86,7 +86,7 @@ func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport,
86
return MITMProbeReport{}, errors.New("listener hostname is unavailable")
87
}
88
89
- publicURL := l.publicURLForHostname(lease.hostname)
89
+ publicURL := l.publicURLForLease(lease)
90
if publicURL == "" {
91
return MITMProbeReport{}, errors.New("listener is not registered")
92
}
types/api.go
+4
@@ -68,6 +68,9 @@ type RegisterChallengeRequest struct {
68
TTL int `json:"ttl,omitempty"`
69
UDPEnabled bool `json:"udp_enabled,omitempty"`
70
TCPEnabled bool `json:"tcp_enabled,omitempty"`
71
+ // MultiHop is the ordered relay path for a multi-hop lease.
72
+ // The first hop is public ingress and the last hop is the exit relay receiving registration.
73
+ MultiHop []RelayDescriptor `json:"multi_hop,omitempty"`
74
}
75
76
type RegisterChallengeResponse struct {
@@ -81,6 +84,7 @@ type RegisterResponse struct {
84
ExpiresAt time.Time `json:"expires_at"`
85
Hostname string `json:"hostname"`
86
AccessToken string `json:"access_token"`
87
+ KeylessURL string `json:"keyless_url,omitempty"`
88
SNIPort int `json:"sni_port,omitempty"`
89
UDPAddr string `json:"udp_addr,omitempty"`
90
UDPEnabled bool `json:"udp_enabled,omitempty"`
types/identity.go
+19
-31
@@ -125,36 +125,6 @@ type RelayDescriptor struct {
125
Signature string `json:"signature,omitempty"`
126
}
127
128
-// canonicalRelayDescriptor mirrors the subset of RelayDescriptor fields that
129
-// participate in the cryptographic signature. Only fields that uniquely
130
-// identify the relay or affect routing are signed; mutable telemetry (Load,
131
-// LoadScore, LastUpdated) and the Signature itself are deliberately excluded
132
-// so that observers may update telemetry without invalidating the signature.
133
-//
134
-// All slice fields are normalized to non-nil to keep encoding deterministic
135
-// (json.Marshal encodes nil slices as `null` and empty slices as `[]`). Time
136
-// fields are encoded as Unix nanoseconds to avoid any RFC3339 round-trip
137
-// ambiguity.
138
-type canonicalRelayDescriptor struct {
139
- Name string `json:"name"`
140
- Address string `json:"address"`
141
- RelayID string `json:"relay_id"`
142
- OwnerAddress string `json:"owner_address"`
143
- Version uint32 `json:"version"`
144
- IssuedAtUnixNano int64 `json:"issued_at_unix_nano"`
145
- ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
146
- APIHTTPSAddr string `json:"api_https_addr"`
147
- IngressTLSAddr string `json:"ingress_tls_addr"`
148
- WireGuardPublicKey string `json:"wireguard_public_key"`
149
- WireGuardEndpoint string `json:"wireguard_endpoint"`
150
- OverlayIPv4 string `json:"overlay_ipv4"`
151
- OverlayCIDRs []string `json:"overlay_cidrs"`
152
- Discovery bool `json:"discovery"`
153
- SupportsUDP bool `json:"supports_udp"`
154
- SupportsTCP bool `json:"supports_tcp"`
155
- SupportsOverlayPeer bool `json:"supports_overlay_peer"`
156
-}
157
-
128
// CanonicalBytes returns the deterministic byte representation of a relay
129
// descriptor used for signing and signature verification. Two descriptors
130
// that differ only in mutable telemetry fields produce identical bytes.
@@ -166,7 +136,25 @@ func CanonicalBytes(desc RelayDescriptor) ([]byte, error) {
136
if overlayCIDRs == nil {
137
overlayCIDRs = []string{}
138
}
169
- canonical := canonicalRelayDescriptor{
139
+ canonical := struct {
140
+ Name string `json:"name"`
141
+ Address string `json:"address"`
142
+ RelayID string `json:"relay_id"`
143
+ OwnerAddress string `json:"owner_address"`
144
+ Version uint32 `json:"version"`
145
+ IssuedAtUnixNano int64 `json:"issued_at_unix_nano"`
146
+ ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
147
+ APIHTTPSAddr string `json:"api_https_addr"`
148
+ IngressTLSAddr string `json:"ingress_tls_addr"`
149
+ WireGuardPublicKey string `json:"wireguard_public_key"`
150
+ WireGuardEndpoint string `json:"wireguard_endpoint"`
151
+ OverlayIPv4 string `json:"overlay_ipv4"`
152
+ OverlayCIDRs []string `json:"overlay_cidrs"`
153
+ Discovery bool `json:"discovery"`
154
+ SupportsUDP bool `json:"supports_udp"`
155
+ SupportsTCP bool `json:"supports_tcp"`
156
+ SupportsOverlayPeer bool `json:"supports_overlay_peer"`
157
+ }{
158
Name: desc.Name,
159
Address: desc.Address,
160
RelayID: desc.RelayID,