feat: Enhance ECH support and hostname registration

Kim committed May 6, 2026 at 13:40 UTC cc89b05aab6018dd23c3a9d30d2c830378bb2974
17 files changed +490 -129
docs/src/routes/security-model/+page.md
+12 -1
@@ -31,6 +31,16 @@ Relay API TLS is separate from tenant TLS:
31 - Tenant TLS protects end-user traffic for lease hostnames.
32 - The internal QUIC datagram backhaul uses `SNI_PORT/udp` with ALPN `portal-tunnel`.
33
34 +## Tunnel ECH
35 +
36 +For default stream leases, the SDK derives an opaque lease identity and an opaque route hostname from the tunnel identity private key. The relay stores the route hostname for ECH routing and a hash of the public fallback hostname for plaintext-SNI fallback. It does not need the real lease hostname in the new SDK registration path.
37 +
38 +ECH-capable clients can use the opaque route hostname as the outer SNI while the real tenant SNI stays inside the ECH-protected ClientHello handled by the SDK. For multi-hop stream routes, the entry relay gets both matchers: a hostname hash for plaintext-SNI fallback and a hidden opaque route hostname for ECH. After the entry relay chooses the route, the remaining hops continue to use hop tokens and passthrough forwarding.
39 +
40 +This protects the packet-level tunnel SNI only for clients that actually offer ECH using the logged ECHConfigList. Operators must distribute that ECHConfigList through DNS HTTPS/SVCB or another ECH-capable bootstrap. Without that distribution, ordinary clients keep using the public hostname SNI and the relay routes them through the existing plaintext-SNI fallback.
41 +
42 +Legacy clients and raw TCP/UDP transports still use the legacy hostname registration path. On those paths the relay control plane receives the lease hostname and can expose it to admin views.
43 +
44 ## MITM Self-Probe
45
46 `portal expose` runs an asynchronous TLS passthrough self-probe after real tenant traffic starts. The SDK connects to its own public hostname, exports TLS keying material from the client side, recognizes the returning probe after SDK-side TLS termination, and compares exporter values.
@@ -42,7 +52,8 @@ Matching exporter values mean the sampled connection preserved passthrough. A mi
52 | Relays can see | Relays cannot see |
53 |---|---|
54 | Source IP and timing metadata | HTTP headers or body |
45 -| Tunnel hostname/SNI | Tenant TLS session keys |
55 +| Tunnel hostname/SNI on the plaintext-SNI fallback path | Tenant TLS session keys |
56 +| Opaque route hostnames on the ECH path | ECH-protected inner SNI when clients use the distributed ECHConfigList |
57 | Traffic volume and connection duration | Application payload on the stream path |
58 | Requested TCP/UDP transport metadata | Local service plaintext on the tenant TLS stream path |
59 | Raw TCP/UDP payloads when the application protocol is unencrypted | Application-level encrypted raw TCP/UDP payloads |
go.sum
-2
@@ -113,8 +113,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA
113 github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
114 github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
115 github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
116 -github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc h1:aS9LQ35x6EtrGKCmOWRj6Y9aQ2l5hP8dVva4oxB9VEg=
117 -github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
116 github.com/gosuda/keyless_tls v0.0.1 h1:IGuGHxqqxSTJL+7kHPwdoLveXpBbOhBGm/T4gnVTUtU=
117 github.com/gosuda/keyless_tls v0.0.1/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
118 github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
portal/auth/hop_route.go
+2
@@ -77,7 +77,9 @@ func normalizeHopRoute(route types.HopRoute, requireOwner bool) (types.HopRoute,
77
78 route.OwnerPublicKey = ownerPublicKey
79 route.RelayURL = relayURL
80 + route.RouteHostname = utils.NormalizeHostname(route.RouteHostname)
81 route.MatchHostname = utils.NormalizeHostname(route.MatchHostname)
82 + route.MatchHostnameHash = strings.TrimSpace(route.MatchHostnameHash)
83 route.MatchToken = strings.TrimSpace(route.MatchToken)
84 route.Metadata = route.Metadata.Copy()
85 route.ForwardToken = strings.TrimSpace(route.ForwardToken)
portal/auth/register_challenge.go
+8 -6
@@ -49,12 +49,14 @@ func NewRegisterChallenge(req types.RegisterChallengeRequest, domain, uri string
49 }
50
51 normalizedRequest := types.RegisterChallengeRequest{
52 - Identity: normalizedIdentity,
53 - Metadata: req.Metadata.Copy(),
54 - TTL: req.TTL,
55 - UDPEnabled: req.UDPEnabled,
56 - TCPEnabled: req.TCPEnabled,
57 - HopToken: strings.TrimSpace(req.HopToken),
52 + Identity: normalizedIdentity,
53 + Metadata: req.Metadata.Copy(),
54 + TTL: req.TTL,
55 + UDPEnabled: req.UDPEnabled,
56 + TCPEnabled: req.TCPEnabled,
57 + HopToken: strings.TrimSpace(req.HopToken),
58 + RouteHostname: utils.NormalizeHostname(req.RouteHostname),
59 + FallbackHostnameHash: strings.TrimSpace(req.FallbackHostnameHash),
60 }
61
62 return &RegisterChallenge{
portal/keyless/client.go
+10 -5
@@ -13,7 +13,7 @@ import (
13 "github.com/gosuda/portal-tunnel/v2/utils"
14 )
15
16 -func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioCloser, error) {
16 +func BuildClientTLSConfig(relayURL string, domains []string, echKeys []tls.EncryptedClientHelloKey) (*tls.Config, ioCloser, error) {
17 normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
18 if err != nil {
19 return nil, nil, err
@@ -53,11 +53,16 @@ func BuildClientTLSConfig(relayURL string, domains []string) (*tls.Config, ioClo
53 return nil, nil, fmt.Errorf("create keyless remote signer: %w", err)
54 }
55
56 + minVersion := uint16(tls.VersionTLS12)
57 + if len(echKeys) > 0 {
58 + minVersion = tls.VersionTLS13
59 + }
60 tlsConfig, err := keylesstls.NewServerTLSConfig(keylesstls.ServerTLSConfig{
57 - CertPEM: certPEM,
58 - Signer: remoteSigner,
59 - NextProtos: []string{"http/1.1"},
60 - MinVersion: tls.VersionTLS12,
61 + CertPEM: certPEM,
62 + Signer: remoteSigner,
63 + NextProtos: []string{"http/1.1"},
64 + MinVersion: minVersion,
65 + EncryptedClientHelloKeys: echKeys,
66 })
67 if err != nil {
68 _ = remoteSigner.Close()
portal/keyless/ech.go
+17
@@ -92,3 +92,20 @@ func EncryptedClientHelloKeys(siwePrivateKey, seed, publicName string) ([]tls.En
92 SendAsRetry: true,
93 }}, nil
94 }
95 +
96 +func EncryptedClientHelloConfigList(keys []tls.EncryptedClientHelloKey) []byte {
97 + var configs bytes.Buffer
98 + for _, key := range keys {
99 + configs.Write(key.Config)
100 + }
101 +
102 + var out bytes.Buffer
103 + writeUint16 := func(buf *bytes.Buffer, value uint16) {
104 + var encoded [2]byte
105 + binary.BigEndian.PutUint16(encoded[:], value)
106 + buf.Write(encoded[:])
107 + }
108 + writeUint16(&out, uint16(configs.Len()))
109 + out.Write(configs.Bytes())
110 + return out.Bytes()
111 +}
portal/lease.go
+163 -51
@@ -97,6 +97,15 @@ func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
97 return record, true
98 }
99 }
100 + hostHash := utils.HostnameHash(host)
101 + for _, record := range r.records {
102 + if record == nil || !record.isPublicEntry() || record.isExpired(now) {
103 + continue
104 + }
105 + if record.FallbackHostnameHash != "" && record.FallbackHostnameHash == hostHash {
106 + return record, true
107 + }
108 + }
109 for _, record := range r.records {
110 if record == nil || !record.isPublicEntry() || record.isExpired(now) {
111 continue
@@ -150,14 +159,29 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
159 }
160
161 identityKey := identity.Key()
153 - hostname, err := utils.LeaseHostname(identity.Name, r.rootHostname)
154 - if err != nil {
155 - return nil, types.RegisterResponse{}, err
156 - }
162 hopToken := strings.TrimSpace(req.HopToken)
163 + routeHostname := utils.NormalizeHostname(req.RouteHostname)
164 + fallbackHostnameHash := strings.TrimSpace(req.FallbackHostnameHash)
165 if hopToken != "" && (req.UDPEnabled || req.TCPEnabled) {
166 return nil, types.RegisterResponse{}, errTransportMismatch
167 }
168 + if (routeHostname != "" || fallbackHostnameHash != "") && (hopToken != "" || req.UDPEnabled || req.TCPEnabled) {
169 + return nil, types.RegisterResponse{}, errTransportMismatch
170 + }
171 + if routeHostname != "" {
172 + routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
173 + normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
174 + if !ok || labelErr != nil || normalizedRouteLabel != routeLabel || routeBase != utils.NormalizeHostname(r.rootHostname) {
175 + return nil, types.RegisterResponse{}, errors.New("route hostname must be a child of relay root hostname")
176 + }
177 + }
178 + hostname := routeHostname
179 + if hostname == "" && fallbackHostnameHash == "" && hopToken == "" {
180 + hostname, err = utils.LeaseHostname(identity.Name, r.rootHostname)
181 + if err != nil {
182 + return nil, types.RegisterResponse{}, err
183 + }
184 + }
185 if req.UDPEnabled {
186 if !r.policy.IsUDPEnabled() {
187 return nil, types.RegisterResponse{}, errUDPDisabled
@@ -181,16 +205,17 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
205
206 stream := transport.NewRelayStream(identityKey, defaultIdleKeepalive, defaultReadyQueueLimit)
207 record := &leaseRecord{
184 - Identity: identity,
185 - Hostname: hostname,
186 - Metadata: req.Metadata,
187 - ExpiresAt: expiresAt,
188 - FirstSeenAt: issuedAt,
189 - LastSeenAt: issuedAt,
190 - ClientIP: clientIP,
191 - ReportedIP: utils.SanitizeReportedIP(reportedIP),
192 - hopToken: hopToken,
193 - stream: stream,
208 + Identity: identity,
209 + Hostname: hostname,
210 + FallbackHostnameHash: fallbackHostnameHash,
211 + Metadata: req.Metadata,
212 + ExpiresAt: expiresAt,
213 + FirstSeenAt: issuedAt,
214 + LastSeenAt: issuedAt,
215 + ClientIP: clientIP,
216 + ReportedIP: utils.SanitizeReportedIP(reportedIP),
217 + hopToken: hopToken,
218 + stream: stream,
219 }
220
221 if req.UDPEnabled {
@@ -258,10 +283,18 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
283 tcpLeases++
284 }
285 }
261 - if existing.isPublicEntry() && existing.Hostname == hostname && existingKey != identityKey {
262 - r.mu.Unlock()
263 - record.Close()
264 - return nil, types.RegisterResponse{}, errHostnameConflict
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 + }
298 }
299 if hopToken != "" && (existing.isHopMiddle() || existing.isHopExit()) && existing.hopToken == hopToken && existingKey != identityKey {
300 r.mu.Unlock()
@@ -285,8 +318,16 @@ func (r *leaseRegistry) Register(req types.RegisterChallengeRequest, clientIP, r
318 }
319 for i := 0; i < len(r.records); i++ {
320 existing := r.records[i]
288 - if existing != nil && existing.stream == nil && existing.isPublicEntry() &&
289 - existing.Hostname == hostname && existing.Key() == identityKey {
321 + if existing == nil || existing.stream != nil || !existing.isPublicEntry() || existing.Key() != identityKey {
322 + continue
323 + }
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 {
331 r.deleteRecord(i)
332 i--
333 }
@@ -427,7 +468,15 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
468 return nil, err
469 }
470 matchHostname := utils.NormalizeHostname(route.MatchHostname)
471 + routeHostname := utils.NormalizeHostname(route.RouteHostname)
472 + matchHostnameHash := strings.TrimSpace(route.MatchHostnameHash)
473 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 + }
480 overlayIPv4, overlayErr := utils.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
481 forwardToken := strings.TrimSpace(route.ForwardToken)
482 expiresAt := route.ExpiresAt.UTC()
@@ -437,17 +486,31 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
486 return nil, errFeatureUnavailable
487 case !expiresAt.After(now):
488 return nil, errors.New("route expiry must be in the future")
440 - case matchHostname == "" && matchToken == "":
441 - return nil, errors.New("hostname or token matcher is required")
442 - case matchHostname != "" && matchToken != "":
443 - return nil, errors.New("hostname and token matchers are mutually exclusive")
489 + case matchers != 1:
490 + return nil, errors.New("exactly one route hostname, hostname hash, hostname, or token matcher is required")
491 case overlayErr != nil:
492 return nil, fmt.Errorf("forward relay overlay ipv4: %w", overlayErr)
493 case forwardToken == "":
494 return nil, errors.New("forward token is required")
495 }
449 - name := matchHostname
450 - if label, _, ok := strings.Cut(matchHostname, "."); ok {
496 + if routeHostname != "" {
497 + routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
498 + normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
499 + if !ok || labelErr != nil || normalizedRouteLabel != routeLabel || routeBase != utils.NormalizeHostname(r.rootHostname) {
500 + return nil, errors.New("route hostname must be a child of relay root hostname")
501 + }
502 + }
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 + }
513 + if label, _, ok := strings.Cut(name, "."); ok {
514 name = label
515 }
516
@@ -459,13 +522,14 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
522 Name: name,
523 Address: ownerKey,
524 },
462 - Hostname: matchHostname,
463 - Metadata: route.Metadata.Copy(),
464 - FirstSeenAt: route.FirstSeenAt.UTC(),
465 - ExpiresAt: expiresAt,
466 - hopToken: matchToken,
467 - hopNextOverlayIPv4: overlayIPv4,
468 - hopNextToken: forwardToken,
525 + Hostname: utils.StringOrDefault(routeHostname, matchHostname),
526 + FallbackHostnameHash: matchHostnameHash,
527 + Metadata: route.Metadata.Copy(),
528 + FirstSeenAt: route.FirstSeenAt.UTC(),
529 + ExpiresAt: expiresAt,
530 + hopToken: matchToken,
531 + hopNextOverlayIPv4: overlayIPv4,
532 + hopNextToken: forwardToken,
533 }
534 switch {
535 case record.isPublicEntry():
@@ -473,7 +537,13 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
537 if existing == nil || !existing.isPublicEntry() || existing.isExpired(now) {
538 continue
539 }
476 - if existing.Hostname != record.Hostname {
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 {
547 continue
548 }
549 if existing.stream != nil || !strings.EqualFold(existing.Address, record.Address) {
@@ -481,9 +551,16 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) (
551 }
552 }
553 for i, existing := range r.records {
484 - if existing != nil && existing.stream == nil && existing.isPublicEntry() &&
485 - existing.Hostname == record.Hostname &&
486 - strings.EqualFold(existing.Address, record.Address) {
554 + if existing == nil || existing.stream != nil || !existing.isPublicEntry() || !strings.EqualFold(existing.Address, record.Address) {
555 + continue
556 + }
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 {
564 r.records[i] = record
565 return record, nil
566 }
@@ -520,6 +597,8 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
597 return nil
598 }
599 hostname := utils.NormalizeHostname(route.MatchHostname)
600 + routeHostname := utils.NormalizeHostname(route.RouteHostname)
601 + hostnameHash := strings.TrimSpace(route.MatchHostnameHash)
602 token := strings.TrimSpace(route.MatchToken)
603
604 var deleted *leaseRecord
@@ -530,13 +609,23 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
609 continue
610 }
611 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 != "" {
534 - deleteRecord = record.isPublicEntry() &&
623 + deleteRecord = deleteRecord || record.isPublicEntry() &&
624 record.Hostname == hostname &&
625 strings.EqualFold(record.Address, ownerKey)
626 }
627 if token != "" {
539 - deleteRecord = record.isHopMiddle() &&
628 + deleteRecord = deleteRecord || record.isHopMiddle() &&
629 record.hopToken == token &&
630 strings.EqualFold(record.Address, ownerKey)
631 }
@@ -552,9 +641,22 @@ func (r *leaseRegistry) DeleteHopRoute(route *types.HopRoute) *leaseRecord {
641 }
642
643 func (r *leaseRegistry) issueRegisterChallenge(req types.RegisterChallengeRequest, domain, uri, clientIP string) (types.RegisterChallengeResponse, error) {
555 - if strings.TrimSpace(req.HopToken) != "" && (req.UDPEnabled || req.TCPEnabled) {
644 + hopToken := strings.TrimSpace(req.HopToken)
645 + routeHostname := utils.NormalizeHostname(req.RouteHostname)
646 + fallbackHostnameHash := strings.TrimSpace(req.FallbackHostnameHash)
647 + if hopToken != "" && (req.UDPEnabled || req.TCPEnabled) {
648 return types.RegisterChallengeResponse{}, errTransportMismatch
649 }
650 + if (routeHostname != "" || fallbackHostnameHash != "") && (hopToken != "" || req.UDPEnabled || req.TCPEnabled) {
651 + return types.RegisterChallengeResponse{}, errTransportMismatch
652 + }
653 + if routeHostname != "" {
654 + routeLabel, routeBase, ok := strings.Cut(routeHostname, ".")
655 + normalizedRouteLabel, labelErr := utils.NormalizeDNSLabel(routeLabel)
656 + if !ok || labelErr != nil || normalizedRouteLabel != routeLabel || routeBase != utils.NormalizeHostname(r.rootHostname) {
657 + return types.RegisterChallengeResponse{}, errors.New("route hostname must be a child of relay root hostname")
658 + }
659 + }
660 if req.UDPEnabled {
661 if !r.policy.IsUDPEnabled() {
662 return types.RegisterChallengeResponse{}, errUDPDisabled
@@ -689,6 +791,9 @@ func (r *leaseRegistry) PublicLeases(now time.Time) []types.Lease {
791 if record.Metadata.Hide {
792 continue
793 }
794 + if record.Hostname == "" {
795 + continue
796 + }
797 if record.stream != nil {
798 identityKey := record.Key()
799 if r.policy.IsIdentityBanned(identityKey) || r.policy.IsIdentityDenied(identityKey) || !r.policy.EffectiveApproval(identityKey) {
@@ -742,12 +847,18 @@ func (r *leaseRegistry) deleteRecord(i int) {
847 }
848
849 func (r *leaseRegistry) publicLease(record *leaseRecord) types.Lease {
850 + name := record.Name
851 + hostname := record.Hostname
852 + if record.FallbackHostnameHash != "" && record.Hostname != "" {
853 + label, _, _ := strings.Cut(record.Hostname, ".")
854 + name = label
855 + }
856 lease := types.Lease{
746 - Name: record.Name,
857 + Name: name,
858 ExpiresAt: record.ExpiresAt,
859 FirstSeenAt: record.FirstSeenAt,
860 LastSeenAt: record.LastSeenAt,
750 - Hostname: record.Hostname,
861 + Hostname: hostname,
862 UDPEnabled: record.datagram != nil,
863 TCPEnabled: record.tcpPort != nil,
864 Metadata: record.Metadata.Copy(),
@@ -769,13 +880,14 @@ func (r *leaseRegistry) publicLease(record *leaseRecord) types.Lease {
880
881 type leaseRecord struct {
882 types.Identity
772 - ExpiresAt time.Time
773 - FirstSeenAt time.Time
774 - LastSeenAt time.Time
775 - ClientIP string
776 - ReportedIP string
777 - Hostname string
778 - Metadata types.LeaseMetadata
883 + ExpiresAt time.Time
884 + FirstSeenAt time.Time
885 + LastSeenAt time.Time
886 + ClientIP string
887 + ReportedIP string
888 + Hostname string
889 + FallbackHostnameHash string
890 + Metadata types.LeaseMetadata
891
892 hopToken string
893 hopNextOverlayIPv4 string
@@ -790,7 +902,7 @@ type leaseRecord struct {
902 }
903
904 func (r *leaseRecord) isPublicEntry() bool {
793 - return r != nil && r.Hostname != "" && r.hopToken == ""
905 + return r != nil && r.hopToken == "" && (r.Hostname != "" || r.FallbackHostnameHash != "")
906 }
907
908 func (r *leaseRecord) isHopMiddle() bool {
@@ -800,7 +912,7 @@ func (r *leaseRecord) isHopMiddle() bool {
912
913 func (r *leaseRecord) isHopExit() bool {
914 _, _, hasNextHop := r.nextHop()
803 - return r != nil && r.Hostname != "" && r.hopToken != "" && !hasNextHop
915 + return r != nil && r.hopToken != "" && !hasNextHop
916 }
917
918 func (r *leaseRecord) nextHop() (string, string, bool) {
portal/lease_test.go
+93
@@ -90,6 +90,99 @@ func TestLeaseRegistryLifecycle(t *testing.T) {
90 }
91 }
92
93 +func TestLeaseRegistryAutomaticECHRouteFallsBackToPlainSNI(t *testing.T) {
94 + t.Parallel()
95 +
96 + registry := newTestRegistry(t)
97 + routeHostname := "ech-auto-ech.example.com"
98 + publicHostname := "auto-ech.example.com"
99 + record, registered, err := registry.Register(types.RegisterChallengeRequest{
100 + Identity: newTestLeaseIdentity(t, "auto-ech"),
101 + RouteHostname: routeHostname,
102 + FallbackHostnameHash: utils.HostnameHash(publicHostname),
103 + }, "203.0.113.10", "")
104 + if err != nil {
105 + t.Fatalf("Register() error = %v", err)
106 + }
107 + if registered.Hostname != routeHostname {
108 + t.Fatalf("Register() hostname = %q, want route hostname %q", registered.Hostname, routeHostname)
109 + }
110 + if registered.Hostname == publicHostname {
111 + t.Fatalf("Register() hostname = public hostname = %q", registered.Hostname)
112 + }
113 + if lookedUp, ok := registry.Lookup(publicHostname); !ok || lookedUp != record {
114 + t.Fatalf("Lookup(public hostname) = %v, %v, want fallback lease", lookedUp, ok)
115 + }
116 + lookedUp, ok := registry.Lookup(registered.Hostname)
117 + if !ok || lookedUp != record {
118 + t.Fatalf("Lookup(route hostname) = %v, %v, want registered lease", lookedUp, ok)
119 + }
120 + leases := registry.PublicLeases(time.Now())
121 + publicHostnameFromLease := ""
122 + if len(leases) == 1 {
123 + publicHostnameFromLease = leases[0].Hostname
124 + }
125 + if len(leases) != 1 || publicHostnameFromLease != registered.Hostname {
126 + t.Fatalf("PublicLeases() hostname = len %d, host %q, want len 1, host %q", len(leases), publicHostnameFromLease, registered.Hostname)
127 + }
128 +
129 + adminLeases := registry.AdminLeases(time.Now())
130 + if len(adminLeases) != 1 {
131 + t.Fatalf("AdminLeases() length = %d, want 1", len(adminLeases))
132 + }
133 + if adminLeases[0].Hostname != registered.Hostname {
134 + t.Fatalf("AdminLeases()[0] hostname = %q, want %q", adminLeases[0].Hostname, registered.Hostname)
135 + }
136 +}
137 +
138 +func TestLeaseRegistryHopRouteCanExposeECHAndPlainSNIFallback(t *testing.T) {
139 + t.Parallel()
140 +
141 + registry := newTestRegistry(t)
142 + owner := newTestLeaseIdentity(t, "multi-hop-owner")
143 + wgPrivate, err := utils.GenerateWireGuardPrivateKey()
144 + if err != nil {
145 + t.Fatalf("GenerateWireGuardPrivateKey() error = %v", err)
146 + }
147 + wgPublic, err := utils.WireGuardPublicKeyFromPrivate(wgPrivate)
148 + if err != nil {
149 + t.Fatalf("WireGuardPublicKeyFromPrivate() error = %v", err)
150 + }
151 + now := time.Now()
152 + baseRoute := types.HopRoute{
153 + OwnerPublicKey: owner.PublicKey,
154 + ForwardRelay: types.RelayDescriptor{
155 + APIHTTPSAddr: "https://next.example.com",
156 + WireGuardPublicKey: wgPublic,
157 + },
158 + ForwardToken: "hpt_forward",
159 + FirstSeenAt: now,
160 + ExpiresAt: now.Add(time.Minute),
161 + }
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
167 +
168 + if _, err := registry.RegisterHopRoute(&plainRoute, now); err != nil {
169 + t.Fatalf("RegisterHopRoute(plain) error = %v", err)
170 + }
171 + if _, err := registry.RegisterHopRoute(&echRoute, now); err != nil {
172 + t.Fatalf("RegisterHopRoute(ech) error = %v", err)
173 + }
174 + if _, ok := registry.Lookup("demo.example.com"); !ok {
175 + t.Fatal("Lookup(plain route) = false, want true")
176 + }
177 + if _, ok := registry.Lookup(echRoute.RouteHostname); !ok {
178 + t.Fatal("Lookup(ech route) = false, want true")
179 + }
180 + leases := registry.PublicLeases(now)
181 + if len(leases) != 0 {
182 + t.Fatalf("PublicLeases() length = %d, want 0 for hostname-minimized hop routes", len(leases))
183 + }
184 +}
185 +
186 func TestLeaseRegistryWildcardAndConflict(t *testing.T) {
187 t.Parallel()
188
portal/server.go
+4 -4
@@ -321,7 +321,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
321 Bool("multihop_enabled", s.hopMux != nil).
322 Bool("udp_enabled", s.quicBackhaul != nil).
323 Bool("tcp_enabled", s.cfg.TCPEnabled).
324 - Bool("ech_enabled", len(apiTLS.EncryptedClientHelloKeys) > 0).
324 + Bool("api_ech_enabled", len(apiTLS.EncryptedClientHelloKeys) > 0).
325 Bool("pprof_enabled", s.pprofServer != nil)
326 if s.pprofListener != nil {
327 logEvent = logEvent.Str("pprof_addr", utils.HostPortOrLoopback(s.pprofListener.Addr().String()))
@@ -529,7 +529,7 @@ func (s *Server) runPublicIngress(ctx context.Context) error {
529 return
530 }
531 if err := s.bridgeLeaseConn(ctx, wrappedConn, record); err != nil {
532 - log.Warn().Err(err).Str("server_name", serverName).Msg("bridge public ingress")
532 + log.Warn().Err(err).Msg("bridge public ingress")
533 _ = wrappedConn.Close()
534 return
535 }
@@ -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() || s.acmeManager == nil {
810 + if record == nil || !record.isPublicEntry() || record.Hostname == "" || 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() || s.acmeManager == nil {
819 + if record == nil || !record.isPublicEntry() || record.Hostname == "" || record.FallbackHostnameHash != "" || s.acmeManager == nil {
820 return
821 }
822 deleteCtx, cancel := context.WithTimeout(ctx, defaultClaimTimeout)
sdk/api_client.go
+89 -23
@@ -2,6 +2,8 @@ package sdk
2
3 import (
4 "context"
5 + "crypto/sha256"
6 + "encoding/base32"
7 "errors"
8 "fmt"
9 "net"
@@ -85,17 +87,33 @@ func (l *listener) initHTTPTransport(ctx context.Context) error {
87 return nil
88 }
89
88 -func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, []types.HopRoute, error) {
90 +func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, []types.HopRoute, string, error) {
91 var exitHopToken string
92 var publicHostname string
93 var keylessURL string
94 + var routeHostname string
95 + var registerRouteHostname string
96 + var registerFallbackHostnameHash string
97 var hopRoutes []types.HopRoute
98 + registerIdentity := l.identity
99 + if !udpEnabled && !tcpEnabled {
100 + token, err := l.identity.DeriveToken("opaque-lease-name", l.identity.Name)
101 + if err != nil {
102 + return types.RegisterResponse{}, nil, "", fmt.Errorf("derive opaque lease name: %w", err)
103 + }
104 + sum := sha256.Sum256([]byte(token))
105 + label := "lid-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:20]))
106 + registerIdentity.Name, err = utils.NormalizeDNSLabel(label)
107 + if err != nil {
108 + return types.RegisterResponse{}, nil, "", fmt.Errorf("derive opaque lease name: %w", err)
109 + }
110 + }
111 if len(l.multiHop) > 0 {
112 if len(l.multiHop) < 2 {
95 - return types.RegisterResponse{}, nil, errors.New("multi-hop requires at least entry and exit relay urls")
113 + return types.RegisterResponse{}, nil, "", errors.New("multi-hop requires at least entry and exit relay urls")
114 }
115 if l.relaySet == nil {
98 - return types.RegisterResponse{}, nil, errors.New("multi-hop relay set is unavailable")
116 + return types.RegisterResponse{}, nil, "", errors.New("multi-hop relay set is unavailable")
117 }
118
119 now := time.Now().UTC()
@@ -103,19 +121,30 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
121 for i, relayURL := range l.multiHop {
122 desc, ok := l.relaySet.OverlayRelayDescriptor(relayURL, now)
123 if !ok {
106 - return types.RegisterResponse{}, nil, fmt.Errorf("multi-hop relay %d descriptor is unavailable", i)
124 + return types.RegisterResponse{}, nil, "", fmt.Errorf("multi-hop relay %d descriptor is unavailable", i)
125 }
126 hopPath = append(hopPath, desc)
127 }
128
129 var err error
112 - publicHostname, err = utils.LeaseHostname(l.identity.Name, utils.PortalRootHost(hopPath[0].APIHTTPSAddr))
130 + entryRootHostname := utils.PortalRootHost(hopPath[0].APIHTTPSAddr)
131 + publicHostname, err = utils.LeaseHostname(l.identity.Name, entryRootHostname)
132 if err != nil {
114 - return types.RegisterResponse{}, nil, err
133 + return types.RegisterResponse{}, nil, "", err
134 + }
135 + routeToken, err := l.identity.DeriveToken("ech-route", publicHostname, entryRootHostname)
136 + if err != nil {
137 + return types.RegisterResponse{}, nil, "", err
138 + }
139 + routeSum := sha256.Sum256([]byte(routeToken))
140 + routeLabel := "ech-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(routeSum[:20]))
141 + routeHostname, err = utils.LeaseHostname(routeLabel, entryRootHostname)
142 + if err != nil {
143 + return types.RegisterResponse{}, nil, "", err
144 }
145 keylessURL = hopPath[0].APIHTTPSAddr
146
118 - hopRoutes = make([]types.HopRoute, 0, len(hopPath)-1)
147 + hopRoutes = make([]types.HopRoute, 0, len(hopPath))
148 var previousHopToken string
149 for i := 0; i < len(hopPath)-1; i++ {
150 token, err := l.identity.DeriveToken(
@@ -126,7 +155,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
155 hopPath[i+1].APIHTTPSAddr,
156 )
157 if err != nil {
129 - return types.RegisterResponse{}, nil, err
158 + return types.RegisterResponse{}, nil, "", err
159 }
160 forwardToken := "hpt_" + token
161 route := types.HopRoute{
@@ -135,32 +164,58 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
164 ForwardToken: forwardToken,
165 }
166 if i == 0 {
138 - route.MatchHostname = publicHostname
139 - route.Metadata = l.metadata
167 + route.MatchHostnameHash = utils.HostnameHash(publicHostname)
168 + hopRoutes = append(hopRoutes, route)
169 + echRoute := route
170 + echRoute.MatchHostnameHash = ""
171 + echRoute.RouteHostname = routeHostname
172 + echRoute.Metadata.Hide = true
173 + hopRoutes = append(hopRoutes, echRoute)
174 } else {
175 route.MatchToken = previousHopToken
176 + hopRoutes = append(hopRoutes, route)
177 }
143 - hopRoutes = append(hopRoutes, route)
178 previousHopToken = forwardToken
179 }
180 exitHopToken = previousHopToken
181 + } else if !udpEnabled && !tcpEnabled {
182 + rootHostname := utils.PortalRootHost(l.relayURL.String())
183 + var err error
184 + publicHostname, err = utils.LeaseHostname(l.identity.Name, rootHostname)
185 + if err != nil {
186 + return types.RegisterResponse{}, nil, "", err
187 + }
188 + routeToken, err := l.identity.DeriveToken("ech-route", publicHostname, rootHostname)
189 + if err != nil {
190 + return types.RegisterResponse{}, nil, "", err
191 + }
192 + routeSum := sha256.Sum256([]byte(routeToken))
193 + routeLabel := "ech-" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(routeSum[:20]))
194 + routeHostname, err = utils.LeaseHostname(routeLabel, rootHostname)
195 + if err != nil {
196 + return types.RegisterResponse{}, nil, "", err
197 + }
198 + registerRouteHostname = routeHostname
199 + registerFallbackHostnameHash = utils.HostnameHash(publicHostname)
200 }
201
202 var challenge types.RegisterChallengeResponse
203 if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRegisterChallenge, types.RegisterChallengeRequest{
151 - Identity: l.identity,
152 - Metadata: l.metadata,
153 - TTL: int(ttl / time.Second),
154 - UDPEnabled: udpEnabled,
155 - TCPEnabled: tcpEnabled,
156 - HopToken: exitHopToken,
204 + Identity: registerIdentity,
205 + Metadata: l.metadata,
206 + TTL: int(ttl / time.Second),
207 + UDPEnabled: udpEnabled,
208 + TCPEnabled: tcpEnabled,
209 + HopToken: exitHopToken,
210 + RouteHostname: registerRouteHostname,
211 + FallbackHostnameHash: registerFallbackHostnameHash,
212 }, nil, &challenge); err != nil {
158 - return types.RegisterResponse{}, nil, err
213 + return types.RegisterResponse{}, nil, "", err
214 }
215
216 signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, l.identity.PrivateKey)
217 if err != nil {
163 - return types.RegisterResponse{}, nil, err
218 + return types.RegisterResponse{}, nil, "", err
219 }
220
221 var resp types.RegisterResponse
@@ -170,17 +225,28 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
225 SIWESignature: signature,
226 ReportedIP: utils.ResolvePublicIP(ctx),
227 }, nil, &resp); err != nil {
173 - return types.RegisterResponse{}, nil, err
228 + return types.RegisterResponse{}, nil, "", err
229 + }
230 + registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
231 + if err != nil {
232 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
233 + return types.RegisterResponse{}, nil, "", err
234 + }
235 + if registeredIdentity.Key() != registerIdentity.Key() {
236 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
237 + return types.RegisterResponse{}, nil, "", errors.New("relay returned mismatched lease identity")
238 }
239 if len(hopRoutes) > 0 {
240 if err := l.syncHopRoutes(ctx, http.MethodPost, resp.ExpiresAt, hopRoutes); err != nil {
241 _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
178 - return types.RegisterResponse{}, nil, err
242 + return types.RegisterResponse{}, nil, "", err
243 }
180 - resp.Hostname = publicHostname
244 resp.KeylessURL = keylessURL
245 }
183 - return resp, hopRoutes, nil
246 + if publicHostname != "" {
247 + resp.Hostname = publicHostname
248 + }
249 + return resp, hopRoutes, routeHostname, nil
250 }
251
252 func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string, hopRoutes []types.HopRoute) (types.RenewResponse, error) {
sdk/expose.go
+5
@@ -2,6 +2,7 @@ package sdk
2
3 import (
4 "context"
5 + "encoding/base64"
6 "errors"
7 "fmt"
8 "net"
@@ -431,6 +432,10 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
432 }
433 if lease, ok := listener.leaseSnapshot(); ok {
434 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 + }
439 }
440 if relayURL != "" {
441 relayByURL[relayURL] = snap
sdk/listener.go
+35 -11
@@ -5,6 +5,7 @@ import (
5 "bytes"
6 "context"
7 "crypto/tls"
8 + "encoding/base64"
9 "errors"
10 "fmt"
11 "io"
@@ -170,10 +171,21 @@ func (l *listener) run(ctx context.Context) {
171
172 retries = 0
173 publicURL := ""
174 + routeHostname := ""
175 + echConfigList := ""
176 if lease, ok := l.leaseSnapshot(); ok {
177 publicURL = l.publicURLForLease(lease)
178 + routeHostname = lease.routeHostname
179 + if len(lease.echConfigList) > 0 {
180 + echConfigList = base64.StdEncoding.EncodeToString(lease.echConfigList)
181 + }
182 }
183 event := log.Info().Str("address", l.identity.Address)
184 + if echConfigList != "" {
185 + event = event.
186 + Str("route_hostname", routeHostname).
187 + Str("ech_config_list_base64", echConfigList)
188 + }
189 if publicURL != "" {
190 event.Msg("service ready at " + publicURL)
191 } else {
@@ -242,6 +254,8 @@ func (l *listener) Close() error {
254
255 type listenerLease struct {
256 hostname string
257 + routeHostname string
258 + echConfigList []byte
259 udpAddr string
260 tcpAddr string
261 accessToken string
@@ -695,7 +709,7 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
709 return err
710 }
711
698 - resp, hopRoutes, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
712 + resp, hopRoutes, routeHostname, err := l.registerLease(ctx, l.leaseTTL, l.udpEnabled, l.tcpEnabled)
713 if err != nil {
714 return err
715 }
@@ -703,15 +717,6 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
717 if resp.AccessToken == "" {
718 return errors.New("relay did not return access token")
719 }
706 - registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
707 - if err != nil {
708 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
709 - return err
710 - }
711 - if registeredIdentity.Key() != l.identity.Key() {
712 - _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
713 - return errors.New("relay returned mismatched lease identity")
714 - }
720 if l.udpEnabled && !resp.UDPEnabled {
721 _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
722 return &types.APIRequestError{
@@ -733,7 +738,24 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
738 publicURLBase = parsedKeylessURL
739 }
740 }
736 - tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname})
741 + var echKeys []tls.EncryptedClientHelloKey
742 + var echConfigList []byte
743 + routeHostname = utils.NormalizeHostname(routeHostname)
744 + if routeHostname != "" {
745 + echSeed, err := l.identity.DeriveToken("tenant-ech", resp.Hostname, routeHostname)
746 + if err != nil {
747 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
748 + return fmt.Errorf("derive tenant ech seed: %w", err)
749 + }
750 + echKeys, err = keyless.EncryptedClientHelloKeys(l.identity.PrivateKey, echSeed, routeHostname)
751 + if err != nil {
752 + _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
753 + return fmt.Errorf("prepare tenant ech keys: %w", err)
754 + }
755 + echConfigList = keyless.EncryptedClientHelloConfigList(echKeys)
756 + }
757 +
758 + tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(keylessURL, []string{resp.Hostname}, echKeys)
759 if err != nil {
760 _ = l.unregisterLease(context.Background(), resp.AccessToken, hopRoutes)
761 if tenantTLSCloser != nil {
@@ -751,6 +773,8 @@ func (l *listener) registerAndConfigure(ctx context.Context) error {
773 }
774 next := &listenerLease{
775 hostname: resp.Hostname,
776 + routeHostname: routeHostname,
777 + echConfigList: echConfigList,
778 udpAddr: resp.UDPAddr,
779 tcpAddr: resp.TCPAddr,
780 accessToken: resp.AccessToken,
sdk/mitm.go
+7 -1
@@ -115,8 +115,14 @@ func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport,
115 ServerName: lease.hostname,
116 InsecureSkipVerify: true,
117 }
118 + if len(lease.echConfigList) > 0 {
119 + probeTLSConf.MinVersion = tls.VersionTLS13
120 + probeTLSConf.EncryptedClientHelloConfigList = append([]byte(nil), lease.echConfigList...)
121 + }
122 if lease.tlsConfig != nil {
119 - probeTLSConf.MinVersion = lease.tlsConfig.MinVersion
123 + if probeTLSConf.MinVersion == 0 || lease.tlsConfig.MinVersion > probeTLSConf.MinVersion {
124 + probeTLSConf.MinVersion = lease.tlsConfig.MinVersion
125 + }
126 probeTLSConf.MaxVersion = lease.tlsConfig.MaxVersion
127 if len(lease.tlsConfig.NextProtos) > 0 {
128 probeTLSConf.NextProtos = append([]string(nil), lease.tlsConfig.NextProtos...)
types/agent.go
+10 -8
@@ -16,14 +16,16 @@ type AgentTunnelStatus struct {
16 }
17
18 type AgentRelayStatus struct {
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"`
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"`
29 }
30
31 type AgentTunnelRequest struct {
types/api.go
+24 -16
@@ -64,12 +64,14 @@ type RegisterRequest struct {
64 }
65
66 type RegisterChallengeRequest struct {
67 - Identity Identity `json:"identity"`
68 - Metadata LeaseMetadata `json:"metadata"`
69 - TTL int `json:"ttl,omitempty"`
70 - UDPEnabled bool `json:"udp_enabled,omitempty"`
71 - TCPEnabled bool `json:"tcp_enabled,omitempty"`
72 - HopToken string `json:"hop_token,omitempty"`
67 + Identity Identity `json:"identity"`
68 + Metadata LeaseMetadata `json:"metadata"`
69 + TTL int `json:"ttl,omitempty"`
70 + UDPEnabled bool `json:"udp_enabled,omitempty"`
71 + TCPEnabled bool `json:"tcp_enabled,omitempty"`
72 + HopToken string `json:"hop_token,omitempty"`
73 + RouteHostname string `json:"route_hostname,omitempty"`
74 + FallbackHostnameHash string `json:"fallback_hostname_hash,omitempty"`
75 }
76
77 type RegisterChallengeResponse struct {
@@ -123,16 +125,18 @@ type UnregisterRequest struct {
125 }
126
127 type HopRoute struct {
126 - OwnerPublicKey string `json:"owner_public_key,omitempty"`
127 - RelayURL string `json:"relay_url"`
128 - MatchHostname string `json:"match_hostname,omitempty"`
129 - MatchToken string `json:"match_token,omitempty"`
130 - Metadata LeaseMetadata `json:"metadata,omitempty"`
131 - ForwardRelay RelayDescriptor `json:"forward_relay"`
132 - ForwardToken string `json:"forward_token"`
133 - FirstSeenAt time.Time `json:"first_seen_at,omitempty"`
134 - ExpiresAt time.Time `json:"expires_at,omitempty"`
135 - Signature string `json:"signature,omitempty"`
128 + OwnerPublicKey string `json:"owner_public_key,omitempty"`
129 + RelayURL string `json:"relay_url"`
130 + RouteHostname string `json:"route_hostname,omitempty"`
131 + MatchHostname string `json:"match_hostname,omitempty"`
132 + MatchHostnameHash string `json:"match_hostname_hash,omitempty"`
133 + MatchToken string `json:"match_token,omitempty"`
134 + Metadata LeaseMetadata `json:"metadata,omitempty"`
135 + ForwardRelay RelayDescriptor `json:"forward_relay"`
136 + ForwardToken string `json:"forward_token"`
137 + FirstSeenAt time.Time `json:"first_seen_at,omitempty"`
138 + ExpiresAt time.Time `json:"expires_at,omitempty"`
139 + Signature string `json:"signature,omitempty"`
140 }
141
142 func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
@@ -145,7 +149,9 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
149 Method string `json:"method"`
150 OwnerPublicKey string `json:"owner_public_key"`
151 RelayURL string `json:"relay_url"`
152 + RouteHostname string `json:"route_hostname"`
153 MatchHostname string `json:"match_hostname"`
154 + MatchHostnameHash string `json:"match_hostname_hash"`
155 MatchToken string `json:"match_token"`
156 ForwardRelay json.RawMessage `json:"forward_relay"`
157 ForwardToken string `json:"forward_token"`
@@ -156,7 +162,9 @@ func HopRouteBytes(method string, route HopRoute) ([]byte, error) {
162 Method: strings.ToUpper(strings.TrimSpace(method)),
163 OwnerPublicKey: strings.TrimSpace(route.OwnerPublicKey),
164 RelayURL: strings.TrimSpace(route.RelayURL),
165 + RouteHostname: strings.TrimSpace(route.RouteHostname),
166 MatchHostname: strings.TrimSpace(route.MatchHostname),
167 + MatchHostnameHash: strings.TrimSpace(route.MatchHostnameHash),
168 MatchToken: strings.TrimSpace(route.MatchToken),
169 ForwardRelay: json.RawMessage(forwardRelay),
170 ForwardToken: strings.TrimSpace(route.ForwardToken),
types/types.go
+1 -1
@@ -2,7 +2,7 @@ package types
2
3 const (
4 ReleaseVersion = "v2.1.9"
5 - SDKVersion = "6"
5 + SDKVersion = "7"
6 DiscoveryVersion = "7"
7 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"
8 OfficialReleaseBaseURL = "https://github.com/gosuda/portal-tunnel/releases"
utils/utils.go
+10
@@ -3,6 +3,7 @@ package utils
3 import (
4 "context"
5 "crypto/rand"
6 + "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "errors"
@@ -226,6 +227,15 @@ func HostnameMatchesPattern(pattern, hostname string) bool {
227 return ok && rest == suffix
228 }
229
230 +func HostnameHash(hostname string) string {
231 + hostname = NormalizeHostname(hostname)
232 + if hostname == "" {
233 + return ""
234 + }
235 + sum := sha256.Sum256([]byte("portal hostname hash v1\x00" + hostname))
236 + return base64.RawURLEncoding.EncodeToString(sum[:])
237 +}
238 +
239 func NormalizeChildHostnames(inputs []string, baseDomain string) []string {
240 if len(inputs) == 0 {
241 return nil