fix: hop expose to frontend

Kim committed Apr 17, 2026 at 15:44 UTC e485cf3ba48bc014ba3e837432dde663c47ca14a
4 files changed +135 -61
portal/api_server.go
+26 -4
@@ -454,7 +454,7 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
454 writeAPIErrorResponse(w, err)
455 return
456 }
457 - if record.isDirect() && s.acmeManager != nil {
457 + if record.isPublicEntry() && s.acmeManager != nil {
458 deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
459 if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
460 log.Warn().
@@ -503,7 +503,18 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
503 return
504 }
505 if r.Method == http.MethodDelete {
506 - s.registry.DeleteHopRoute(&route)
506 + record := s.registry.DeleteHopRoute(&route)
507 + if record != nil && record.isPublicEntry() && s.acmeManager != nil {
508 + deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
509 + if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
510 + log.Warn().
511 + Err(err).
512 + Str("hostname", record.Hostname).
513 + Str("address", record.Address).
514 + Msg("delete hop route remote state")
515 + }
516 + cancel()
517 + }
518 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
519 return
520 }
@@ -531,10 +542,21 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
542 utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
543 return
544 }
534 - if err := s.registry.RegisterHopRoute(&route, now); err != nil {
545 + record, err := s.registry.RegisterHopRoute(&route, now)
546 + if err != nil {
547 writeAPIErrorResponse(w, err)
548 return
549 }
550 + if record.isPublicEntry() && s.acmeManager != nil {
551 + syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
552 + if err := s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address); err != nil {
553 + cancel()
554 + _ = s.registry.DeleteHopRoute(&route)
555 + writeAPIErrorResponse(w, err)
556 + return
557 + }
558 + cancel()
559 + }
560 utils.WriteAPIData(w, http.StatusOK, map[string]any{})
561 }
562
@@ -778,7 +800,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
800 record.Close()
801 return types.RegisterResponse{}, err
802 }
781 - if record.isDirect() {
803 + if record.isPublicEntry() {
804 syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
805 defer cancel()
806 if err := s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address); err != nil {
portal/auth/hop_route.go
+6 -14
@@ -1,8 +1,6 @@
1 package auth
2
3 import (
4 - "crypto/sha256"
5 - "encoding/hex"
4 "errors"
5 "fmt"
6 "strings"
@@ -23,26 +21,20 @@ func SignHopRoute(method string, route types.HopRoute, identity types.Identity,
21 if err != nil {
22 return types.HopRoute{}, err
23 }
26 - ownerToken, err := identity.DeriveToken(
27 - "hop-route-owner",
28 - route.RelayURL,
29 - route.MatchHostname,
30 - route.MatchToken,
31 - )
24 + identity, err = utils.NormalizeStoredIdentity(identity)
25 if err != nil {
26 return types.HopRoute{}, err
27 }
35 - ownerSeed := sha256.Sum256([]byte(ownerToken))
36 - owner, err := utils.ResolveSecp256k1Identity(hex.EncodeToString(ownerSeed[:]))
37 - if err != nil {
38 - return types.HopRoute{}, err
28 + if strings.TrimSpace(identity.PrivateKey) == "" || strings.TrimSpace(identity.PublicKey) == "" {
29 + return types.HopRoute{}, errors.New("hop route owner identity is required")
30 }
40 - route.OwnerPublicKey = owner.PublicKey
31 +
32 + route.OwnerPublicKey = identity.PublicKey
33 payload, err := types.HopRouteBytes(method, route)
34 if err != nil {
35 return types.HopRoute{}, err
36 }
45 - route.Signature, err = utils.SignSHA256Secp256k1DER(payload, owner.PrivateKey)
37 + route.Signature, err = utils.SignSHA256Secp256k1DER(payload, identity.PrivateKey)
38 if err != nil {
39 return types.HopRoute{}, err
40 }
portal/lease.go
+97 -36
@@ -46,10 +46,17 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
46 defer r.mu.Unlock()
47
48 out := make([]*leaseRecord, 0, len(r.leasesByKey))
49 + seen := make(map[*leaseRecord]struct{}, len(r.leasesByKey))
50 for _, record := range r.leasesByKey {
51 out = append(out, record)
52 + seen[record] = struct{}{}
53 r.policy.ForgetIdentity(record.Key())
54 }
55 + for _, record := range r.recordsByHostname {
56 + if _, ok := seen[record]; record != nil && !ok && record.isPublicEntry() {
57 + out = append(out, record)
58 + }
59 + }
60 r.leasesByKey = make(map[string]*leaseRecord)
61 r.recordsByHostname = make(map[string]*leaseRecord)
62 r.recordsByHopToken = make(map[string]*leaseRecord)
@@ -100,13 +107,13 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
107 r.mu.Lock()
108
109 now := time.Now()
103 - if record.isDirect() {
110 + if record.isPublicEntry() {
111 if existing := r.recordsByHostname[hostname]; existing != nil && existing.Key() != key && now.Before(existing.ExpiresAt) {
112 r.mu.Unlock()
113 return errHostnameConflict
114 }
115 }
109 - if record.hopToken != "" {
116 + if record.isHopExit() {
117 if existing := r.recordsByHopToken[record.hopToken]; existing != nil && existing.Key() != key && now.Before(existing.ExpiresAt) {
118 r.mu.Unlock()
119 return errors.New("hop token conflict")
@@ -121,10 +128,10 @@ func (r *leaseRegistry) Register(record *leaseRecord) error {
128 }
129 record.Hostname = hostname
130 r.leasesByKey[key] = record
124 - if record.isDirect() {
131 + if record.isPublicEntry() {
132 r.recordsByHostname[hostname] = record
133 }
127 - if record.hopToken != "" {
134 + if record.isHopExit() {
135 r.recordsByHopToken[record.hopToken] = record
136 }
137 r.policy.IPFilter().RegisterIdentityIP(key, record.ClientIP)
@@ -207,13 +214,13 @@ func (r *leaseRegistry) RecordByHopToken(token string, now time.Time) (*leaseRec
214 return nil, false
215 }
216
210 -func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) error {
217 +func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (*leaseRecord, error) {
218 if route == nil {
212 - return errors.New("hop route is required")
219 + return nil, errors.New("hop route is required")
220 }
221 ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
222 if err != nil {
216 - return err
223 + return nil, err
224 }
225 matchHostname := utils.NormalizeHostname(route.MatchHostname)
226 matchToken := strings.TrimSpace(route.MatchToken)
@@ -223,23 +230,26 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) e
230
231 switch {
232 case r == nil:
226 - return errFeatureUnavailable
233 + return nil, errFeatureUnavailable
234 case !expiresAt.After(now):
228 - return errors.New("route expiry must be in the future")
235 + return nil, errors.New("route expiry must be in the future")
236 case matchHostname == "" && matchToken == "":
230 - return errors.New("hostname or token matcher is required")
237 + return nil, errors.New("hostname or token matcher is required")
238 case matchHostname != "" && matchToken != "":
232 - return errors.New("hostname and token matchers are mutually exclusive")
239 + return nil, errors.New("hostname and token matchers are mutually exclusive")
240 case overlayErr != nil:
234 - return fmt.Errorf("forward relay overlay ipv4: %w", overlayErr)
241 + return nil, fmt.Errorf("forward relay overlay ipv4: %w", overlayErr)
242 case forwardToken == "":
236 - return errors.New("forward token is required")
243 + return nil, errors.New("forward token is required")
244 }
245
246 r.mu.Lock()
247 defer r.mu.Unlock()
248
249 record := &leaseRecord{
250 + Identity: types.Identity{
251 + Address: ownerKey,
252 + },
253 Hostname: matchHostname,
254 ExpiresAt: expiresAt,
255 hopOwnerKey: ownerKey,
@@ -247,47 +257,59 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) e
257 hopNextOverlayIPv4: overlayIPv4,
258 hopNextToken: forwardToken,
259 }
250 - if matchHostname != "" {
251 - if existing := r.lookupLocked(matchHostname); existing != nil && now.Before(existing.ExpiresAt) {
252 - if !existing.isHopForward() || existing.hopOwnerKey != ownerKey {
253 - return errHostnameConflict
260 + switch {
261 + case record.isPublicEntry():
262 + if existing := r.lookupLocked(record.Hostname); existing != nil && now.Before(existing.ExpiresAt) {
263 + if !existing.isPublicEntry() || !sameHopRouteOwner(existing, record) {
264 + return nil, errHostnameConflict
265 }
266 }
256 - r.recordsByHostname[matchHostname] = record
257 - return nil
258 - }
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")
267 + r.recordsByHostname[record.Hostname] = record
268 + return record, nil
269 + case record.isHopMiddle():
270 + if existing := r.recordsByHopToken[record.hopToken]; existing != nil && now.Before(existing.ExpiresAt) {
271 + if !existing.isHopMiddle() || !sameHopRouteOwner(existing, record) {
272 + return nil, errors.New("hop token conflict")
273 + }
274 }
275 + r.recordsByHopToken[record.hopToken] = record
276 + return record, nil
277 + default:
278 + return nil, errors.New("invalid hop route")
279 }
264 - r.recordsByHopToken[matchToken] = record
265 - return nil
280 }
281
268 -func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) {
282 +func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
283 if r == nil || route == nil {
270 - return
284 + return nil
285 }
286 ownerKey, err := utils.AddressFromCompressedPublicKeyHex(route.OwnerPublicKey)
287 if err != nil {
274 - return
288 + return nil
289 }
290 hostname := utils.NormalizeHostname(route.MatchHostname)
291 token := strings.TrimSpace(route.MatchToken)
292 + candidate := &leaseRecord{
293 + hopOwnerKey: ownerKey,
294 + hopNextToken: strings.TrimSpace(route.ForwardToken),
295 + }
296
297 + var deleted *leaseRecord
298 r.mu.Lock()
299 if hostname != "" {
281 - if record := r.recordsByHostname[hostname]; record != nil && record.isHopForward() && record.hopOwnerKey == ownerKey {
300 + if record := r.recordsByHostname[hostname]; record != nil && record.isPublicEntry() && sameHopRouteOwner(record, candidate) {
301 delete(r.recordsByHostname, hostname)
302 + deleted = record
303 }
304 }
305 if token != "" {
286 - if record := r.recordsByHopToken[token]; record != nil && record.isHopForward() && record.hopOwnerKey == ownerKey {
306 + if record := r.recordsByHopToken[token]; record != nil && record.isHopMiddle() && sameHopRouteOwner(record, candidate) {
307 delete(r.recordsByHopToken, token)
308 + deleted = record
309 }
310 }
311 r.mu.Unlock()
312 + return deleted
313 }
314
315 func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string) (types.RegisterChallengeResponse, error) {
@@ -390,6 +412,9 @@ func (r *leaseRegistry) cleanupExpired(now time.Time) []*leaseRecord {
412 for hostname, record := range r.recordsByHostname {
413 if record != nil && !now.Before(record.ExpiresAt) {
414 delete(r.recordsByHostname, hostname)
415 + if record.isPublicEntry() {
416 + expired = append(expired, record)
417 + }
418 }
419 }
420 for token, record := range r.recordsByHopToken {
@@ -432,9 +457,10 @@ func (r *leaseRegistry) LeaseSnapshots(now time.Time) []types.Lease {
457 r.mu.RLock()
458 defer r.mu.RUnlock()
459
435 - snapshots := make([]types.Lease, 0, len(r.leasesByKey))
460 + snapshots := make([]types.Lease, 0, len(r.leasesByKey)+len(r.recordsByHostname))
461 + seenHostnames := make(map[string]struct{}, len(r.leasesByKey))
462 for _, record := range r.leasesByKey {
437 - if record == nil || now.After(record.ExpiresAt) {
463 + if record == nil || !record.isPublicEntry() || now.After(record.ExpiresAt) {
464 continue
465 }
466 adminSnapshot := r.AdminSnapshot(record)
@@ -449,6 +475,16 @@ func (r *leaseRegistry) LeaseSnapshots(now time.Time) []types.Lease {
475 continue
476 }
477 snapshots = append(snapshots, adminSnapshot.Lease)
478 + seenHostnames[adminSnapshot.Hostname] = struct{}{}
479 + }
480 + for hostname, record := range r.recordsByHostname {
481 + if record == nil || !record.isPublicEntry() || hostname == "" || now.After(record.ExpiresAt) {
482 + continue
483 + }
484 + if _, ok := seenHostnames[record.Hostname]; ok {
485 + continue
486 + }
487 + snapshots = append(snapshots, r.Snapshot(record))
488 }
489 return snapshots
490 }
@@ -513,12 +549,37 @@ type leaseRecord struct {
549 startOnce sync.Once
550 }
551
516 -func (r *leaseRecord) isDirect() bool {
517 - return r == nil || (strings.TrimSpace(r.hopToken) == "" && !r.isHopForward())
552 +func (r *leaseRecord) isPublicEntry() bool {
553 + return r != nil && strings.TrimSpace(r.Hostname) != "" && strings.TrimSpace(r.hopToken) == ""
554 +}
555 +
556 +func (r *leaseRecord) isHopMiddle() bool {
557 + _, _, hasNextHop := r.nextHop()
558 + return r != nil && strings.TrimSpace(r.hopToken) != "" && hasNextHop
559 }
560
520 -func (r *leaseRecord) isHopForward() bool {
521 - return r != nil && (strings.TrimSpace(r.hopNextOverlayIPv4) != "" || strings.TrimSpace(r.hopNextToken) != "")
561 +func (r *leaseRecord) isHopExit() bool {
562 + _, _, hasNextHop := r.nextHop()
563 + return r != nil && strings.TrimSpace(r.hopToken) != "" && !hasNextHop
564 +}
565 +
566 +func (r *leaseRecord) nextHop() (string, string, bool) {
567 + if r == nil {
568 + return "", "", false
569 + }
570 + overlayIPv4 := strings.TrimSpace(r.hopNextOverlayIPv4)
571 + forwardToken := strings.TrimSpace(r.hopNextToken)
572 + return overlayIPv4, forwardToken, overlayIPv4 != "" || forwardToken != ""
573 +}
574 +
575 +func sameHopRouteOwner(existing, next *leaseRecord) bool {
576 + if existing == nil || next == nil {
577 + return false
578 + }
579 + if strings.TrimSpace(existing.hopOwnerKey) == strings.TrimSpace(next.hopOwnerKey) {
580 + return true
581 + }
582 + return strings.TrimSpace(existing.hopNextToken) != "" && strings.TrimSpace(existing.hopNextToken) == strings.TrimSpace(next.hopNextToken)
583 }
584
585 func (r *leaseRegistry) deleteIndexesLocked(record *leaseRecord) {
portal/server.go
+6 -7
@@ -322,7 +322,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
322
323 for _, lease := range s.registry.CloseAll() {
324 if lease != nil {
325 - if lease.isDirect() && s.acmeManager != nil {
325 + if lease.isPublicEntry() && s.acmeManager != nil {
326 deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
327 if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname); err != nil {
328 log.Warn().
@@ -404,7 +404,7 @@ func (s *Server) LeaseSnapshotByHostname(hostname string) (types.Lease, bool) {
404 }
405
406 record, ok := s.registry.Lookup(hostname)
407 - if !ok || record == nil || record.isHopForward() || time.Now().After(record.ExpiresAt) {
407 + if !ok || record == nil || time.Now().After(record.ExpiresAt) {
408 return types.Lease{}, false
409 }
410 return s.registry.Snapshot(record), true
@@ -530,7 +530,8 @@ func (s *Server) runHopMux(ctx context.Context) error {
530 _ = stream.Conn.Close()
531 return
532 }
533 - log.Info().Str("remote_addr", stream.RemoteAddr).Bool("forward", record.isHopForward()).Msg("hop stream received")
533 + _, _, hasNextHop := record.nextHop()
534 + log.Info().Str("remote_addr", stream.RemoteAddr).Bool("forward", hasNextHop).Msg("hop stream received")
535 if err := s.bridgeLeaseConn(groupCtx, stream.Conn, record); err != nil {
536 log.Warn().Err(err).Str("remote_addr", stream.RemoteAddr).Msg("hop stream bridge failed")
537 _ = stream.Conn.Close()
@@ -545,9 +546,7 @@ func (s *Server) bridgeLeaseConn(ctx context.Context, conn net.Conn, record *lea
546 if s == nil || s.registry == nil || record == nil || time.Now().After(record.ExpiresAt) {
547 return errLeaseNotFound
548 }
548 - if record.isHopForward() {
549 - overlayIPv4 := strings.TrimSpace(record.hopNextOverlayIPv4)
550 - forwardToken := strings.TrimSpace(record.hopNextToken)
549 + if overlayIPv4, forwardToken, hasNextHop := record.nextHop(); hasNextHop {
550 switch {
551 case s.hopMux == nil:
552 return errFeatureUnavailable
@@ -608,7 +607,7 @@ func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) er
607 return nil
608 case <-ticker.C:
609 for _, lease := range s.registry.cleanupExpired(time.Now()) {
611 - if lease.isDirect() && s.acmeManager != nil {
610 + if lease.isPublicEntry() && s.acmeManager != nil {
611 deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
612 err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname)
613 cancel()