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()