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