feat: Refactor hostname handling and improve error checks in lease registration
Kim committed
May 6, 2026 at 14:13 UTC
02574ed096b2fcc4fe3fa49fd25bc60acc21801c
8 files changed
+95
-115
portal/keyless/client.go
+7
-10
@@ -13,7 +13,7 @@ import (
13
"github.com/gosuda/portal-tunnel/v2/utils"
14
)
15
16
-func BuildClientTLSConfig(relayURL string, domains []string, echKeys []tls.EncryptedClientHelloKey) (*tls.Config, ioCloser, error) {
16
+func BuildClientTLSConfig(relayURL, hostname string, echKeys []tls.EncryptedClientHelloKey) (*tls.Config, ioCloser, error) {
17
normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
18
if err != nil {
19
return nil, nil, err
@@ -32,15 +32,12 @@ func BuildClientTLSConfig(relayURL string, domains []string, echKeys []tls.Encry
32
if err != nil {
33
return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
34
}
35
- for _, domain := range domains {
36
- domain = strings.TrimSpace(domain)
37
- if domain == "" {
38
- continue
39
- }
40
- verifyErr := VerifyCertificateHostname(certPEM, domain)
41
- if verifyErr != nil {
42
- return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, verifyErr)
43
- }
35
+ hostname = strings.TrimSpace(hostname)
36
+ if hostname == "" {
37
+ return nil, nil, errors.New("keyless hostname is required")
38
+ }
39
+ if verifyErr := VerifyCertificateHostname(certPEM, hostname); verifyErr != nil {
40
+ return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", hostname, verifyErr)
41
}
42
43
remoteSigner, err := keylesstls.NewRemoteSigner(keylesstls.RemoteSignerConfig{
portal/lease.go
+56
-71
@@ -168,6 +168,9 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
168
if (routeHostname != "" || fallbackHostnameHash != "") && (hopToken != "" || req.UDPEnabled || req.TCPEnabled) {
169
return nil, types.RegisterResponse{}, errTransportMismatch
170
}
171
+ if fallbackHostnameHash != "" && routeHostname == "" {
172
+ return nil, types.RegisterResponse{}, errors.New("fallback hostname hash requires route hostname")
173
+ }
174
if routeHostname != "" {
175
routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
176
normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
@@ -176,7 +179,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
179
}
180
}
181
hostname := routeHostname
179
- if hostname == "" && fallbackHostnameHash == "" && hopToken == "" {
182
+ if hostname == "" && hopToken == "" {
183
hostname, err = utils.LeaseHostname(identity.Name, r.rootHostname)
184
if err != nil {
185
return nil, types.RegisterResponse{}, err
@@ -283,18 +286,10 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
286
tcpLeases++
287
}
288
}
286
- if existing.isPublicEntry() && existingKey != identityKey {
287
- sameRoute := hostname != "" && (existing.Hostname == hostname ||
288
- (existing.FallbackHostnameHash != "" && existing.FallbackHostnameHash == utils.HostnameHash(hostname)))
289
- if fallbackHostnameHash != "" {
290
- sameRoute = sameRoute || existing.FallbackHostnameHash == fallbackHostnameHash ||
291
- (existing.Hostname != "" && utils.HostnameHash(existing.Hostname) == fallbackHostnameHash)
292
- }
293
- if sameRoute {
294
- r.mu.Unlock()
295
- record.Close()
296
- return nil, types.RegisterResponse{}, errHostnameConflict
297
- }
289
+ if existing.isPublicEntry() && existingKey != identityKey && existing.routesOverlap(record) {
290
+ r.mu.Unlock()
291
+ record.Close()
292
+ return nil, types.RegisterResponse{}, errHostnameConflict
293
}
294
if hopToken != "" && (existing.isHopMiddle() || existing.isHopExit()) && existing.hopToken == hopToken && existingKey != identityKey {
295
r.mu.Unlock()
@@ -321,13 +316,7 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
316
if existing == nil || existing.stream != nil || !existing.isPublicEntry() || existing.Key() != identityKey {
317
continue
318
}
324
- sameRoute := hostname != "" && (existing.Hostname == hostname ||
325
- (existing.FallbackHostnameHash != "" && existing.FallbackHostnameHash == utils.HostnameHash(hostname)))
326
- if fallbackHostnameHash != "" {
327
- sameRoute = sameRoute || existing.FallbackHostnameHash == fallbackHostnameHash ||
328
- (existing.Hostname != "" && utils.HostnameHash(existing.Hostname) == fallbackHostnameHash)
329
- }
330
- if sameRoute {
319
+ if existing.routesOverlap(record) {
320
r.deleteRecord(i)
321
i--
322
}
@@ -471,23 +460,26 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
460
routeHostname := utils.NormalizeHostname(route.RouteHostname)
461
matchHostnameHash := strings.TrimSpace(route.MatchHostnameHash)
462
matchToken := strings.TrimSpace(route.MatchToken)
474
- matchers := 0
475
- for _, matcher := range []string{routeHostname, matchHostname, matchHostnameHash, matchToken} {
476
- if strings.TrimSpace(matcher) != "" {
477
- matchers++
478
- }
479
- }
463
overlayIPv4, overlayErr := utils.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
464
forwardToken := strings.TrimSpace(route.ForwardToken)
465
expiresAt := route.ExpiresAt.UTC()
466
+ hostname := routeHostname
467
+ if hostname == "" {
468
+ hostname = matchHostname
469
+ }
470
+ hasPublicMatcher := hostname != "" || matchHostnameHash != ""
471
472
switch {
473
case r == nil:
474
return nil, errFeatureUnavailable
475
case !expiresAt.After(now):
476
return nil, errors.New("route expiry must be in the future")
489
- case matchers != 1:
490
- return nil, errors.New("exactly one route hostname, hostname hash, hostname, or token matcher is required")
477
+ case matchToken != "" && hasPublicMatcher:
478
+ return nil, errors.New("hostname and token matchers are mutually exclusive")
479
+ case matchToken == "" && hostname == "":
480
+ return nil, errors.New("route hostname or hostname matcher is required")
481
+ case routeHostname != "" && matchHostname != "":
482
+ return nil, errors.New("route hostname and hostname matcher are mutually exclusive")
483
case overlayErr != nil:
484
return nil, fmt.Errorf("forward relay overlay ipv4: %w", overlayErr)
485
case forwardToken == "":
@@ -500,16 +492,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
492
return nil, errors.New("route hostname must be a child of relay root hostname")
493
}
494
}
503
- name := routeHostname
504
- if name == "" {
505
- name = matchHostname
506
- }
507
- if name == "" && matchHostnameHash != "" {
508
- name = "hash-" + strings.ToLower(matchHostnameHash)
509
- if len(name) > len("hash-")+12 {
510
- name = name[:len("hash-")+12]
511
- }
512
- }
495
+ name := hostname
496
if label, _, ok := strings.Cut(name, "."); ok {
497
name = label
498
}
@@ -522,7 +505,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
505
Name: name,
506
Address: ownerKey,
507
},
525
- Hostname: utils.StringOrDefault(routeHostname, matchHostname),
508
+ Hostname: hostname,
509
FallbackHostnameHash: matchHostnameHash,
510
Metadata: route.Metadata.Copy(),
511
FirstSeenAt: route.FirstSeenAt.UTC(),
@@ -537,13 +520,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
520
if existing == nil || !existing.isPublicEntry() || existing.isExpired(now) {
521
continue
522
}
540
- sameRoute := record.Hostname != "" && (existing.Hostname == record.Hostname ||
541
- (existing.FallbackHostnameHash != "" && existing.FallbackHostnameHash == utils.HostnameHash(record.Hostname)))
542
- if record.FallbackHostnameHash != "" {
543
- sameRoute = sameRoute || existing.FallbackHostnameHash == record.FallbackHostnameHash ||
544
- (existing.Hostname != "" && utils.HostnameHash(existing.Hostname) == record.FallbackHostnameHash)
545
- }
546
- if !sameRoute {
523
+ if !existing.routesOverlap(record) {
524
continue
525
}
526
if existing.stream != nil || !strings.EqualFold(existing.Address, record.Address) {
@@ -554,13 +531,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
531
if existing == nil || existing.stream != nil || !existing.isPublicEntry() || !strings.EqualFold(existing.Address, record.Address) {
532
continue
533
}
557
- sameRoute := record.Hostname != "" && (existing.Hostname == record.Hostname ||
558
- (existing.FallbackHostnameHash != "" && existing.FallbackHostnameHash == utils.HostnameHash(record.Hostname)))
559
- if record.FallbackHostnameHash != "" {
560
- sameRoute = sameRoute || existing.FallbackHostnameHash == record.FallbackHostnameHash ||
561
- (existing.Hostname != "" && utils.HostnameHash(existing.Hostname) == record.FallbackHostnameHash)
562
- }
563
- if sameRoute {
534
+ if existing.routesOverlap(record) {
535
r.records[i] = record
536
return record, nil
537
}
@@ -600,6 +571,10 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
571
routeHostname := utils.NormalizeHostname(route.RouteHostname)
572
hostnameHash := strings.TrimSpace(route.MatchHostnameHash)
573
token := strings.TrimSpace(route.MatchToken)
574
+ routeKey := routeHostname
575
+ if routeKey == "" {
576
+ routeKey = hostname
577
+ }
578
579
var deleted *leaseRecord
580
r.mu.Lock()
@@ -609,20 +584,14 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
584
continue
585
}
586
deleteRecord := false
612
- if routeHostname != "" {
613
- deleteRecord = deleteRecord || record.isPublicEntry() &&
614
- record.Hostname == routeHostname &&
615
- strings.EqualFold(record.Address, ownerKey)
616
- }
617
- if hostnameHash != "" {
618
- deleteRecord = deleteRecord || record.isPublicEntry() &&
619
- record.FallbackHostnameHash == hostnameHash &&
620
- strings.EqualFold(record.Address, ownerKey)
621
- }
622
- if hostname != "" {
623
- deleteRecord = deleteRecord || record.isPublicEntry() &&
624
- record.Hostname == hostname &&
625
- strings.EqualFold(record.Address, ownerKey)
587
+ if routeKey != "" || hostnameHash != "" {
588
+ deleteRecord = record.isPublicEntry() && strings.EqualFold(record.Address, ownerKey)
589
+ if routeKey != "" {
590
+ deleteRecord = deleteRecord && record.Hostname == routeKey
591
+ }
592
+ if hostnameHash != "" {
593
+ deleteRecord = deleteRecord && record.FallbackHostnameHash == hostnameHash
594
+ }
595
}
596
if token != "" {
597
deleteRecord = deleteRecord || record.isHopMiddle() &&
@@ -650,6 +619,9 @@ func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeReques
619
if (routeHostname != "" || fallbackHostnameHash != "") && (hopToken != "" || req.UDPEnabled || req.TCPEnabled) {
620
return types.RegisterChallengeResponse{}, errTransportMismatch
621
}
622
+ if fallbackHostnameHash != "" && routeHostname == "" {
623
+ return types.RegisterChallengeResponse{}, errors.New("fallback hostname hash requires route hostname")
624
+ }
625
if routeHostname != "" {
626
routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
627
normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
@@ -791,9 +763,6 @@ func (r *leaseRegistry) PublicLeases(now time.Time) []types.Lease {
763
if record.Metadata.Hide {
764
continue
765
}
794
- if record.Hostname == "" {
795
- continue
796
- }
766
if record.stream != nil {
767
identityKey := record.Key()
768
if r.policy.IsIdentityBanned(identityKey) || r.policy.IsIdentityDenied(identityKey) || !r.policy.EffectiveApproval(identityKey) {
@@ -902,7 +871,7 @@ type leaseRecord struct {
871
}
872
873
func (r *leaseRecord) isPublicEntry() bool {
905
- return r != nil && r.hopToken == "" && (r.Hostname != "" || r.FallbackHostnameHash != "")
874
+ return r != nil && r.hopToken == "" && r.Hostname != ""
875
}
876
877
func (r *leaseRecord) isHopMiddle() bool {
@@ -915,6 +884,22 @@ func (r *leaseRecord) isHopExit() bool {
884
return r != nil && r.hopToken != "" && !hasNextHop
885
}
886
887
+func (r *leaseRecord) routesOverlap(other *leaseRecord) bool {
888
+ if r == nil || other == nil {
889
+ return false
890
+ }
891
+ if r.Hostname != "" && other.Hostname != "" && r.Hostname == other.Hostname {
892
+ return true
893
+ }
894
+ if r.FallbackHostnameHash != "" && other.FallbackHostnameHash != "" && r.FallbackHostnameHash == other.FallbackHostnameHash {
895
+ return true
896
+ }
897
+ if r.Hostname != "" && other.FallbackHostnameHash != "" && utils.HostnameHash(r.Hostname) == other.FallbackHostnameHash {
898
+ return true
899
+ }
900
+ return other.Hostname != "" && r.FallbackHostnameHash != "" && utils.HostnameHash(other.Hostname) == r.FallbackHostnameHash
901
+}
902
+
903
func (r *leaseRecord) nextHop() (string, string, bool) {
904
if r == nil {
905
return "", "", false
portal/lease_test.go
+18
-10
@@ -133,6 +133,13 @@ func TestLeaseRegistryAutomaticECHRouteFallsBackToPlainSNI(t *testing.T) {
133
if adminLeases[0].Hostname != registered.Hostname {
134
t.Fatalf("AdminLeases()[0] hostname = %q, want %q", adminLeases[0].Hostname, registered.Hostname)
135
}
136
+
137
+ if _, _, err := registry.Register(types.RegisterChallengeRequest{
138
+ Identity: newTestLeaseIdentity(t, "hash-only"),
139
+ FallbackHostnameHash: utils.HostnameHash("hash-only.example.com"),
140
+ }, "203.0.113.10", ""); err == nil {
141
+ t.Fatal("Register(fallback hash only) error = nil, want error")
142
+ }
143
}
144
145
func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
@@ -159,22 +166,23 @@ func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
166
FirstSeenAt: now,
167
ExpiresAt: now.Add(time.Minute),
168
}
162
- plainRoute := baseRoute
163
- plainRoute.MatchHostnameHash = utils.HostnameHash("demo.example.com")
164
- echRoute := baseRoute
165
- echRoute.RouteHostname = "ech-demo.example.com"
166
- echRoute.Metadata.Hide = true
169
+ route := baseRoute
170
+ route.RouteHostname = "ech-demo.example.com"
171
+ route.MatchHostnameHash = utils.HostnameHash("demo.example.com")
172
+ route.Metadata.Hide = true
173
168
- if _, err := registry.RegisterHopRoute(&plainRoute, now); err != nil {
169
- t.Fatalf("RegisterHopRoute(plain) error = %v", err)
174
+ if _, err := registry.RegisterHopRoute(&route, now); err != nil {
175
+ t.Fatalf("RegisterHopRoute() error = %v", err)
176
}
171
- if _, err := registry.RegisterHopRoute(&echRoute, now); err != nil {
172
- t.Fatalf("RegisterHopRoute(ech) error = %v", err)
177
+ hashOnlyRoute := baseRoute
178
+ hashOnlyRoute.MatchHostnameHash = utils.HostnameHash("hash-only.example.com")
179
+ if _, err := registry.RegisterHopRoute(&hashOnlyRoute, now); err == nil {
180
+ t.Fatal("RegisterHopRoute(hash only) error = nil, want error")
181
}
182
if _, ok := registry.Lookup("demo.example.com"); !ok {
183
t.Fatal("Lookup(plain route) = false, want true")
184
}
177
- if _, ok := registry.Lookup(echRoute.RouteHostname); !ok {
185
+ if _, ok := registry.Lookup(route.RouteHostname); !ok {
186
t.Fatal("Lookup(ech route) = false, want true")
187
}
188
leases := registry.PublicLeases(now)
portal/server.go
+2
-2
@@ -807,7 +807,7 @@ func (s *Server) newSelfDescriptor(now time.Time) (types.RelayDescriptor, error)
807
}
808
809
func (s *Server) syncENSGaslessHostname(ctx context.Context, record *leaseRecord) error {
810
- if record == nil || !record.isPublicEntry() || record.Hostname == "" || record.FallbackHostnameHash != "" || s.acmeManager == nil {
810
+ if record == nil || !record.isPublicEntry() || record.FallbackHostnameHash != "" || s.acmeManager == nil {
811
return nil
812
}
813
syncCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
@@ -816,7 +816,7 @@ func (s *Server) syncENSGaslessHostname(ctx context.Context, record *leaseRecord
816
}
817
818
func (s *Server) deleteENSGaslessHostname(ctx context.Context, record *leaseRecord, logMessage string) {
819
- if record == nil || !record.isPublicEntry() || record.Hostname == "" || record.FallbackHostnameHash != "" || s.acmeManager == nil {
819
+ if record == nil || !record.isPublicEntry() || record.FallbackHostnameHash != "" || s.acmeManager == nil {
820
return
821
}
822
deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
sdk/api_client.go
+3
-6
@@ -144,7 +144,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
144
}
145
keylessURL = hopPath[0].APIHTTPSAddr
146
147
- hopRoutes = make([]types.HopRoute, 0, len(hopPath))
147
+ hopRoutes = make([]types.HopRoute, 0, len(hopPath)-1)
148
var previousHopToken string
149
for i := 0; i < len(hopPath)-1; i++ {
150
token, err := l.identity.DeriveToken(
@@ -164,13 +164,10 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
164
ForwardToken: forwardToken,
165
}
166
if i == 0 {
167
+ route.RouteHostname = routeHostname
168
route.MatchHostnameHash = utils.HostnameHash(publicHostname)
169
+ route.Metadata.Hide = true
170
hopRoutes = append(hopRoutes, route)
169
- echRoute := route
170
- echRoute.MatchHostnameHash = ""
171
- echRoute.RouteHostname = routeHostname
172
- echRoute.Metadata.Hide = true
173
- hopRoutes = append(hopRoutes, echRoute)
171
} else {
172
route.MatchToken = previousHopToken
173
hopRoutes = append(hopRoutes, route)
sdk/expose.go
-5
@@ -2,7 +2,6 @@ package sdk
2
3
import (
4
"context"
5
- "encoding/base64"
5
"errors"
6
"fmt"
7
"net"
@@ -432,10 +431,6 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
431
}
432
if lease, ok := listener.leaseSnapshot(); ok {
433
snap.PublicURL = listener.publicURLForLease(lease)
435
- snap.RouteHostname = lease.routeHostname
436
- if len(lease.echConfigList) > 0 {
437
- snap.ECHConfigListBase64 = base64.StdEncoding.EncodeToString(lease.echConfigList)
438
- }
434
}
435
if relayURL != "" {
436
relayByURL[relayURL] = snap
sdk/listener.go
+1
-1
@@ -755,7 +755,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
755
echConfigList = keyless.EncryptedClientHelloConfigList(echKeys)
756
}
757
758
- tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname}, echKeys)
758
+ tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, resp.Hostname, echKeys)
759
if err != nil {
760
_ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
761
if tenantTLSCloser != nil {
types/agent.go
+8
-10
@@ -16,16 +16,14 @@ type AgentTunnelStatus struct {
16
}
17
18
type AgentRelayStatus struct {
19
- RelayURL string `json:"relay_url"`
20
- PublicURL string `json:"public_url,omitempty"`
21
- RouteHostname string `json:"route_hostname,omitempty"`
22
- ECHConfigListBase64 string `json:"ech_config_list_base64,omitempty"`
23
- Connecting bool `json:"connecting"`
24
- Bootstrap bool `json:"bootstrap"`
25
- Banned bool `json:"banned"`
26
- SupportsOverlay bool `json:"supports_overlay"`
27
- SupportsUDP bool `json:"supports_udp"`
28
- SupportsTCP bool `json:"supports_tcp"`
19
+ RelayURL string `json:"relay_url"`
20
+ PublicURL string `json:"public_url,omitempty"`
21
+ Connecting bool `json:"connecting"`
22
+ Bootstrap bool `json:"bootstrap"`
23
+ Banned bool `json:"banned"`
24
+ SupportsOverlay bool `json:"supports_overlay"`
25
+ SupportsUDP bool `json:"supports_udp"`
26
+ SupportsTCP bool `json:"supports_tcp"`
27
}
28
29
type AgentTunnelRequest struct {