feat: Implement multi-hop support in the overlay and API client

Kim committed Apr 16, 2026 at 20:16 UTC a3af94e06a1e8fd9eb537fdd080cb417a8b0d07c
16 files changed +789 -803
portal/api_server.go
+122 -76
@@ -137,6 +137,18 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
137 })
138 }
139
140 +func (s *Server) hopRegistryHandler() http.Handler {
141 + apiHandler := s.apiHandler(nil, nil)
142 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143 + switch strings.TrimSpace(r.URL.Path) {
144 + case types.PathSDKRegisterChallenge, types.PathSDKRegister, types.PathSDKRenew, types.PathSDKUnregister:
145 + apiHandler.ServeHTTP(w, r)
146 + default:
147 + utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeInvalidRequest, "unsupported hop registry path")
148 + }
149 + })
150 +}
151 +
152 func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
153 utils.WriteAPIData(w, http.StatusOK, map[string]any{
154 "service": "portal-relay",
@@ -329,6 +341,30 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
341 if !ok {
342 return
343 }
344 + if req.Hop != nil {
345 + hop := req.Hop
346 + req.Hop = hop.Next
347 + status, body, err := s.forwardHopRegistry(r.Context(), hop, types.PathSDKRegister, req)
348 + if err != nil {
349 + writeAPIErrorResponse(w, err)
350 + return
351 + }
352 + if status >= http.StatusOK && status < http.StatusMultipleChoices {
353 + var resp struct {
354 + ExpiresAt time.Time `json:"expires_at"`
355 + }
356 + if err := utils.DecodeAPIData(body, &resp); err != nil {
357 + utils.WriteAPIError(w, http.StatusBadGateway, types.APIErrorCodeInvalidRequest, err.Error())
358 + return
359 + }
360 + if err := s.registry.RegisterHopRoute(hop, resp.ExpiresAt, time.Now()); err != nil {
361 + writeAPIErrorResponse(w, err)
362 + return
363 + }
364 + }
365 + utils.WriteRawAPIResponse(w, status, body)
366 + return
367 + }
368
369 challenge, err := s.registry.consumeVerifiedRegisterChallenge(req)
370 if err != nil {
@@ -367,6 +403,17 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
403 if !ok {
404 return
405 }
406 + if req.Hop != nil {
407 + hop := req.Hop
408 + req.Hop = hop.Next
409 + status, body, err := s.forwardHopRegistry(r.Context(), hop, types.PathSDKRegisterChallenge, req)
410 + if err != nil {
411 + writeAPIErrorResponse(w, err)
412 + return
413 + }
414 + utils.WriteRawAPIResponse(w, status, body)
415 + return
416 + }
417
418 scheme := "https"
419 if r.TLS == nil {
@@ -382,35 +429,11 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
429 Path: types.PathSDKRegister,
430 }).String()
431
385 - if len(req.MultiHop) > 0 && (s.hops == nil || s.overlay == nil || s.relaySet == nil) {
432 + req.HopToken = strings.TrimSpace(req.HopToken)
433 + if req.HopToken != "" && s.hopMux == nil {
434 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
435 return
436 }
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 - }
437 if req.UDPEnabled && (!s.cfg.UDPEnabled || s.group != nil && s.quicTunnel == nil) {
438 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
439 return
@@ -443,6 +466,30 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
466 if !ok {
467 return
468 }
469 + if req.Hop != nil {
470 + hop := req.Hop
471 + req.Hop = hop.Next
472 + status, body, err := s.forwardHopRegistry(r.Context(), hop, types.PathSDKRenew, req)
473 + if err != nil {
474 + writeAPIErrorResponse(w, err)
475 + return
476 + }
477 + if status >= http.StatusOK && status < http.StatusMultipleChoices {
478 + var resp struct {
479 + ExpiresAt time.Time `json:"expires_at"`
480 + }
481 + if err := utils.DecodeAPIData(body, &resp); err != nil {
482 + utils.WriteAPIError(w, http.StatusBadGateway, types.APIErrorCodeInvalidRequest, err.Error())
483 + return
484 + }
485 + if err := s.registry.RegisterHopRoute(hop, resp.ExpiresAt, time.Now()); err != nil {
486 + writeAPIErrorResponse(w, err)
487 + return
488 + }
489 + }
490 + utils.WriteRawAPIResponse(w, status, body)
491 + return
492 + }
493
494 claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, time.Now().UTC())
495 if err != nil {
@@ -459,10 +506,6 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
506 writeAPIErrorResponse(w, err)
507 return
508 }
462 - if err := s.hops.renew(record); err != nil {
463 - utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, err.Error())
464 - return
465 - }
509 nextAccessToken, _, err := auth.IssueLeaseAccessToken(s.identity.PrivateKey, s.identity.Address, s.cfg.PortalURL, record.Copy(), ttl)
510 if err != nil {
511 utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
@@ -484,6 +527,20 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
527 if !ok {
528 return
529 }
530 + if req.Hop != nil {
531 + hop := req.Hop
532 + req.Hop = hop.Next
533 + status, body, err := s.forwardHopRegistry(r.Context(), hop, types.PathSDKUnregister, req)
534 + if err != nil {
535 + writeAPIErrorResponse(w, err)
536 + return
537 + }
538 + if status >= http.StatusOK && status < http.StatusMultipleChoices {
539 + s.registry.DeleteHopRoute(hop)
540 + }
541 + utils.WriteRawAPIResponse(w, status, body)
542 + return
543 + }
544 claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, time.Now().UTC())
545 if err != nil {
546 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
@@ -495,7 +552,6 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
552 writeAPIErrorResponse(w, err)
553 return
554 }
498 - s.hops.delete(record)
555 if record.isDirect() && s.acmeManager != nil {
556 deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
557 if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
@@ -512,6 +568,37 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
568 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
569 }
570
571 +func (s *Server) forwardHopRegistry(ctx context.Context, route *types.HopRoute, path string, payload any) (int, []byte, error) {
572 + if s.hopMux == nil || s.overlay == nil || s.relaySet == nil {
573 + return 0, nil, errFeatureUnavailable
574 + }
575 + if route == nil {
576 + return 0, nil, errors.New("hop route is required")
577 + }
578 + forwardRelay, err := auth.VerifyRelayDescriptor(route.ForwardRelay)
579 + if err != nil {
580 + return 0, nil, fmt.Errorf("forward relay: %w", err)
581 + }
582 + if !forwardRelay.SupportsOverlayPeer ||
583 + strings.TrimSpace(forwardRelay.WireGuardPublicKey) == "" ||
584 + strings.TrimSpace(forwardRelay.WireGuardEndpoint) == "" ||
585 + strings.TrimSpace(forwardRelay.OverlayIPv4) == "" {
586 + return 0, nil, errors.New("forward relay wireguard overlay metadata is required")
587 + }
588 + if err := s.relaySet.InsertAnnounced(forwardRelay, time.Now().UTC()); err != nil {
589 + return 0, nil, fmt.Errorf("forward relay: %w", err)
590 + }
591 + if err := s.overlay.Sync(s.relaySet.OverlayPeerStates()); err != nil {
592 + return 0, nil, err
593 + }
594 +
595 + status, respBody, err := s.hopMux.OpenRegistry(ctx, forwardRelay.OverlayIPv4, path, payload)
596 + if err != nil {
597 + return 0, nil, &apiError{types.APIErrorCodeFeatureUnavailable, err.Error(), http.StatusBadGateway}
598 + }
599 + return status, respBody, nil
600 +}
601 +
602 func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
603 if !utils.RequireMethod(w, r, http.MethodGet) {
604 return
@@ -695,16 +782,10 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
782 }
783 issuedAt := claims.IssuedAt.Time().UTC()
784 expiresAt := claims.Expiry.Time().UTC()
698 - if len(req.MultiHop) > 0 && (s.hops == nil || s.overlay == nil || s.relaySet == nil) {
785 + req.HopToken = strings.TrimSpace(req.HopToken)
786 + if req.HopToken != "" && s.hopMux == nil {
787 return types.RegisterResponse{}, errFeatureUnavailable
788 }
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 - }
789 identityKey := identity.Key()
790 stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
791 record := &leaseRecord{
@@ -718,14 +799,9 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
799 ReportedIP: utils.SanitizeReportedIP(reportedIP),
800 UDPEnabled: req.UDPEnabled,
801 TCPEnabled: req.TCPEnabled,
721 - multiHop: multiHop,
802 + hopToken: req.HopToken,
803 stream: stream,
804 }
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 - }
805 if req.UDPEnabled {
806 if s.udpPorts == nil {
807 return types.RegisterResponse{}, errors.New("udp port allocation not available")
@@ -759,34 +835,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
835 return types.RegisterResponse{}, err
836 }
837
762 - replaced, err := s.registry.Register(record)
763 - if err != nil {
764 - record.Close()
765 - return types.RegisterResponse{}, err
766 - }
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())
838 + if err := s.registry.Register(record); err != nil {
839 record.Close()
840 return types.RegisterResponse{}, err
841 }
@@ -808,9 +857,6 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
857 UDPEnabled: record.UDPEnabled,
858 TCPEnabled: record.TCPEnabled,
859 }
811 - if !record.isDirect() {
812 - resp.KeylessURL = record.multiHop[0].APIHTTPSAddr
813 - }
860 if record.datagram != nil {
861 resp.SNIPort = s.cfg.SNIPort
862 resp.UDPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.datagram.UDPPort())
portal/auth/register_challenge.go
+1 -6
@@ -48,18 +48,13 @@ 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 -
51 normalizedRequest := types.RegisterChallengeRequest{
52 Identity: normalizedIdentity,
53 Metadata: req.Metadata.Copy(),
54 TTL: req.TTL,
55 UDPEnabled: req.UDPEnabled,
56 TCPEnabled: req.TCPEnabled,
62 - MultiHop: multiHop,
57 + HopToken: strings.TrimSpace(req.HopToken),
58 }
59
60 return &RegisterChallenge{
portal/hop.go deleted
-436
@@ -1,436 +0,0 @@
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
+173 -41
@@ -18,8 +18,9 @@ import (
18 const defaultRegisterChallengeTTL = 2 * time.Minute
19
20 type leaseRegistry struct {
21 - leasesByHostname map[string]string
21 leasesByKey map[string]*leaseRecord
22 + recordsByHostname map[string]*leaseRecord
23 + recordsByHopToken map[string]*leaseRecord
24 registerChallenges map[string]*auth.RegisterChallenge
25 policy *policy.Runtime
26 mu sync.RWMutex
@@ -32,8 +33,9 @@ func newLeaseRegistry(udpEnabled, tcpPortEnabled bool, trustProxyHeaders bool, r
33 }
34
35 return &leaseRegistry{
35 - leasesByHostname: make(map[string]string),
36 leasesByKey: make(map[string]*leaseRecord),
37 + recordsByHostname: make(map[string]*leaseRecord),
38 + recordsByHopToken: make(map[string]*leaseRecord),
39 registerChallenges: make(map[string]*auth.RegisterChallenge),
40 policy: runtime,
41 }, nil
@@ -48,8 +50,9 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
50 out = append(out, record)
51 r.policy.ForgetIdentity(record.Key())
52 }
51 - r.leasesByHostname = make(map[string]string)
53 r.leasesByKey = make(map[string]*leaseRecord)
54 + r.recordsByHostname = make(map[string]*leaseRecord)
55 + r.recordsByHopToken = make(map[string]*leaseRecord)
56 r.registerChallenges = make(map[string]*auth.RegisterChallenge)
57 return out
58 }
@@ -63,54 +66,66 @@ func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
66 r.mu.RLock()
67 defer r.mu.RUnlock()
68
66 - key, ok := r.leasesByHostname[host]
67 - if !ok {
69 + record := r.lookupLocked(host)
70 + return record, record != nil
71 +}
72 +
73 +func (r *leaseRegistry) lookupLocked(host string) *leaseRecord {
74 + record := r.recordsByHostname[host]
75 + if record == nil {
76 parts := strings.Split(host, ".")
77 if len(parts) < 3 {
70 - return nil, false
71 - }
72 - key, ok = r.leasesByHostname["*."+strings.Join(parts[1:], ".")]
73 - if !ok {
74 - return nil, false
78 + return nil
79 }
80 + record = r.recordsByHostname["*."+strings.Join(parts[1:], ".")]
81 }
77 - record, ok := r.leasesByKey[key]
78 - return record, ok && record != nil
82 + return record
83 }
84
81 -func (r *leaseRegistry) Register(record *leaseRecord) (*leaseRecord, error) {
85 +func (r *leaseRegistry) Register(record *leaseRecord) error {
86 if record == nil {
83 - return nil, errors.New("lease record is required")
87 + return errors.New("lease record is required")
88 }
89
90 key := record.Key()
91 if key == "" {
88 - return nil, errors.New("lease identity is required")
92 + return errors.New("lease identity is required")
93 }
94 hostname := utils.NormalizeHostname(record.Hostname)
95 if hostname == "" {
92 - return nil, errors.New("lease hostname is required")
96 + return errors.New("lease hostname is required")
97 }
98 + record.hopToken = strings.TrimSpace(record.hopToken)
99
100 r.mu.Lock()
101
97 - if existingKey, ok := r.leasesByHostname[hostname]; ok && existingKey != key {
98 - r.mu.Unlock()
99 - return nil, errHostnameConflict
102 + now := time.Now()
103 + if record.isDirect() {
104 + if existing := r.recordsByHostname[hostname]; existing != nil && existing.Key() != key && now.Before(existing.ExpiresAt) {
105 + r.mu.Unlock()
106 + return errHostnameConflict
107 + }
108 + }
109 + if record.hopToken != "" {
110 + if existing := r.recordsByHopToken[record.hopToken]; existing != nil && existing.Key() != key && now.Before(existing.ExpiresAt) {
111 + r.mu.Unlock()
112 + return errors.New("hop token conflict")
113 + }
114 }
115
116 var replaced *leaseRecord
117 if existing, ok := r.leasesByKey[key]; ok && existing != nil {
118 replaced = existing
105 - if existing != record && existing.isDirect() {
106 - delete(r.leasesByHostname, utils.NormalizeHostname(existing.Hostname))
107 - }
119 + r.deleteIndexesLocked(existing)
120 r.policy.ForgetIdentity(existing.Key())
121 }
122 record.Hostname = hostname
123 r.leasesByKey[key] = record
124 if record.isDirect() {
113 - r.leasesByHostname[hostname] = key
125 + r.recordsByHostname[hostname] = record
126 + }
127 + if record.hopToken != "" {
128 + r.recordsByHopToken[record.hopToken] = record
129 }
130 r.policy.IPFilter().RegisterIdentityIP(key, record.ClientIP)
131 r.mu.Unlock()
@@ -118,7 +133,7 @@ func (r *leaseRegistry) Register(record *leaseRecord) (*leaseRecord, error) {
133 if replaced != nil && replaced != record {
134 replaced.Close()
135 }
121 - return replaced, nil
136 + return nil
137 }
138
139 func (r *leaseRegistry) Renew(identity types.Identity, ttl time.Duration, clientIP, reportedIP string) (*leaseRecord, error) {
@@ -155,9 +170,7 @@ func (r *leaseRegistry) Unregister(identity types.Identity) (*leaseRecord, error
170 }
171
172 delete(r.leasesByKey, key)
158 - if record.isDirect() {
159 - delete(r.leasesByHostname, utils.NormalizeHostname(record.Hostname))
160 - }
173 + r.deleteIndexesLocked(record)
174 r.policy.ForgetIdentity(key)
175 return record, nil
176 }
@@ -178,8 +191,94 @@ func (r *leaseRegistry) RecordByKey(key string, now time.Time) (*leaseRecord, bo
191 return record, true
192 }
193
194 +func (r *leaseRegistry) RecordByHopToken(token string, now time.Time) (*leaseRecord, bool) {
195 + token = strings.TrimSpace(token)
196 + if token == "" {
197 + return nil, false
198 + }
199 +
200 + r.mu.RLock()
201 + defer r.mu.RUnlock()
202 +
203 + record := r.recordsByHopToken[token]
204 + if record != nil && now.Before(record.ExpiresAt) {
205 + return record, true
206 + }
207 + return nil, false
208 +}
209 +
210 +func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, expiresAt, now time.Time) error {
211 + if route == nil {
212 + return errors.New("hop route is required")
213 + }
214 + matchHostname := utils.NormalizeHostname(route.MatchHostname)
215 + matchToken := strings.TrimSpace(route.MatchToken)
216 + overlayIPv4 := strings.TrimSpace(route.ForwardRelay.OverlayIPv4)
217 + forwardToken := strings.TrimSpace(route.ForwardToken)
218 + expiresAt = expiresAt.UTC()
219 +
220 + switch {
221 + case r == nil:
222 + return errFeatureUnavailable
223 + case !expiresAt.After(now):
224 + return errors.New("route expiry must be in the future")
225 + case matchHostname == "" && matchToken == "":
226 + return errors.New("hostname or token matcher is required")
227 + case matchHostname != "" && matchToken != "":
228 + return errors.New("hostname and token matchers are mutually exclusive")
229 + case overlayIPv4 == "":
230 + return errors.New("forward overlay ipv4 is required")
231 + case forwardToken == "":
232 + return errors.New("forward token is required")
233 + }
234 +
235 + r.mu.Lock()
236 + defer r.mu.Unlock()
237 +
238 + record := &leaseRecord{
239 + Hostname: matchHostname,
240 + ExpiresAt: expiresAt,
241 + hopToken: matchToken,
242 + hopNextOverlayIPv4: overlayIPv4,
243 + hopNextToken: forwardToken,
244 + }
245 + if matchHostname != "" {
246 + if existing := r.lookupLocked(matchHostname); existing != nil && !existing.isHopForward() && now.Before(existing.ExpiresAt) {
247 + return errHostnameConflict
248 + }
249 + r.recordsByHostname[matchHostname] = record
250 + return nil
251 + }
252 + if existing := r.recordsByHopToken[matchToken]; existing != nil && !existing.isHopForward() && now.Before(existing.ExpiresAt) {
253 + return errors.New("hop token conflict")
254 + }
255 + r.recordsByHopToken[matchToken] = record
256 + return nil
257 +}
258 +
259 +func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) {
260 + if r == nil || route == nil {
261 + return
262 + }
263 + hostname := utils.NormalizeHostname(route.MatchHostname)
264 + token := strings.TrimSpace(route.MatchToken)
265 +
266 + r.mu.Lock()
267 + if hostname != "" {
268 + if record := r.recordsByHostname[hostname]; record != nil && record.isHopForward() {
269 + delete(r.recordsByHostname, hostname)
270 + }
271 + }
272 + if token != "" {
273 + if record := r.recordsByHopToken[token]; record != nil && record.isHopForward() {
274 + delete(r.recordsByHopToken, token)
275 + }
276 + }
277 + r.mu.Unlock()
278 +}
279 +
280 func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string) (types.RegisterChallengeResponse, error) {
182 - if len(req.MultiHop) > 0 && (req.UDPEnabled || req.TCPEnabled) {
281 + if strings.TrimSpace(req.HopToken) != "" && (req.UDPEnabled || req.TCPEnabled) {
282 return types.RegisterChallengeResponse{}, errTransportMismatch
283 }
284 if req.UDPEnabled {
@@ -266,9 +365,7 @@ func (r *leaseRegistry) cleanupExpired(now time.Time) []*leaseRecord {
365 if now.After(record.ExpiresAt) {
366 expired = append(expired, record)
367 delete(r.leasesByKey, key)
269 - if record.isDirect() {
270 - delete(r.leasesByHostname, utils.NormalizeHostname(record.Hostname))
271 - }
368 + r.deleteIndexesLocked(record)
369 r.policy.ForgetIdentity(key)
370 }
371 }
@@ -277,6 +374,16 @@ func (r *leaseRegistry) cleanupExpired(now time.Time) []*leaseRecord {
374 delete(r.registerChallenges, challengeID)
375 }
376 }
377 + for hostname, record := range r.recordsByHostname {
378 + if record != nil && !now.Before(record.ExpiresAt) {
379 + delete(r.recordsByHostname, hostname)
380 + }
381 + }
382 + for token, record := range r.recordsByHopToken {
383 + if record != nil && !now.Before(record.ExpiresAt) {
384 + delete(r.recordsByHopToken, token)
385 + }
386 + }
387 return expired
388 }
389
@@ -378,19 +485,44 @@ type leaseRecord struct {
485 UDPEnabled bool
486 TCPEnabled bool
487 Metadata types.LeaseMetadata
381 - multiHop []types.RelayDescriptor
382 - hopID string
383 - datagram *transport.RelayDatagram
384 - udpPorts *transport.PortAllocator
385 - tcpPort *transport.RelayTCPPort
386 - tcpPorts *transport.PortAllocator
387 - stream *transport.RelayStream
388 - startErr error
389 - startOnce sync.Once
488 +
489 + hopToken string
490 + hopNextOverlayIPv4 string
491 + hopNextToken string
492 +
493 + datagram *transport.RelayDatagram
494 + udpPorts *transport.PortAllocator
495 + tcpPort *transport.RelayTCPPort
496 + tcpPorts *transport.PortAllocator
497 + stream *transport.RelayStream
498 + startErr error
499 + startOnce sync.Once
500 }
501
502 func (r *leaseRecord) isDirect() bool {
393 - return len(r.multiHop) == 0
503 + return r == nil || (strings.TrimSpace(r.hopToken) == "" && !r.isHopForward())
504 +}
505 +
506 +func (r *leaseRecord) isHopForward() bool {
507 + return r != nil && (strings.TrimSpace(r.hopNextOverlayIPv4) != "" || strings.TrimSpace(r.hopNextToken) != "")
508 +}
509 +
510 +func (r *leaseRegistry) deleteIndexesLocked(record *leaseRecord) {
511 + if record == nil {
512 + return
513 + }
514 + hostname := utils.NormalizeHostname(record.Hostname)
515 + if hostname != "" {
516 + if r.recordsByHostname[hostname] == record {
517 + delete(r.recordsByHostname, hostname)
518 + }
519 + }
520 + token := strings.TrimSpace(record.hopToken)
521 + if token != "" {
522 + if r.recordsByHopToken[token] == record {
523 + delete(r.recordsByHopToken, token)
524 + }
525 + }
526 }
527
528 func (r *leaseRegistry) AdminSnapshot(record *leaseRecord) types.AdminLease {
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
+174 -117
@@ -1,6 +1,7 @@
1 package overlay
2
3 import (
4 + "bytes"
5 "context"
6 "encoding/binary"
7 "encoding/json"
@@ -8,6 +9,7 @@ import (
9 "fmt"
10 "io"
11 "net"
12 + "net/http"
13 "strings"
14 "sync"
15 "time"
@@ -17,32 +19,27 @@ import (
19 )
20
21 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
22 + hopProtocolVersion = 1
23 + hopPrefaceLimit = 4 << 10
24 + hopRegistryFrameLimit = 8 << 20
25 + hopIncomingBuffer = 128
26 + defaultPrefaceTimeout = 2 * time.Second
27 )
28
29 const (
30 hopModeTLSStream = "tls-stream"
30 - hopModeControl = "control"
31 -
32 - HopControlInstall = "install"
33 - HopControlRenew = "renew"
34 - HopControlDelete = "delete"
31 + hopModeRegistry = "registry"
32 )
33
34 type HopMux struct {
35 listener net.Listener
36 overlay *Overlay
37 incoming chan HopStream
38 + registry chan HopRegistryStream
39
40 mu sync.Mutex
43 - sessions map[string]*yamux.Session
44 - active map[*yamux.Session]struct{}
45 - closed bool
41 + outbound map[string]*yamux.Session
42 + done chan struct{}
43 }
44
45 type hopPreface struct {
@@ -51,29 +48,31 @@ type hopPreface struct {
48 Token string `json:"token"`
49 }
50
54 -type HopControl struct {
55 - Action string `json:"action"`
56 - Route HopRouteSpec `json:"route"`
51 +type HopStream struct {
52 + Conn net.Conn
53 + Token string
54 }
55
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"`
56 +type HopRegistryStream struct {
57 + Path string
58 + Body []byte
59 + conn net.Conn
60 }
61
68 -type hopControlResponse struct {
69 - OK bool `json:"ok"`
70 - Error string `json:"error,omitempty"`
62 +type hopRegistryHTTPResponse struct {
63 + header http.Header
64 + status int
65 + body bytes.Buffer
66 }
67
73 -type HopStream struct {
74 - Conn net.Conn
75 - Token string
76 - Control *HopControl
68 +type hopRegistryRequest struct {
69 + Path string `json:"path"`
70 + Body []byte `json:"body"`
71 +}
72 +
73 +type hopRegistryResponse struct {
74 + Status int `json:"status"`
75 + Body []byte `json:"body"`
76 }
77
78 func NewHopMux(overlay *Overlay) (*HopMux, error) {
@@ -88,8 +87,9 @@ func NewHopMux(overlay *Overlay) (*HopMux, error) {
87 listener: listener,
88 overlay: overlay,
89 incoming: make(chan HopStream, hopIncomingBuffer),
91 - sessions: make(map[string]*yamux.Session),
92 - active: make(map[*yamux.Session]struct{}),
90 + registry: make(chan HopRegistryStream, hopIncomingBuffer),
91 + outbound: make(map[string]*yamux.Session),
92 + done: make(chan struct{}),
93 }, nil
94 }
95
@@ -132,35 +132,43 @@ func (m *HopMux) Accept(ctx context.Context) (HopStream, error) {
132 }
133 }
134
135 +func (m *HopMux) AcceptRegistry(ctx context.Context) (HopRegistryStream, error) {
136 + if m == nil {
137 + <-ctx.Done()
138 + return HopRegistryStream{}, ctx.Err()
139 + }
140 + select {
141 + case stream := <-m.registry:
142 + return stream, nil
143 + case <-ctx.Done():
144 + return HopRegistryStream{}, ctx.Err()
145 + }
146 +}
147 +
148 func (m *HopMux) Close() error {
149 if m == nil {
150 return nil
151 }
152
153 m.mu.Lock()
141 - if m.closed {
154 + if m.done == nil {
155 + m.done = make(chan struct{})
156 + }
157 + select {
158 + case <-m.done:
159 m.mu.Unlock()
160 return nil
161 + default:
162 }
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 {
163 + close(m.done)
164 + sessions := make([]*yamux.Session, 0, len(m.outbound))
165 + for _, session := range m.outbound {
166 + if session == nil {
167 continue
168 }
159 - seen[session] = struct{}{}
169 sessions = append(sessions, session)
170 }
162 - m.sessions = make(map[string]*yamux.Session)
163 - m.active = make(map[*yamux.Session]struct{})
171 + m.outbound = make(map[string]*yamux.Session)
172 listener := m.listener
173 m.mu.Unlock()
174
@@ -196,45 +204,38 @@ func (m *HopMux) OpenStream(ctx context.Context, overlayIPv4, token string) (net
204 return stream, nil
205 }
206
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")
207 +func (m *HopMux) OpenRegistry(ctx context.Context, overlayIPv4, path string, payload any) (int, []byte, error) {
208 + path = strings.TrimSpace(path)
209 + if path == "" {
210 + return 0, nil, errors.New("registry path is required")
211 + }
212 + body, err := json.Marshal(payload)
213 + if err != nil {
214 + return 0, nil, err
215 }
216
217 stream, err := m.openYamuxStream(ctx, overlayIPv4)
218 if err != nil {
207 - return err
219 + return 0, nil, err
220 }
221 defer stream.Close()
222
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 -
223 preface := hopPreface{
224 Version: hopProtocolVersion,
219 - Mode: hopModeControl,
225 + Mode: hopModeRegistry,
226 }
227 if err := writeFramedJSON(stream, preface, hopPrefaceLimit); err != nil {
222 - return err
228 + return 0, nil, err
229 }
224 - if err := writeFramedJSON(stream, req, hopControlPayloadLimit); err != nil {
225 - return err
230 + if err := writeFramedJSON(stream, hopRegistryRequest{Path: path, Body: body}, hopRegistryFrameLimit); err != nil {
231 + return 0, nil, err
232 }
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)
233 +
234 + var resp hopRegistryResponse
235 + if err := readFramedJSON(stream, &resp, hopRegistryFrameLimit); err != nil {
236 + return 0, nil, err
237 }
237 - return nil
238 + return resp.Status, resp.Body, nil
239 }
240
241 func (m *HopMux) openYamuxStream(ctx context.Context, overlayIPv4 string) (*yamux.Stream, error) {
@@ -258,18 +259,19 @@ func (m *HopMux) openYamuxStream(ctx context.Context, overlayIPv4 string) (*yamu
259
260 func (m *HopMux) session(ctx context.Context, overlayIPv4 string) (*yamux.Session, error) {
261 m.mu.Lock()
261 - if m.closed {
262 + select {
263 + case <-m.done:
264 m.mu.Unlock()
265 return nil, net.ErrClosed
266 + default:
267 }
265 - if session := m.sessions[overlayIPv4]; session != nil {
268 + if session := m.outbound[overlayIPv4]; session != nil {
269 if !session.IsClosed() {
270 m.mu.Unlock()
271 return session, nil
272 }
270 - delete(m.active, session)
273 }
272 - delete(m.sessions, overlayIPv4)
274 + delete(m.outbound, overlayIPv4)
275 m.mu.Unlock()
276
277 if m.overlay == nil || m.overlay.stack == nil {
@@ -288,18 +290,19 @@ func (m *HopMux) session(ctx context.Context, overlayIPv4 string) (*yamux.Sessio
290 }
291
292 m.mu.Lock()
291 - if m.closed {
293 + select {
294 + case <-m.done:
295 m.mu.Unlock()
296 _ = session.Close()
297 return nil, net.ErrClosed
298 + default:
299 }
296 - if current := m.sessions[overlayIPv4]; current != nil && !current.IsClosed() {
300 + if current := m.outbound[overlayIPv4]; current != nil && !current.IsClosed() {
301 m.mu.Unlock()
302 _ = session.Close()
303 return current, nil
304 }
301 - m.sessions[overlayIPv4] = session
302 - m.active[session] = struct{}{}
305 + m.outbound[overlayIPv4] = session
306 m.mu.Unlock()
307 return session, nil
308 }
@@ -307,10 +310,9 @@ func (m *HopMux) session(ctx context.Context, overlayIPv4 string) (*yamux.Sessio
310 func (m *HopMux) forgetSession(overlayIPv4 string, session *yamux.Session) {
311 m.mu.Lock()
312 defer m.mu.Unlock()
310 - if m.sessions[overlayIPv4] == session {
311 - delete(m.sessions, overlayIPv4)
313 + if m.outbound[overlayIPv4] == session {
314 + delete(m.outbound, overlayIPv4)
315 }
313 - delete(m.active, session)
316 }
317
318 func (m *HopMux) serveSession(ctx context.Context, conn net.Conn) {
@@ -321,17 +323,27 @@ func (m *HopMux) serveSession(ctx context.Context, conn net.Conn) {
323 return
324 }
325 m.mu.Lock()
324 - if m.closed {
326 + select {
327 + case <-m.done:
328 m.mu.Unlock()
329 _ = session.Close()
330 return
331 + default:
332 }
329 - m.active[session] = struct{}{}
333 + done := m.done
334 m.mu.Unlock()
335 + sessionDone := make(chan struct{})
336 + go func() {
337 + select {
338 + case <-ctx.Done():
339 + _ = session.Close()
340 + case <-done:
341 + _ = session.Close()
342 + case <-sessionDone:
343 + }
344 + }()
345 defer func() {
332 - m.mu.Lock()
333 - delete(m.active, session)
334 - m.mu.Unlock()
346 + close(sessionDone)
347 _ = session.Close()
348 }()
349
@@ -367,31 +379,26 @@ func (m *HopMux) handleStream(ctx context.Context, stream *yamux.Stream) {
379 Conn: stream,
380 Token: preface.Token,
381 })
370 - case hopModeControl:
371 - m.readControlStream(ctx, stream)
382 + case hopModeRegistry:
383 + var req hopRegistryRequest
384 + if err := readFramedJSON(stream, &req, hopRegistryFrameLimit); err != nil {
385 + _ = stream.Close()
386 + return
387 + }
388 + if strings.TrimSpace(req.Path) == "" {
389 + _ = stream.Close()
390 + return
391 + }
392 + m.deliverRegistry(ctx, HopRegistryStream{
393 + Path: req.Path,
394 + Body: req.Body,
395 + conn: stream,
396 + })
397 default:
398 _ = stream.Close()
399 }
400 }
401
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 -
402 func (m *HopMux) deliver(ctx context.Context, stream HopStream) {
403 select {
404 case m.incoming <- stream:
@@ -400,16 +407,66 @@ func (m *HopMux) deliver(ctx context.Context, stream HopStream) {
407 }
408 }
409
403 -func (s HopStream) Respond(err error) error {
404 - if s.Conn == nil {
405 - return nil
410 +func (m *HopMux) deliverRegistry(ctx context.Context, stream HopRegistryStream) {
411 + select {
412 + case m.registry <- stream:
413 + case <-ctx.Done():
414 + _ = stream.conn.Close()
415 }
407 - defer s.Conn.Close()
408 - resp := hopControlResponse{OK: err == nil}
416 +}
417 +
418 +func (s HopRegistryStream) Respond(status int, body []byte) error {
419 + if s.conn == nil {
420 + return net.ErrClosed
421 + }
422 + defer s.conn.Close()
423 + return writeFramedJSON(s.conn, hopRegistryResponse{Status: status, Body: body}, hopRegistryFrameLimit)
424 +}
425 +
426 +func (s HopRegistryStream) ServeRegistryHTTP(ctx context.Context, host string, handler http.Handler) error {
427 + if handler == nil {
428 + if s.conn != nil {
429 + _ = s.conn.Close()
430 + }
431 + return errors.New("hop registry handler is required")
432 + }
433 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+strings.TrimSpace(host)+s.Path, bytes.NewReader(s.Body))
434 if err != nil {
410 - resp.Error = err.Error()
435 + if s.conn != nil {
436 + _ = s.conn.Close()
437 + }
438 + return err
439 + }
440 + req.RemoteAddr = "127.0.0.1:0"
441 + req.Header.Set("Content-Type", "application/json")
442 +
443 + resp := &hopRegistryHTTPResponse{header: make(http.Header)}
444 + handler.ServeHTTP(resp, req)
445 + return s.Respond(resp.Status(), resp.body.Bytes())
446 +}
447 +
448 +func (r *hopRegistryHTTPResponse) Header() http.Header {
449 + return r.header
450 +}
451 +
452 +func (r *hopRegistryHTTPResponse) Write(body []byte) (int, error) {
453 + if r.status == 0 {
454 + r.status = http.StatusOK
455 + }
456 + return r.body.Write(body)
457 +}
458 +
459 +func (r *hopRegistryHTTPResponse) WriteHeader(status int) {
460 + if r.status == 0 {
461 + r.status = status
462 + }
463 +}
464 +
465 +func (r *hopRegistryHTTPResponse) Status() int {
466 + if r.status == 0 {
467 + return http.StatusOK
468 }
412 - return writeFramedJSON(s.Conn, resp, hopControlPayloadLimit)
469 + return r.status
470 }
471
472 func hopYamuxConfig() *yamux.Config {
portal/overlay/stack.go
+11 -1
@@ -36,6 +36,7 @@ type stack struct {
36 mu sync.Mutex
37 closed bool
38 peerEndpoints map[string]string
39 + peerConfig string
40 }
41
42 func newStack(cfg Config) (*stack, error) {
@@ -187,11 +188,20 @@ func (s *stack) ApplyPeers(peers []desiredPeer) error {
188 }
189 }
190
190 - if err := s.device.IpcSet(builder.String()); err != nil {
191 + config := builder.String()
192 + s.mu.Lock()
193 + if s.peerConfig == config {
194 + s.mu.Unlock()
195 + return warnErr
196 + }
197 + s.mu.Unlock()
198 +
199 + if err := s.device.IpcSet(config); err != nil {
200 return err
201 }
202 s.mu.Lock()
203 s.peerEndpoints = nextPeerEndpoints
204 + s.peerConfig = config
205 s.mu.Unlock()
206 return warnErr
207 }
portal/server.go
+89 -27
@@ -117,10 +117,10 @@ type Server struct {
117 quicTunnel *quic.Listener
118
119 overlay *overlay.Overlay
120 + hopMux *overlay.HopMux
121 relaySet *discovery.RelaySet
122 announceLimiter *discovery.AnnounceLimiter
123 registry *leaseRegistry
123 - hops *hopManager
124 udpPorts *transport.PortAllocator
125 tcpPorts *transport.PortAllocator
126 }
@@ -175,7 +175,7 @@ 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
178 - var hops *hopManager
178 + var hopMux *overlay.HopMux
179 var ov *overlay.Overlay
180 var quicTunnel *quic.Listener
181 defer func() {
@@ -183,8 +183,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
183 return
184 }
185 acmeManager.Stop()
186 - if hops != nil {
187 - _ = hops.close()
186 + if hopMux != nil {
187 + _ = hopMux.Close()
188 }
189 if ov != nil {
190 _ = ov.Shutdown(context.Background())
@@ -225,7 +225,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
225 if err != nil {
226 return err
227 }
228 - hops, err = newHopManager(ov, s.registry, &s.proxy)
228 + hopMux, err = overlay.NewHopMux(ov)
229 if err != nil {
230 return err
231 }
@@ -246,7 +246,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
246 s.cancel = cancel
247 s.group = group
248 s.overlay = ov
249 - s.hops = hops
249 + s.hopMux = hopMux
250 s.quicTunnel = quicTunnel
251 started = true
252
@@ -259,8 +259,8 @@ 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) })
262 + if s.hopMux != nil {
263 + group.Go(func() error { return s.runHopMux(groupCtx) })
264 }
265 if s.quicTunnel != nil {
266 group.Go(s.runQUICTunnelListener)
@@ -282,7 +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).
285 + Bool("multihop_enabled", s.hopMux != nil).
286 Bool("udp_enabled", s.quicTunnel != nil).
287 Bool("tcp_enabled", s.cfg.TCPEnabled)
288 if s.quicTunnel != nil {
@@ -320,7 +320,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
320
321 for _, lease := range s.registry.CloseAll() {
322 if lease != nil {
323 - s.hops.delete(lease)
323 if lease.isDirect() && s.acmeManager != nil {
324 deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
325 if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname); err != nil {
@@ -349,8 +348,8 @@ func (s *Server) Shutdown(ctx context.Context) error {
348 shutdownErr = err
349 }
350 }
352 - if s.hops != nil {
353 - if err := s.hops.close(); err != nil && shutdownErr == nil && !errors.Is(err, net.ErrClosed) {
351 + if s.hopMux != nil {
352 + if err := s.hopMux.Close(); err != nil && shutdownErr == nil && !errors.Is(err, net.ErrClosed) {
353 shutdownErr = err
354 }
355 }
@@ -403,7 +402,7 @@ func (s *Server) LeaseSnapshotByHostname(hostname string) (types.Lease, bool) {
402 }
403
404 record, ok := s.registry.Lookup(hostname)
406 - if !ok || record == nil || time.Now().After(record.ExpiresAt) {
405 + if !ok || record == nil || record.isHopForward() || time.Now().After(record.ExpiresAt) {
406 return types.Lease{}, false
407 }
408 return s.registry.Snapshot(record), true
@@ -484,22 +483,10 @@ func (s *Server) runSNIListener(ctx context.Context) error {
483 }
484
485 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()) {
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)
496 - session, err := record.stream.Claim(claimCtx)
497 - cancel()
498 - if err != nil {
486 + if !ok || !s.bridgeLeaseConn(ctx, wrappedConn, record) {
487 _ = wrappedConn.Close()
488 return
489 }
502 - s.proxy.bridge(wrappedConn, session, record.Key(), s.registry.policy.BPSManager())
490 }(conn)
491 case errors.Is(err, net.ErrClosed):
492 return nil
@@ -512,6 +499,82 @@ func (s *Server) runSNIListener(ctx context.Context) error {
499 }
500 }
501
502 +func (s *Server) runHopMux(ctx context.Context) error {
503 + if s == nil || s.hopMux == nil {
504 + <-ctx.Done()
505 + return nil
506 + }
507 +
508 + group, groupCtx := errgroup.WithContext(ctx)
509 + group.Go(func() error { return s.hopMux.Serve(groupCtx) })
510 + group.Go(func() error {
511 + for {
512 + stream, err := s.hopMux.Accept(groupCtx)
513 + if err != nil {
514 + if groupCtx.Err() != nil {
515 + return nil
516 + }
517 + return err
518 + }
519 + go func(stream overlay.HopStream) {
520 + record, ok := s.registry.RecordByHopToken(stream.Token, time.Now())
521 + if !ok || !s.bridgeLeaseConn(groupCtx, stream.Conn, record) {
522 + _ = stream.Conn.Close()
523 + }
524 + }(stream)
525 + }
526 + })
527 + group.Go(func() error {
528 + registryHandler := s.hopRegistryHandler()
529 + for {
530 + stream, err := s.hopMux.AcceptRegistry(groupCtx)
531 + if err != nil {
532 + if groupCtx.Err() != nil {
533 + return nil
534 + }
535 + return err
536 + }
537 + go func(stream overlay.HopRegistryStream) {
538 + if err := stream.ServeRegistryHTTP(groupCtx, s.identity.Name, registryHandler); err != nil {
539 + log.Warn().Err(err).Str("path", stream.Path).Msg("respond hop registry stream")
540 + }
541 + }(stream)
542 + }
543 + })
544 + return group.Wait()
545 +}
546 +
547 +func (s *Server) bridgeLeaseConn(ctx context.Context, conn net.Conn, record *leaseRecord) bool {
548 + if s == nil || s.registry == nil || record == nil || time.Now().After(record.ExpiresAt) {
549 + return false
550 + }
551 + if record.isHopForward() {
552 + overlayIPv4 := strings.TrimSpace(record.hopNextOverlayIPv4)
553 + forwardToken := strings.TrimSpace(record.hopNextToken)
554 + if s.hopMux == nil || overlayIPv4 == "" || forwardToken == "" {
555 + return false
556 + }
557 + next, err := s.hopMux.OpenStream(ctx, overlayIPv4, forwardToken)
558 + if err != nil {
559 + log.Warn().Err(err).Str("next_overlay_ipv4", overlayIPv4).Msg("open next hop stream")
560 + return false
561 + }
562 + s.proxy.bridge(conn, next, "", nil)
563 + return true
564 + }
565 + if record.stream == nil || !s.registry.policy.IsIdentityRoutable(record.Key()) {
566 + return false
567 + }
568 + claimCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
569 + session, err := record.stream.Claim(claimCtx)
570 + cancel()
571 + if err != nil {
572 + return false
573 + }
574 + s.proxy.bridge(conn, session, record.Key(), s.registry.policy.BPSManager())
575 + return true
576 +}
577 +
578 func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) error {
579 if interval <= 0 {
580 return errors.New("janitor interval must be positive")
@@ -526,7 +589,6 @@ func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) er
589 return nil
590 case <-ticker.C:
591 for _, lease := range s.registry.cleanupExpired(time.Now()) {
529 - s.hops.delete(lease)
592 if lease.isDirect() && s.acmeManager != nil {
593 deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
594 err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname)
sdk/api_client.go
+102 -55
@@ -30,12 +30,18 @@ var errRelayIncompatible = errors.New("relay is incompatible")
30 // API call creates fresh TCP connections. Call this after detecting a system
31 // sleep/wake cycle where pooled connections are almost certainly dead.
32 func (l *listener) resetTransport() {
33 + if l.leaseHTTPClient != nil && l.leaseHTTPClient != l.httpClient {
34 + if transport, ok := l.leaseHTTPClient.Transport.(*http.Transport); ok {
35 + transport.CloseIdleConnections()
36 + }
37 + }
38 if l.httpClient != nil {
39 if transport, ok := l.httpClient.Transport.(*http.Transport); ok {
40 transport.CloseIdleConnections()
41 }
42 }
43 l.httpClient = nil
44 + l.leaseHTTPClient = nil
45 l.tlsConfig = nil
46 }
47
@@ -76,101 +82,142 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
82 return fmt.Errorf("%w: relay sdk protocol version mismatch: relay=%q client=%q", errRelayIncompatible, protocolVersion, types.SDKVersion)
83 }
84
85 + if l.leaseAPIURL == nil {
86 + l.leaseAPIURL = l.relayURL
87 + }
88 + leaseHTTPClient := httpClient
89 + if l.leaseAPIURL.String() != l.relayURL.String() {
90 + _, leaseHTTPClient, err = utils.NewHTTPTLSClient(bootstrapCtx, l.leaseAPIURL, l.requestTimeout)
91 + if err != nil {
92 + if transport, ok := httpClient.Transport.(*http.Transport); ok {
93 + transport.CloseIdleConnections()
94 + }
95 + return err
96 + }
97 + }
98 +
99 l.httpClient = httpClient
100 + l.leaseHTTPClient = leaseHTTPClient
101 l.tlsConfig = tlsConfig
102 return nil
103 }
104
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
105 +func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, *types.HopRoute, error) {
106 + var err error
107 + var exitHopToken string
108 + var publicHostname string
109 + var keylessURL string
110 + var hop *types.HopRoute
111 + if len(l.multiHop) > 0 {
112 + if len(l.multiHop) < 2 {
113 + return types.RegisterResponse{}, nil, errors.New("multi-hop requires at least entry and exit relay urls")
114 + }
115 + if l.relaySet == nil {
116 + return types.RegisterResponse{}, nil, errors.New("multi-hop relay set is unavailable")
117 + }
118 +
119 + states := l.relaySet.AggregateRelays()
120 + descriptors := make(map[string]types.RelayDescriptor, len(states))
121 + for _, state := range states {
122 + desc := state.Descriptor
123 + if desc.APIHTTPSAddr != "" {
124 + descriptors[desc.APIHTTPSAddr] = desc
125 + }
126 + }
127 +
128 + hopPath := make([]types.RelayDescriptor, 0, len(l.multiHop))
129 + for i, relayURL := range l.multiHop {
130 + desc, ok := descriptors[relayURL]
131 + if !ok {
132 + return types.RegisterResponse{}, nil, fmt.Errorf("multi-hop relay %d descriptor was not discovered", i)
133 + }
134 + hopPath = append(hopPath, desc)
135 + }
136 +
137 + publicHostname, err = utils.LeaseHostname(l.identity.Name, utils.PortalRootHost(hopPath[0].APIHTTPSAddr))
138 + if err != nil {
139 + return types.RegisterResponse{}, nil, err
140 + }
141 + keylessURL = hopPath[0].APIHTTPSAddr
142 +
143 + tokens := make([]string, len(hopPath)-1)
144 + for i := range tokens {
145 + token, err := l.identity.DeriveToken(fmt.Sprintf("hop:%s:%d:%s:%s", publicHostname, i, hopPath[i].APIHTTPSAddr, hopPath[i+1].APIHTTPSAddr))
146 + if err != nil {
147 + return types.RegisterResponse{}, nil, err
148 + }
149 + tokens[i] = "hpt_" + token
150 + }
151 + exitHopToken = tokens[len(tokens)-1]
152 +
153 + var next *types.HopRoute
154 + for i := len(hopPath) - 2; i >= 0; i-- {
155 + route := &types.HopRoute{
156 + ForwardRelay: hopPath[i+1],
157 + ForwardToken: tokens[i],
158 + Next: next,
159 + }
160 + if i == 0 {
161 + route.MatchHostname = publicHostname
162 + } else {
163 + route.MatchToken = tokens[i-1]
164 + }
165 + hop = route
166 + next = route
167 + }
168 }
169
170 var challenge types.RegisterChallengeResponse
91 - if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
171 + if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
172 Identity: l.identity,
173 Metadata: l.metadata,
174 TTL: int(ttl / time.Second),
175 UDPEnabled: udpEnabled,
176 TCPEnabled: tcpEnabled,
97 - MultiHop: multiHop,
177 + HopToken: exitHopToken,
178 + Hop: hop,
179 }, nil, &challenge); err != nil {
99 - return types.RegisterResponse{}, err
180 + return types.RegisterResponse{}, nil, err
181 }
182
183 signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, l.identity.PrivateKey)
184 if err != nil {
104 - return types.RegisterResponse{}, err
185 + return types.RegisterResponse{}, nil, err
186 }
187
188 var resp types.RegisterResponse
108 - if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
189 + if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
190 ChallengeID: challenge.ChallengeID,
191 SIWEMessage: challenge.SIWEMessage,
192 SIWESignature: signature,
193 ReportedIP: utils.ResolvePublicIP(ctx),
194 + Hop: hop,
195 }, nil, &resp); err != nil {
114 - return types.RegisterResponse{}, err
115 - }
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 - }
196 + return types.RegisterResponse{}, nil, err
197 }
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)
198 + if hop != nil {
199 + resp.Hostname = publicHostname
200 + resp.KeylessURL = keylessURL
201 }
157 - return path, nil
202 + return resp, hop, nil
203 }
204
160 -func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) {
205 +func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string, hop *types.HopRoute) (types.RenewResponse, error) {
206 var resp types.RenewResponse
162 - if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
207 + if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
208 AccessToken: accessToken,
209 TTL: int(ttl / time.Second),
210 ReportedIP: utils.ResolvePublicIP(ctx),
211 + Hop: hop,
212 }, nil, &resp); err != nil {
213 return types.RenewResponse{}, err
214 }
215 return resp, nil
216 }
217
172 -func (l *listener) unregisterLease(ctx context.Context, accessToken string) error {
173 - return utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
218 +func (l *listener) unregisterLease(ctx context.Context, accessToken string, hop *types.HopRoute) error {
219 + return utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
220 AccessToken: accessToken,
221 + Hop: hop,
222 }, nil, nil)
223 }
sdk/expose.go
+5 -5
@@ -76,14 +76,14 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
76 if err != nil {
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")
79 + if len(multiHop) == 1 {
80 + return nil, errors.New("multi-hop requires at least entry and exit relay urls")
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)
85 + var listenerRelayURLs []string
86 + var relaySetURLs []string
87 if len(multiHop) > 0 {
88 listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
89 relaySetURLs = append([]string(nil), multiHop...)
@@ -397,7 +397,7 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
397 }
398
399 func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
400 - listenerRelayURLs := []string(nil)
400 + var listenerRelayURLs []string
401 var multiHop []string
402 if len(e.multiHop) > 0 {
403 listenerRelayURLs = []string{e.multiHop[len(e.multiHop)-1]}
sdk/listener.go
+32 -11
@@ -52,6 +52,7 @@ type listener struct {
52 closeOnce sync.Once
53
54 relayURL *url.URL
55 + leaseAPIURL *url.URL
56 identity types.Identity
57 metadata types.LeaseMetadata
58 relaySet *discovery.RelaySet
@@ -70,8 +71,9 @@ type listener struct {
71 datagram *transport.ClientDatagram
72 mitmManager *mitmManager
73
73 - httpClient *http.Client
74 - tlsConfig *tls.Config
74 + httpClient *http.Client
75 + leaseHTTPClient *http.Client
76 + tlsConfig *tls.Config
77
78 leaseMu sync.RWMutex
79 lease *listenerLease
@@ -99,11 +101,25 @@ func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*lis
101 cancel()
102 return nil, fmt.Errorf("parse relay url: %w", err)
103 }
104 + leaseAPIURL := relayurl
105 + if len(cfg.MultiHop) > 0 {
106 + normalizedEntryURL, err := utils.NormalizeRelayURL(cfg.MultiHop[0])
107 + if err != nil {
108 + cancel()
109 + return nil, fmt.Errorf("normalize multi-hop entry relay url: %w", err)
110 + }
111 + leaseAPIURL, err = url.Parse(normalizedEntryURL)
112 + if err != nil {
113 + cancel()
114 + return nil, fmt.Errorf("parse multi-hop entry relay url: %w", err)
115 + }
116 + }
117
118 l := &listener{
119 cancel: cancel,
120 doneCh: listenerCtx.Done(),
121 relayURL: relayurl,
122 + leaseAPIURL: leaseAPIURL,
123 identity: cfg.Identity,
124 metadata: cfg.Metadata,
125 relaySet: cfg.relaySet,
@@ -231,7 +247,7 @@ func (l *listener) Close() error {
247
248 if lease != nil && lease.hostname != "" && l.identity.Key() != "" && lease.accessToken != "" {
249 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
234 - closeErr = errors.Join(closeErr, l.unregisterLease(ctx, lease.accessToken))
250 + closeErr = errors.Join(closeErr, l.unregisterLease(ctx, lease.accessToken, lease.hop))
251 cancel()
252 }
253 if lease != nil && lease.tlsCloser != nil {
@@ -251,6 +267,7 @@ type listenerLease struct {
267 publicURLBase *url.URL
268 tlsConfig *tls.Config
269 tlsCloser io.Closer
270 + hop *types.HopRoute
271 }
272
273 func (l *listener) clearLease(reason string) *listenerLease {
@@ -703,7 +720,7 @@ func (l *listener) renewLease(ctx context.Context) error {
720 }
721
722 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
706 - resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken)
723 + resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken, lease.hop)
724 cancel()
725 if err != nil {
726 if errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
@@ -734,7 +751,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
751 return err
752 }
753
737 - resp, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
754 + resp, hop, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
755 if err != nil {
756 return err
757 }
@@ -744,22 +761,22 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
761 }
762 registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
763 if err != nil {
747 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
764 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
765 return err
766 }
767 if registeredIdentity.Key() != l.identity.Key() {
751 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
768 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
769 return errors.New("relay returned mismatched lease identity")
770 }
771 if l.udpEnabled && !resp.UDPEnabled {
755 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
772 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
773 return &types.APIRequestError{
774 Code: types.APIErrorCodeFeatureUnavailable,
775 Message: "relay did not enable required udp support",
776 }
777 }
778 if l.udpEnabled && resp.SNIPort <= 0 {
762 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
779 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
780 return errors.New("relay did not return sni port for udp transport")
781 }
782 keylessURL := strings.TrimSpace(resp.KeylessURL)
@@ -774,12 +791,15 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
791 }
792 tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname})
793 if err != nil {
777 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
794 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
795 + if tenantTLSCloser != nil {
796 + _ = tenantTLSCloser.Close()
797 + }
798 return err
799 }
800
801 if ctx.Err() != nil {
782 - _ = l.unregisterLease(context.Background(), resp.AccessToken)
802 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
803 if tenantTLSCloser != nil {
804 _ = tenantTLSCloser.Close()
805 }
@@ -793,6 +813,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
813 publicURLBase: publicURLBase,
814 tlsConfig: tlsConf,
815 tlsCloser: tenantTLSCloser,
816 + hop: hop,
817 }
818 if l.udpEnabled {
819 next.sniPort = resp.SNIPort
types/api.go
+21 -11
@@ -56,10 +56,11 @@ func (e *APIRequestError) Is(target error) bool {
56 }
57
58 type RegisterRequest struct {
59 - ChallengeID string `json:"challenge_id"`
60 - SIWEMessage string `json:"siwe_message"`
61 - SIWESignature string `json:"siwe_signature"`
62 - ReportedIP string `json:"reported_ip,omitempty"`
59 + ChallengeID string `json:"challenge_id"`
60 + SIWEMessage string `json:"siwe_message"`
61 + SIWESignature string `json:"siwe_signature"`
62 + ReportedIP string `json:"reported_ip,omitempty"`
63 + Hop *HopRoute `json:"hop,omitempty"`
64 }
65
66 type RegisterChallengeRequest struct {
@@ -68,9 +69,8 @@ type RegisterChallengeRequest struct {
69 TTL int `json:"ttl,omitempty"`
70 UDPEnabled bool `json:"udp_enabled,omitempty"`
71 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"`
72 + HopToken string `json:"hop_token,omitempty"`
73 + Hop *HopRoute `json:"hop,omitempty"`
74 }
75
76 type RegisterChallengeResponse struct {
@@ -118,9 +118,10 @@ type QUICControlResponse struct {
118 }
119
120 type RenewRequest struct {
121 - AccessToken string `json:"access_token"`
122 - TTL int `json:"ttl,omitempty"`
123 - ReportedIP string `json:"reported_ip,omitempty"`
121 + AccessToken string `json:"access_token"`
122 + TTL int `json:"ttl,omitempty"`
123 + ReportedIP string `json:"reported_ip,omitempty"`
124 + Hop *HopRoute `json:"hop,omitempty"`
125 }
126
127 type RenewResponse struct {
@@ -129,7 +130,16 @@ type RenewResponse struct {
130 }
131
132 type UnregisterRequest struct {
132 - AccessToken string `json:"access_token"`
133 + AccessToken string `json:"access_token"`
134 + Hop *HopRoute `json:"hop,omitempty"`
135 +}
136 +
137 +type HopRoute struct {
138 + MatchHostname string `json:"match_hostname,omitempty"`
139 + MatchToken string `json:"match_token,omitempty"`
140 + ForwardRelay RelayDescriptor `json:"forward_relay"`
141 + ForwardToken string `json:"forward_token"`
142 + Next *HopRoute `json:"next,omitempty"`
143 }
144
145 type DomainResponse struct {
types/identity.go
+22
@@ -1,7 +1,11 @@
1 package types
2
3 import (
4 + "crypto/hmac"
5 + "crypto/sha256"
6 + "encoding/base64"
7 "encoding/json"
8 + "errors"
9 "strings"
10 "time"
11 )
@@ -57,6 +61,24 @@ func (i Identity) Key() string {
61 return name + IdentityKeySeparator + address
62 }
63
64 +func (i Identity) DeriveToken(nonce string) (string, error) {
65 + privateKey := strings.TrimSpace(i.PrivateKey)
66 + if privateKey == "" {
67 + return "", errors.New("identity private key is required")
68 + }
69 + nonce = strings.TrimSpace(nonce)
70 + if nonce == "" {
71 + return "", errors.New("identity token nonce is required")
72 + }
73 +
74 + mac := hmac.New(sha256.New, []byte(privateKey))
75 + _, _ = mac.Write([]byte("Portal identity token v1\n"))
76 + _, _ = mac.Write([]byte(i.Key()))
77 + _, _ = mac.Write([]byte("\n"))
78 + _, _ = mac.Write([]byte(nonce))
79 + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
80 +}
81 +
82 type LeaseMetadata struct {
83 Description string `json:"description,omitempty"`
84 Owner string `json:"owner,omitempty"`
types/types.go
+1 -1
@@ -2,7 +2,7 @@ package types
2
3 const (
4 ReleaseVersion = "v2.1.5"
5 - SDKVersion = "5"
5 + SDKVersion = "6"
6 DiscoveryVersion = "6"
7 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"
8
utils/api.go
+29 -8
@@ -4,6 +4,7 @@ import (
4 "bytes"
5 "context"
6 "encoding/json"
7 + "errors"
8 "fmt"
9 "io"
10 "net/http"
@@ -38,6 +39,15 @@ func WriteAPIError(w http.ResponseWriter, status int, code, message string) {
39 })
40 }
41
42 +func WriteRawAPIResponse(w http.ResponseWriter, status int, body []byte) {
43 + if status <= 0 {
44 + status = http.StatusBadGateway
45 + }
46 + w.Header().Set("Content-Type", "application/json")
47 + w.WriteHeader(status)
48 + _, _ = w.Write(body)
49 +}
50 +
51 func MethodNotAllowedError() APIErrorResponse {
52 return APIErrorResponse{
53 Status: http.StatusMethodNotAllowed,
@@ -121,21 +131,32 @@ func HTTPDoAPIPath(ctx context.Context, client *http.Client, baseURL *url.URL, m
131 return DecodeAPIRequestError(resp)
132 }
133
134 + respBody, err := io.ReadAll(resp.Body)
135 + if err != nil {
136 + return fmt.Errorf("read response: %w", err)
137 + }
138 + if err := DecodeAPIData(respBody, out); err != nil {
139 + var apiErr *types.APIRequestError
140 + if errors.As(err, &apiErr) && apiErr.StatusCode == 0 {
141 + apiErr.StatusCode = resp.StatusCode
142 + }
143 + return err
144 + }
145 + return nil
146 +}
147 +
148 +func DecodeAPIData(body []byte, out any) error {
149 var envelope types.APIEnvelope[json.RawMessage]
125 - if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
150 + if err := json.Unmarshal(body, &envelope); err != nil {
151 return fmt.Errorf("decode response: %w", err)
152 }
153 if !envelope.OK {
154 if envelope.Error == nil {
130 - return &types.APIRequestError{
131 - StatusCode: resp.StatusCode,
132 - Message: fmt.Sprintf("api request failed with status %d", resp.StatusCode),
133 - }
155 + return &types.APIRequestError{Message: "api response is not ok"}
156 }
157 return &types.APIRequestError{
136 - StatusCode: resp.StatusCode,
137 - Code: envelope.Error.Code,
138 - Message: envelope.Error.Message,
158 + Code: envelope.Error.Code,
159 + Message: envelope.Error.Message,
160 }
161 }
162 if out == nil {
utils/identity.go
+2 -3
@@ -455,10 +455,9 @@ func populateRelayIdentity(identity *types.RelayIdentity, discoveryEnabled bool)
455 }
456
457 if strings.TrimSpace(identity.AdminSecretKey) == "" {
458 - var err error
459 - adminSecretKey, err := RandomHex(16)
458 + adminSecretKey, err := identity.Identity.DeriveToken("admin")
459 if err != nil {
461 - return fmt.Errorf("generate relay admin secret key: %w", err)
460 + return fmt.Errorf("derive relay admin secret key: %w", err)
461 }
462 identity.AdminSecretKey = adminSecretKey
463 }