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 {