Refactor hop route registration and synchronization

Kim committed Apr 16, 2026 at 21:11 UTC 65014b487986aa238481c6a77a7c47f56b3ae4b4
11 files changed +371 -382
portal/api_server.go
+58 -99
@@ -19,6 +19,7 @@ import (
19 "github.com/gosuda/portal-tunnel/v2/portal/auth"
20 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
21 "github.com/gosuda/portal-tunnel/v2/portal/keyless"
22 + "github.com/gosuda/portal-tunnel/v2/portal/overlay"
23 "github.com/gosuda/portal-tunnel/v2/portal/transport"
24 "github.com/gosuda/portal-tunnel/v2/types"
25 "github.com/gosuda/portal-tunnel/v2/utils"
@@ -111,6 +112,8 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
112 s.handleRenew(w, r)
113 case types.PathSDKUnregister:
114 s.handleUnregister(w, r)
115 + case types.PathSDKHop:
116 + s.handleHop(w, r)
117 case types.PathSDKConnect:
118 s.handleConnect(w, r)
119 case types.PathDiscovery:
@@ -137,18 +140,6 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
140 })
141 }
142
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 -
143 func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
144 utils.WriteAPIData(w, http.StatusOK, map[string]any{
145 "service": "portal-relay",
@@ -341,30 +332,6 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
332 if !ok {
333 return
334 }
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 - }
335
336 challenge, err := s.registry.consumeVerifiedRegisterChallenge(req)
337 if err != nil {
@@ -403,17 +370,6 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
370 if !ok {
371 return
372 }
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 - }
373
374 scheme := "https"
375 if r.TLS == nil {
@@ -466,30 +422,6 @@ func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
422 if !ok {
423 return
424 }
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 - }
425
426 claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, time.Now().UTC())
427 if err != nil {
@@ -527,20 +459,6 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
459 if !ok {
460 return
461 }
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 - }
462 claims, err := auth.VerifyLeaseAccessToken(req.AccessToken, s.identity.PublicKey, s.cfg.PortalURL, time.Now().UTC())
463 if err != nil {
464 utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, errUnauthorized.Error())
@@ -568,35 +486,76 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
486 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
487 }
488
571 -func (s *Server) forwardHopRegistry(ctx context.Context, route *types.HopRoute, path string, payload any) (int, []byte, error) {
489 +func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
490 + switch r.Method {
491 + case http.MethodPost, http.MethodDelete:
492 + default:
493 + utils.MethodNotAllowedError().Write(w)
494 + return
495 + }
496 if s.hopMux == nil || s.overlay == nil || s.relaySet == nil {
573 - return 0, nil, errFeatureUnavailable
497 + utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
498 + return
499 + }
500 + if _, ok := s.extractAllowedClientIP(w, r); !ok {
501 + return
502 + }
503 +
504 + route, ok := utils.DecodeJSONRequest[types.HopRoute](w, r, defaultControlBodyLimit)
505 + if !ok {
506 + return
507 + }
508 + route, err := overlay.VerifyHopRoute(r.Method, route)
509 + if errors.Is(err, overlay.ErrHopRouteSignatureInvalid) {
510 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, "hop route signature is invalid")
511 + return
512 }
575 - if route == nil {
576 - return 0, nil, errors.New("hop route is required")
513 + if err != nil {
514 + utils.InvalidRequestError(err).Write(w)
515 + return
516 + }
517 + if route.RelayURL != s.cfg.PortalURL {
518 + utils.WriteAPIError(w, http.StatusForbidden, types.APIErrorCodeUnauthorized, "hop route relay url does not match receiving relay")
519 + return
520 + }
521 +
522 + if r.Method == http.MethodDelete {
523 + s.registry.DeleteHopRoute(&route)
524 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
525 + return
526 + }
527 +
528 + now := time.Now().UTC()
529 + if !route.ExpiresAt.UTC().After(now) {
530 + utils.InvalidRequestError(errors.New("route expiry must be in the future")).Write(w)
531 + return
532 }
533 forwardRelay, err := auth.VerifyRelayDescriptor(route.ForwardRelay)
534 if err != nil {
580 - return 0, nil, fmt.Errorf("forward relay: %w", err)
535 + utils.InvalidRequestError(fmt.Errorf("forward relay: %w", err)).Write(w)
536 + return
537 }
538 if !forwardRelay.SupportsOverlayPeer ||
539 strings.TrimSpace(forwardRelay.WireGuardPublicKey) == "" ||
540 strings.TrimSpace(forwardRelay.WireGuardEndpoint) == "" ||
541 strings.TrimSpace(forwardRelay.OverlayIPv4) == "" {
586 - return 0, nil, errors.New("forward relay wireguard overlay metadata is required")
542 + utils.InvalidRequestError(errors.New("forward relay wireguard overlay metadata is required")).Write(w)
543 + return
544 }
588 - if err := s.relaySet.InsertAnnounced(forwardRelay, time.Now().UTC()); err != nil {
589 - return 0, nil, fmt.Errorf("forward relay: %w", err)
545 + route.ForwardRelay = forwardRelay
546 + if err := s.relaySet.InsertAnnounced(forwardRelay, now); err != nil {
547 + utils.InvalidRequestError(fmt.Errorf("forward relay: %w", err)).Write(w)
548 + return
549 }
550 if err := s.overlay.Sync(s.relaySet.OverlayPeerStates()); err != nil {
592 - return 0, nil, err
551 + utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
552 + return
553 }
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}
554 + if err := s.registry.RegisterHopRoute(&route, now); err != nil {
555 + writeAPIErrorResponse(w, err)
556 + return
557 }
599 - return status, respBody, nil
558 + utils.WriteAPIData(w, http.StatusOK, map[string]any{})
559 }
560
561 func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) {
portal/lease.go
+22 -8
@@ -207,15 +207,19 @@ func (r *leaseRegistry) RecordByHopToken(token string, now time.Time) (*leaseRec
207 return nil, false
208 }
209
210 -func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, expiresAt, now time.Time) error {
210 +func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) error {
211 if route == nil {
212 return errors.New("hop route is required")
213 }
214 + ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
215 + if err != nil {
216 + return err
217 + }
218 matchHostname := utils.NormalizeHostname(route.MatchHostname)
219 matchToken := strings.TrimSpace(route.MatchToken)
220 overlayIPv4 := strings.TrimSpace(route.ForwardRelay.OverlayIPv4)
221 forwardToken := strings.TrimSpace(route.ForwardToken)
218 - expiresAt = expiresAt.UTC()
222 + expiresAt := route.ExpiresAt.UTC()
223
224 switch {
225 case r == nil:
@@ -238,19 +242,24 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, expiresAt, now t
242 record := &leaseRecord{
243 Hostname: matchHostname,
244 ExpiresAt: expiresAt,
245 + hopOwnerKey: ownerKey,
246 hopToken: matchToken,
247 hopNextOverlayIPv4: overlayIPv4,
248 hopNextToken: forwardToken,
249 }
250 if matchHostname != "" {
246 - if existing := r.lookupLocked(matchHostname); existing != nil && !existing.isHopForward() && now.Before(existing.ExpiresAt) {
247 - return errHostnameConflict
251 + if existing := r.lookupLocked(matchHostname); existing != nil && now.Before(existing.ExpiresAt) {
252 + if !existing.isHopForward() || existing.hopOwnerKey != ownerKey {
253 + return errHostnameConflict
254 + }
255 }
256 r.recordsByHostname[matchHostname] = record
257 return nil
258 }
252 - if existing := r.recordsByHopToken[matchToken]; existing != nil && !existing.isHopForward() && now.Before(existing.ExpiresAt) {
253 - return errors.New("hop token conflict")
259 + if existing := r.recordsByHopToken[matchToken]; existing != nil && now.Before(existing.ExpiresAt) {
260 + if !existing.isHopForward() || existing.hopOwnerKey != ownerKey {
261 + return errors.New("hop token conflict")
262 + }
263 }
264 r.recordsByHopToken[matchToken] = record
265 return nil
@@ -260,17 +269,21 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) {
269 if r == nil || route == nil {
270 return
271 }
272 + ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
273 + if err != nil {
274 + return
275 + }
276 hostname := utils.NormalizeHostname(route.MatchHostname)
277 token := strings.TrimSpace(route.MatchToken)
278
279 r.mu.Lock()
280 if hostname != "" {
268 - if record := r.recordsByHostname[hostname]; record != nil && record.isHopForward() {
281 + if record := r.recordsByHostname[hostname]; record != nil && record.isHopForward() && record.hopOwnerKey == ownerKey {
282 delete(r.recordsByHostname, hostname)
283 }
284 }
285 if token != "" {
273 - if record := r.recordsByHopToken[token]; record != nil && record.isHopForward() {
286 + if record := r.recordsByHopToken[token]; record != nil && record.isHopForward() && record.hopOwnerKey == ownerKey {
287 delete(r.recordsByHopToken, token)
288 }
289 }
@@ -487,6 +500,7 @@ type leaseRecord struct {
500 Metadata types.LeaseMetadata
501
502 hopToken string
503 + hopOwnerKey string
504 hopNextOverlayIPv4 string
505 hopNextToken string
506
portal/overlay/hop.go new
+126
@@ -0,0 +1,126 @@
1 +package overlay
2 +
3 +import (
4 + "crypto/sha256"
5 + "encoding/hex"
6 + "encoding/json"
7 + "errors"
8 + "fmt"
9 + "strings"
10 + "time"
11 +
12 + "github.com/gosuda/portal-tunnel/v2/types"
13 + "github.com/gosuda/portal-tunnel/v2/utils"
14 +)
15 +
16 +var ErrHopRouteSignatureInvalid = errors.New("hop route signature is invalid")
17 +
18 +func SignHopRoute(method string, route types.HopRoute, identity types.Identity, expiresAt time.Time) (types.HopRoute, error) {
19 + route.ExpiresAt = expiresAt.UTC()
20 + route.Signature = ""
21 + route.OwnerPublicKey = ""
22 +
23 + route, err := normalizeHopRoute(route, false)
24 + if err != nil {
25 + return types.HopRoute{}, err
26 + }
27 + owner, err := deriveHopRouteOwner(identity, route)
28 + if err != nil {
29 + return types.HopRoute{}, err
30 + }
31 + route.OwnerPublicKey = owner.PublicKey
32 +
33 + route, err = normalizeHopRoute(route, true)
34 + if err != nil {
35 + return types.HopRoute{}, err
36 + }
37 + payload, err := types.HopRouteBytes(method, route)
38 + if err != nil {
39 + return types.HopRoute{}, err
40 + }
41 + route.Signature, err = utils.SignSHA256Secp256k1DER(payload, owner.PrivateKey)
42 + if err != nil {
43 + return types.HopRoute{}, err
44 + }
45 + return route, nil
46 +}
47 +
48 +func VerifyHopRoute(method string, route types.HopRoute) (types.HopRoute, error) {
49 + signature := strings.TrimSpace(route.Signature)
50 + route.Signature = ""
51 +
52 + route, err := normalizeHopRoute(route, true)
53 + if err != nil {
54 + return types.HopRoute{}, err
55 + }
56 + payload, err := types.HopRouteBytes(method, route)
57 + if err != nil {
58 + return types.HopRoute{}, err
59 + }
60 + if err := utils.VerifySHA256Secp256k1DER(payload, route.OwnerPublicKey, signature); err != nil {
61 + return types.HopRoute{}, ErrHopRouteSignatureInvalid
62 + }
63 + route.Signature = signature
64 + return route, nil
65 +}
66 +
67 +func normalizeHopRoute(route types.HopRoute, requireOwner bool) (types.HopRoute, error) {
68 + ownerPublicKey := strings.ToLower(utils.TrimHexPrefix(strings.TrimSpace(route.OwnerPublicKey)))
69 + if ownerPublicKey != "" {
70 + if _, err := utils.ParseSecp256k1PublicKeyHex(ownerPublicKey); err != nil {
71 + return types.HopRoute{}, fmt.Errorf("hop route owner public key: %w", err)
72 + }
73 + } else if requireOwner {
74 + return types.HopRoute{}, errors.New("hop route owner public key is required")
75 + }
76 +
77 + relayURL, err := utils.NormalizeRelayURL(route.RelayURL)
78 + if err != nil {
79 + return types.HopRoute{}, fmt.Errorf("hop relay url: %w", err)
80 + }
81 +
82 + route.OwnerPublicKey = ownerPublicKey
83 + route.RelayURL = relayURL
84 + route.MatchHostname = utils.NormalizeHostname(route.MatchHostname)
85 + route.MatchToken = strings.TrimSpace(route.MatchToken)
86 + route.ForwardToken = strings.TrimSpace(route.ForwardToken)
87 + route.ExpiresAt = route.ExpiresAt.UTC()
88 + route.Signature = strings.TrimSpace(route.Signature)
89 + return route, nil
90 +}
91 +
92 +func deriveHopRouteOwner(identity types.Identity, route types.HopRoute) (types.Identity, error) {
93 + nonce, err := hopRouteOwnerNonce(route)
94 + if err != nil {
95 + return types.Identity{}, err
96 + }
97 + for counter := range 4 {
98 + token, err := identity.DeriveToken(fmt.Sprintf("hop-owner:%s:%d", nonce, counter))
99 + if err != nil {
100 + return types.Identity{}, err
101 + }
102 + seed := sha256.Sum256([]byte(token))
103 + owner, err := utils.ResolveSecp256k1Identity(hex.EncodeToString(seed[:]))
104 + if err == nil {
105 + return owner, nil
106 + }
107 + }
108 + return types.Identity{}, errors.New("derive hop route owner key")
109 +}
110 +
111 +func hopRouteOwnerNonce(route types.HopRoute) (string, error) {
112 + payload := struct {
113 + RelayURL string `json:"relay_url"`
114 + MatchHostname string `json:"match_hostname"`
115 + MatchToken string `json:"match_token"`
116 + }{
117 + RelayURL: route.RelayURL,
118 + MatchHostname: route.MatchHostname,
119 + MatchToken: route.MatchToken,
120 + }
121 + encoded, err := json.Marshal(payload)
122 + if err != nil {
123 + return "", err
124 + }
125 + return string(encoded), nil
126 +}
portal/overlay/hop_mux.go
+2 -154
@@ -1,7 +1,6 @@
1 package overlay
2
3 import (
4 - "bytes"
4 "context"
5 "encoding/binary"
6 "encoding/json"
@@ -9,7 +8,6 @@ import (
8 "fmt"
9 "io"
10 "net"
12 - "net/http"
11 "strings"
12 "sync"
13 "time"
@@ -21,21 +19,18 @@ import (
19 const (
20 hopProtocolVersion = 1
21 hopPrefaceLimit = 4 << 10
24 - hopRegistryFrameLimit = 8 << 20
22 hopIncomingBuffer = 128
23 defaultPrefaceTimeout = 2 * time.Second
24 )
25
26 const (
27 hopModeTLSStream = "tls-stream"
31 - hopModeRegistry = "registry"
28 )
29
30 type HopMux struct {
31 listener net.Listener
32 overlay *Overlay
33 incoming chan HopStream
38 - registry chan HopRegistryStream
34
35 mu sync.Mutex
36 outbound map[string]*yamux.Session
@@ -53,28 +48,6 @@ type HopStream struct {
48 Token string
49 }
50
56 -type HopRegistryStream struct {
57 - Path string
58 - Body []byte
59 - conn net.Conn
60 -}
61 -
62 -type hopRegistryHTTPResponse struct {
63 - header http.Header
64 - status int
65 - body bytes.Buffer
66 -}
67 -
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 -
51 func NewHopMux(overlay *Overlay) (*HopMux, error) {
52 if overlay == nil || overlay.stack == nil {
53 return nil, errors.New("overlay is required for multi-hop mux")
@@ -87,7 +60,6 @@ func NewHopMux(overlay *Overlay) (*HopMux, error) {
60 listener: listener,
61 overlay: overlay,
62 incoming: make(chan HopStream, hopIncomingBuffer),
90 - registry: make(chan HopRegistryStream, hopIncomingBuffer),
63 outbound: make(map[string]*yamux.Session),
64 done: make(chan struct{}),
65 }, nil
@@ -111,8 +83,8 @@ func (m *HopMux) Serve(ctx context.Context) error {
83 case errors.Is(err, net.ErrClosed):
84 return nil
85 default:
114 - if ctx.Err() != nil {
115 - return nil
86 + if ctxErr := ctx.Err(); ctxErr != nil {
87 + return ctxErr
88 }
89 return fmt.Errorf("accept hop mux connection: %w", err)
90 }
@@ -132,19 +104,6 @@ func (m *HopMux) Accept(ctx context.Context) (HopStream, error) {
104 }
105 }
106
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 -
107 func (m *HopMux) Close() error {
108 if m == nil {
109 return nil
@@ -204,40 +163,6 @@ func (m *HopMux) OpenStream(ctx context.Context, overlayIPv4, token string) (net
163 return stream, nil
164 }
165
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 {
219 - return 0, nil, err
220 - }
221 - defer stream.Close()
222 -
223 - preface := hopPreface{
224 - Version: hopProtocolVersion,
225 - Mode: hopModeRegistry,
226 - }
227 - if err := writeFramedJSON(stream, preface, hopPrefaceLimit); err != nil {
228 - return 0, nil, err
229 - }
230 - if err := writeFramedJSON(stream, hopRegistryRequest{Path: path, Body: body}, hopRegistryFrameLimit); err != nil {
231 - return 0, nil, err
232 - }
233 -
234 - var resp hopRegistryResponse
235 - if err := readFramedJSON(stream, &resp, hopRegistryFrameLimit); err != nil {
236 - return 0, nil, err
237 - }
238 - return resp.Status, resp.Body, nil
239 -}
240 -
166 func (m *HopMux) openYamuxStream(ctx context.Context, overlayIPv4 string) (*yamux.Stream, error) {
167 overlayIPv4 = strings.TrimSpace(overlayIPv4)
168 if overlayIPv4 == "" {
@@ -379,21 +304,6 @@ func (m *HopMux) handleStream(ctx context.Context, stream *yamux.Stream) {
304 Conn: stream,
305 Token: preface.Token,
306 })
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 - })
307 default:
308 _ = stream.Close()
309 }
@@ -407,68 +317,6 @@ func (m *HopMux) deliver(ctx context.Context, stream HopStream) {
317 }
318 }
319
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 - }
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 {
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 - }
469 - return r.status
470 -}
471 -
320 func hopYamuxConfig() *yamux.Config {
321 cfg := yamux.DefaultConfig()
322 cfg.Logger = nil
portal/server.go
+2 -19
@@ -511,8 +511,8 @@ func (s *Server) runHopMux(ctx context.Context) error {
511 for {
512 stream, err := s.hopMux.Accept(groupCtx)
513 if err != nil {
514 - if groupCtx.Err() != nil {
515 - return nil
514 + if ctxErr := groupCtx.Err(); ctxErr != nil {
515 + return ctxErr
516 }
517 return err
518 }
@@ -524,23 +524,6 @@ func (s *Server) runHopMux(ctx context.Context) error {
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 - })
527 return group.Wait()
528 }
529
sdk/api_client.go
+89 -43
@@ -6,9 +6,12 @@ import (
6 "fmt"
7 "net"
8 "net/http"
9 + "net/url"
10 + "slices"
11 "strings"
12 "time"
13
14 + "github.com/gosuda/portal-tunnel/v2/portal/overlay"
15 "github.com/gosuda/portal-tunnel/v2/types"
16 "github.com/gosuda/portal-tunnel/v2/utils"
17 )
@@ -30,18 +33,12 @@ var errRelayIncompatible = errors.New("relay is incompatible")
33 // API call creates fresh TCP connections. Call this after detecting a system
34 // sleep/wake cycle where pooled connections are almost certainly dead.
35 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 - }
36 if l.httpClient != nil {
37 if transport, ok := l.httpClient.Transport.(*http.Transport); ok {
38 transport.CloseIdleConnections()
39 }
40 }
41 l.httpClient = nil
44 - l.leaseHTTPClient = nil
42 l.tlsConfig = nil
43 }
44
@@ -82,32 +79,16 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
79 return fmt.Errorf("%w: relay sdk protocol version mismatch: relay=%q client=%q", errRelayIncompatible, protocolVersion, types.SDKVersion)
80 }
81
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 -
82 l.httpClient = httpClient
100 - l.leaseHTTPClient = leaseHTTPClient
83 l.tlsConfig = tlsConfig
84 return nil
85 }
86
105 -func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, *types.HopRoute, error) {
106 - var err error
87 +func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, []types.HopRoute, error) {
88 var exitHopToken string
89 var publicHostname string
90 var keylessURL string
110 - var hop *types.HopRoute
91 + var hopRoutes []types.HopRoute
92 if len(l.multiHop) > 0 {
93 if len(l.multiHop) < 2 {
94 return types.RegisterResponse{}, nil, errors.New("multi-hop requires at least entry and exit relay urls")
@@ -134,6 +115,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
115 hopPath = append(hopPath, desc)
116 }
117
118 + var err error
119 publicHostname, err = utils.LeaseHostname(l.identity.Name, utils.PortalRootHost(hopPath[0].APIHTTPSAddr))
120 if err != nil {
121 return types.RegisterResponse{}, nil, err
@@ -148,34 +130,32 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
130 }
131 tokens[i] = "hpt_" + token
132 }
151 - exitHopToken = tokens[len(tokens)-1]
133
153 - var next *types.HopRoute
154 - for i := len(hopPath) - 2; i >= 0; i-- {
155 - route := &types.HopRoute{
134 + hopRoutes = make([]types.HopRoute, 0, len(tokens))
135 + for i := range tokens {
136 + route := types.HopRoute{
137 + RelayURL: hopPath[i].APIHTTPSAddr,
138 ForwardRelay: hopPath[i+1],
139 ForwardToken: tokens[i],
158 - Next: next,
140 }
141 if i == 0 {
142 route.MatchHostname = publicHostname
143 } else {
144 route.MatchToken = tokens[i-1]
145 }
165 - hop = route
166 - next = route
146 + hopRoutes = append(hopRoutes, route)
147 }
148 + exitHopToken = tokens[len(tokens)-1]
149 }
150
151 var challenge types.RegisterChallengeResponse
171 - if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
152 + if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
153 Identity: l.identity,
154 Metadata: l.metadata,
155 TTL: int(ttl / time.Second),
156 UDPEnabled: udpEnabled,
157 TCPEnabled: tcpEnabled,
158 HopToken: exitHopToken,
178 - Hop: hop,
159 }, nil, &challenge); err != nil {
160 return types.RegisterResponse{}, nil, err
161 }
@@ -186,38 +166,104 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
166 }
167
168 var resp types.RegisterResponse
189 - if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
169 + if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
170 ChallengeID: challenge.ChallengeID,
171 SIWEMessage: challenge.SIWEMessage,
172 SIWESignature: signature,
173 ReportedIP: utils.ResolvePublicIP(ctx),
194 - Hop: hop,
174 }, nil, &resp); err != nil {
175 return types.RegisterResponse{}, nil, err
176 }
198 - if hop != nil {
177 + if len(hopRoutes) > 0 {
178 + if err := l.syncHopRoutes(ctx, http.MethodPost, resp.ExpiresAt, hopRoutes); err != nil {
179 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
180 + return types.RegisterResponse{}, nil, err
181 + }
182 resp.Hostname = publicHostname
183 resp.KeylessURL = keylessURL
184 }
202 - return resp, hop, nil
185 + return resp, hopRoutes, nil
186 }
187
205 -func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string, hop *types.HopRoute) (types.RenewResponse, error) {
188 +func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string, hopRoutes []types.HopRoute) (types.RenewResponse, error) {
189 var resp types.RenewResponse
207 - if err := utils.HTTPDoAPIPath(ctx, l.leaseHTTPClient, l.leaseAPIURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
190 + if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
191 AccessToken: accessToken,
192 TTL: int(ttl / time.Second),
193 ReportedIP: utils.ResolvePublicIP(ctx),
211 - Hop: hop,
194 }, nil, &resp); err != nil {
195 return types.RenewResponse{}, err
196 }
197 + if err := l.syncHopRoutes(ctx, http.MethodPost, resp.ExpiresAt, hopRoutes); err != nil {
198 + return types.RenewResponse{}, err
199 + }
200 return resp, nil
201 }
202
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{
203 +func (l *listener) unregisterLease(ctx context.Context, accessToken string, hopRoutes []types.HopRoute) error {
204 + var unregisterErr error
205 + if err := l.syncHopRoutes(ctx, http.MethodDelete, time.Time{}, hopRoutes); err != nil {
206 + unregisterErr = errors.Join(unregisterErr, err)
207 + }
208 + err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
209 AccessToken: accessToken,
221 - Hop: hop,
210 }, nil, nil)
211 + return errors.Join(unregisterErr, err)
212 +}
213 +
214 +func (l *listener) syncHopRoutes(ctx context.Context, method string, expiresAt time.Time, routes []types.HopRoute) error {
215 + if len(routes) == 0 {
216 + return nil
217 + }
218 +
219 + orderedRoutes := routes
220 + if method == http.MethodPost {
221 + orderedRoutes = append([]types.HopRoute(nil), routes...)
222 + slices.Reverse(orderedRoutes)
223 + }
224 +
225 + var syncErr error
226 + for _, unsignedRoute := range orderedRoutes {
227 + route, err := overlay.SignHopRoute(method, unsignedRoute, l.identity, expiresAt)
228 + if err != nil {
229 + if method == http.MethodDelete {
230 + syncErr = errors.Join(syncErr, err)
231 + continue
232 + }
233 + return err
234 + }
235 + relayURL, err := url.Parse(route.RelayURL)
236 + if err != nil {
237 + err = fmt.Errorf("parse hop route relay url: %w", err)
238 + if method == http.MethodDelete {
239 + syncErr = errors.Join(syncErr, err)
240 + continue
241 + }
242 + return err
243 + }
244 +
245 + bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
246 + _, client, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, l.requestTimeout)
247 + cancel()
248 + if err != nil {
249 + if method == http.MethodDelete {
250 + syncErr = errors.Join(syncErr, err)
251 + continue
252 + }
253 + return err
254 + }
255 + transport, _ := client.Transport.(*http.Transport)
256 + err = utils.HTTPDoAPIPath(ctx, client, relayURL, method, types.PathSDKHop, route, nil, nil)
257 + if transport != nil {
258 + transport.CloseIdleConnections()
259 + }
260 + if err != nil {
261 + if method == http.MethodDelete {
262 + syncErr = errors.Join(syncErr, err)
263 + continue
264 + }
265 + return err
266 + }
267 + }
268 + return syncErr
269 }
sdk/expose.go
+12 -3
@@ -72,9 +72,18 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
72 if err != nil {
73 return nil, err
74 }
75 - multiHop, err := utils.NormalizeRelayURLs(cfg.MultiHop...)
76 - if err != nil {
77 - return nil, fmt.Errorf("normalize multi-hop relay urls: %w", err)
75 + var multiHop []string
76 + for _, input := range cfg.MultiHop {
77 + for _, part := range utils.SplitCSV(input) {
78 + relayURL, err := utils.NormalizeRelayURL(part)
79 + if err != nil {
80 + return nil, fmt.Errorf("normalize multi-hop relay url: %w", err)
81 + }
82 + if slices.Contains(multiHop, relayURL) {
83 + return nil, fmt.Errorf("multi-hop relay url repeated: %s", relayURL)
84 + }
85 + multiHop = append(multiHop, relayURL)
86 + }
87 }
88 if len(multiHop) == 1 {
89 return nil, errors.New("multi-hop requires at least entry and exit relay urls")
sdk/listener.go
+13 -30
@@ -52,7 +52,6 @@ type listener struct {
52 closeOnce sync.Once
53
54 relayURL *url.URL
55 - leaseAPIURL *url.URL
55 identity types.Identity
56 metadata types.LeaseMetadata
57 relaySet *discovery.RelaySet
@@ -71,9 +70,8 @@ type listener struct {
70 datagram *transport.ClientDatagram
71 mitmManager *mitmManager
72
74 - httpClient *http.Client
75 - leaseHTTPClient *http.Client
76 - tlsConfig *tls.Config
73 + httpClient *http.Client
74 + tlsConfig *tls.Config
75
76 leaseMu sync.RWMutex
77 lease *listenerLease
@@ -101,25 +99,10 @@ func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*lis
99 cancel()
100 return nil, fmt.Errorf("parse relay url: %w", err)
101 }
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 -
102 l := &listener{
103 cancel: cancel,
104 doneCh: listenerCtx.Done(),
105 relayURL: relayurl,
122 - leaseAPIURL: leaseAPIURL,
106 identity: cfg.Identity,
107 metadata: cfg.Metadata,
108 relaySet: cfg.relaySet,
@@ -247,7 +230,7 @@ func (l *listener) Close() error {
230
231 if lease != nil && lease.hostname != "" && l.identity.Key() != "" && lease.accessToken != "" {
232 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
250 - closeErr = errors.Join(closeErr, l.unregisterLease(ctx, lease.accessToken, lease.hop))
233 + closeErr = errors.Join(closeErr, l.unregisterLease(ctx, lease.accessToken, lease.hopRoutes))
234 cancel()
235 }
236 if lease != nil && lease.tlsCloser != nil {
@@ -267,7 +250,7 @@ type listenerLease struct {
250 publicURLBase *url.URL
251 tlsConfig *tls.Config
252 tlsCloser io.Closer
270 - hop *types.HopRoute
253 + hopRoutes []types.HopRoute
254 }
255
256 func (l *listener) clearLease(reason string) *listenerLease {
@@ -720,7 +703,7 @@ func (l *listener) renewLease(ctx context.Context) error {
703 }
704
705 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
723 - resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken, lease.hop)
706 + resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, lease.accessToken, lease.hopRoutes)
707 cancel()
708 if err != nil {
709 if errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
@@ -751,7 +734,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
734 return err
735 }
736
754 - resp, hop, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
737 + resp, hopRoutes, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
738 if err != nil {
739 return err
740 }
@@ -761,22 +744,22 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
744 }
745 registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
746 if err != nil {
764 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
747 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
748 return err
749 }
750 if registeredIdentity.Key() != l.identity.Key() {
768 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
751 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
752 return errors.New("relay returned mismatched lease identity")
753 }
754 if l.udpEnabled && !resp.UDPEnabled {
772 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
755 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
756 return &types.APIRequestError{
757 Code: types.APIErrorCodeFeatureUnavailable,
758 Message: "relay did not enable required udp support",
759 }
760 }
761 if l.udpEnabled && resp.SNIPort <= 0 {
779 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
762 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
763 return errors.New("relay did not return sni port for udp transport")
764 }
765 keylessURL := strings.TrimSpace(resp.KeylessURL)
@@ -791,7 +774,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
774 }
775 tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname})
776 if err != nil {
794 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
777 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
778 if tenantTLSCloser != nil {
779 _ = tenantTLSCloser.Close()
780 }
@@ -799,7 +782,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
782 }
783
784 if ctx.Err() != nil {
802 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hop)
785 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
786 if tenantTLSCloser != nil {
787 _ = tenantTLSCloser.Close()
788 }
@@ -813,7 +796,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
796 publicURLBase: publicURLBase,
797 tlsConfig: tlsConf,
798 tlsCloser: tenantTLSCloser,
816 - hop: hop,
799 + hopRoutes: hopRoutes,
800 }
801 if l.udpEnabled {
802 next.sniPort = resp.SNIPort
types/api.go
+46 -17
@@ -1,6 +1,7 @@
1 package types
2
3 import (
4 + "encoding/json"
5 "fmt"
6 "strings"
7 "time"
@@ -56,11 +57,10 @@ func (e *APIRequestError) Is(target error) bool {
57 }
58
59 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"`
63 - Hop *HopRoute `json:"hop,omitempty"`
60 + ChallengeID string `json:"challenge_id"`
61 + SIWEMessage string `json:"siwe_message"`
62 + SIWESignature string `json:"siwe_signature"`
63 + ReportedIP string `json:"reported_ip,omitempty"`
64 }
65
66 type RegisterChallengeRequest struct {
@@ -70,7 +70,6 @@ type RegisterChallengeRequest struct {
70 UDPEnabled bool `json:"udp_enabled,omitempty"`
71 TCPEnabled bool `json:"tcp_enabled,omitempty"`
72 HopToken string `json:"hop_token,omitempty"`
73 - Hop *HopRoute `json:"hop,omitempty"`
73 }
74
75 type RegisterChallengeResponse struct {
@@ -118,10 +117,9 @@ type QUICControlResponse struct {
117 }
118
119 type RenewRequest struct {
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"`
120 + AccessToken string `json:"access_token"`
121 + TTL int `json:"ttl,omitempty"`
122 + ReportedIP string `json:"reported_ip,omitempty"`
123 }
124
125 type RenewResponse struct {
@@ -130,16 +128,47 @@ type RenewResponse struct {
128 }
129
130 type UnregisterRequest struct {
133 - AccessToken string `json:"access_token"`
134 - Hop *HopRoute `json:"hop,omitempty"`
131 + AccessToken string `json:"access_token"`
132 }
133
134 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"`
135 + OwnerPublicKey string `json:"owner_public_key,omitempty"`
136 + RelayURL string `json:"relay_url"`
137 + MatchHostname string `json:"match_hostname,omitempty"`
138 + MatchToken string `json:"match_token,omitempty"`
139 + ForwardRelay RelayDescriptor `json:"forward_relay"`
140 + ForwardToken string `json:"forward_token"`
141 + ExpiresAt time.Time `json:"expires_at,omitempty"`
142 + Signature string `json:"signature,omitempty"`
143 +}
144 +
145 +func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
146 + forwardRelay, err := CanonicalBytes(route.ForwardRelay)
147 + if err != nil {
148 + return nil, err
149 + }
150 + payload := struct {
151 + Purpose string `json:"purpose"`
152 + Method string `json:"method"`
153 + OwnerPublicKey string `json:"owner_public_key"`
154 + RelayURL string `json:"relay_url"`
155 + MatchHostname string `json:"match_hostname"`
156 + MatchToken string `json:"match_token"`
157 + ForwardRelay json.RawMessage `json:"forward_relay"`
158 + ForwardToken string `json:"forward_token"`
159 + ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
160 + }{
161 + Purpose: "portal hop route v1",
162 + Method: strings.ToUpper(strings.TrimSpace(method)),
163 + OwnerPublicKey: strings.TrimSpace(route.OwnerPublicKey),
164 + RelayURL: strings.TrimSpace(route.RelayURL),
165 + MatchHostname: strings.TrimSpace(route.MatchHostname),
166 + MatchToken: strings.TrimSpace(route.MatchToken),
167 + ForwardRelay: json.RawMessage(forwardRelay),
168 + ForwardToken: strings.TrimSpace(route.ForwardToken),
169 + ExpiresAtUnixNano: route.ExpiresAt.UTC().UnixNano(),
170 + }
171 + return json.Marshal(payload)
172 }
173
174 type DomainResponse struct {
types/paths.go
+1
@@ -33,6 +33,7 @@ const (
33 PathSDKRegister = "/sdk/register"
34 PathSDKRenew = "/sdk/renew"
35 PathSDKUnregister = "/sdk/unregister"
36 + PathSDKHop = "/sdk/hop"
37 PathSDKConnect = "/sdk/connect"
38 PathDiscovery = "/discovery"
39 PathDiscoveryAnnounce = "/discovery/announce"
utils/api.go
-9
@@ -39,15 +39,6 @@ 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 -
42 func MethodNotAllowedError() APIErrorResponse {
43 return APIErrorResponse{
44 Status: http.StatusMethodNotAllowed,