Skip to content

Commit 7cbf5b0

Browse files
authored
TLS ECH client: echForceQuery "full" / "half" / "none" (default) (XTLS#4973)
XTLS#4971 (comment)
1 parent 87fff12 commit 7cbf5b0

6 files changed

Lines changed: 54 additions & 42 deletions

File tree

infra/conf/transport_internet.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ type TLSConfig struct {
414414
VerifyPeerCertInNames []string `json:"verifyPeerCertInNames"`
415415
ECHServerKeys string `json:"echServerKeys"`
416416
ECHConfigList string `json:"echConfigList"`
417-
ECHForceQuery bool `json:"echForceQuery"`
417+
ECHForceQuery string `json:"echForceQuery"`
418418
ECHSocketSettings *SocketConfig `json:"echSockopt"`
419419
}
420420

@@ -494,6 +494,12 @@ func (c *TLSConfig) Build() (proto.Message, error) {
494494
}
495495
config.EchServerKeys = EchPrivateKey
496496
}
497+
switch c.ECHForceQuery {
498+
case "none", "half", "full", "":
499+
config.EchForceQuery = c.ECHForceQuery
500+
default:
501+
return nil, errors.New(`invalid "echForceQuery": `, c.ECHForceQuery)
502+
}
497503
config.EchForceQuery = c.ECHForceQuery
498504
config.EchConfigList = c.ECHConfigList
499505
if c.ECHSocketSettings != nil {

transport/internet/tls/config.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"crypto/tls"
99
"crypto/x509"
1010
"encoding/base64"
11-
"github.com/xtls/xray-core/features/dns"
1211
"os"
1312
"slices"
1413
"strings"
@@ -451,7 +450,7 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config {
451450
if len(c.EchConfigList) > 0 || len(c.EchServerKeys) > 0 {
452451
err := ApplyECH(c, config)
453452
if err != nil {
454-
if c.EchForceQuery || errors.Cause(err) != dns.ErrEmptyResponse {
453+
if c.EchForceQuery == "full" {
455454
errors.LogError(context.Background(), err)
456455
} else {
457456
errors.LogInfo(context.Background(), err)

transport/internet/tls/config.pb.go

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

transport/internet/tls/config.proto

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ message Config {
9898

9999
string ech_config_list = 19;
100100

101-
bool ech_force_query = 20;
101+
string ech_force_query = 20;
102102

103103
SocketConfig ech_socket_settings = 21;
104104
}

transport/internet/tls/ech.go

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ import (
99
"encoding/base64"
1010
"encoding/binary"
1111
"fmt"
12-
utls "github.com/refraction-networking/utls"
13-
"github.com/xtls/xray-core/common/crypto"
14-
dns2 "github.com/xtls/xray-core/features/dns"
15-
"golang.org/x/net/http2"
1612
"io"
1713
"net/http"
1814
"net/url"
@@ -21,6 +17,11 @@ import (
2117
"sync/atomic"
2218
"time"
2319

20+
utls "github.com/refraction-networking/utls"
21+
"github.com/xtls/xray-core/common/crypto"
22+
dns2 "github.com/xtls/xray-core/features/dns"
23+
"golang.org/x/net/http2"
24+
2425
"github.com/miekg/dns"
2526
"github.com/xtls/reality"
2627
"github.com/xtls/reality/hpke"
@@ -52,10 +53,18 @@ func ApplyECH(c *Config, config *tls.Config) error {
5253

5354
// for client
5455
if len(c.EchConfigList) != 0 {
56+
ECHForceQuery := c.EchForceQuery
57+
switch ECHForceQuery {
58+
case "none", "half", "full":
59+
case "":
60+
ECHForceQuery = "none" // default to none
61+
default:
62+
panic("Invalid ECHForceQuery: " + c.EchForceQuery)
63+
}
5564
defer func() {
5665
// if failed to get ECHConfig, use an invalid one to make connection fail
57-
if err != nil {
58-
if c.EchForceQuery {
66+
if err != nil || len(ECHConfig) == 0 {
67+
if ECHForceQuery == "full" {
5968
ECHConfig = []byte{1, 1, 4, 5, 1, 4}
6069
}
6170
}
@@ -106,32 +115,40 @@ type echConfigRecord struct {
106115
}
107116

108117
var (
109-
// key value must be like this: "example.com|udp://1.1.1.1"
118+
// The keys for both maps must be generated by ECHCacheKey().
110119
GlobalECHConfigCache = utils.NewTypedSyncMap[string, *ECHConfigCache]()
111120
clientForECHDOH = utils.NewTypedSyncMap[string, *http.Client]()
112121
)
113122

123+
// sockopt can be nil if not specified.
124+
// if for clientForECHDOH, domain can be empty.
125+
func ECHCacheKey(server, domain string, sockopt *internet.SocketConfig) string {
126+
return server + "|" + domain + "|" + fmt.Sprintf("%p", sockopt)
127+
}
128+
114129
// Update updates the ECH config for given domain and server.
115130
// this method is concurrent safe, only one update request will be sent, others get the cache.
116131
// if isLockedUpdate is true, it will not try to acquire the lock.
117-
func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, forceQuery bool, sockopt *internet.SocketConfig) ([]byte, error) {
132+
func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, forceQuery string, sockopt *internet.SocketConfig) ([]byte, error) {
118133
if !isLockedUpdate {
119134
c.UpdateLock.Lock()
120135
defer c.UpdateLock.Unlock()
121136
}
122137
// Double check cache after acquiring lock
123138
configRecord := c.configRecord.Load()
124-
if configRecord.expire.After(time.Now()) {
139+
if configRecord.expire.After(time.Now()) && configRecord.err == nil {
125140
errors.LogDebug(context.Background(), "Cache hit for domain after double check: ", domain)
126141
return configRecord.config, configRecord.err
127142
}
128143
// Query ECH config from DNS server
129144
errors.LogDebug(context.Background(), "Trying to query ECH config for domain: ", domain, " with ECH server: ", server)
130145
echConfig, ttl, err := dnsQuery(server, domain, sockopt)
131-
if err != nil {
132-
if forceQuery || ttl == 0 {
133-
return nil, err
134-
}
146+
// if in "full", directly return
147+
if err != nil && forceQuery == "full" {
148+
return nil, err
149+
}
150+
if ttl == 0 {
151+
ttl = dns2.DefaultTTL
135152
}
136153
configRecord = &echConfigRecord{
137154
config: echConfig,
@@ -144,16 +161,16 @@ func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate boo
144161

145162
// QueryRecord returns the ECH config for given domain.
146163
// If the record is not in cache or expired, it will query the DNS server and update the cache.
147-
func QueryRecord(domain string, server string, forceQuery bool, sockopt *internet.SocketConfig) ([]byte, error) {
148-
GlobalECHConfigCacheKey := domain + "|" + server + "|" + fmt.Sprintf("%p", sockopt)
164+
func QueryRecord(domain string, server string, forceQuery string, sockopt *internet.SocketConfig) ([]byte, error) {
165+
GlobalECHConfigCacheKey := ECHCacheKey(server, domain, sockopt)
149166
echConfigCache, ok := GlobalECHConfigCache.Load(GlobalECHConfigCacheKey)
150167
if !ok {
151168
echConfigCache = &ECHConfigCache{}
152169
echConfigCache.configRecord.Store(&echConfigRecord{})
153170
echConfigCache, _ = GlobalECHConfigCache.LoadOrStore(GlobalECHConfigCacheKey, echConfigCache)
154171
}
155172
configRecord := echConfigCache.configRecord.Load()
156-
if configRecord.expire.After(time.Now()) {
173+
if configRecord.expire.After(time.Now()) && (configRecord.err == nil || forceQuery == "none") {
157174
errors.LogDebug(context.Background(), "Cache hit for domain: ", domain)
158175
return configRecord.config, configRecord.err
159176
}
@@ -196,7 +213,7 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b
196213
return nil, 0, err
197214
}
198215
var client *http.Client
199-
serverKey := server + "|" + fmt.Sprintf("%p", sockopt)
216+
serverKey := ECHCacheKey(server, "", sockopt)
200217
if client, _ = clientForECHDOH.Load(serverKey); client == nil {
201218
// All traffic sent by core should via xray's internet.DialSystem
202219
// This involves the behavior of some Android VPN GUI clients
@@ -307,7 +324,8 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b
307324
}
308325
}
309326
}
310-
return nil, dns2.DefaultTTL, dns2.ErrEmptyResponse
327+
// empty is valid, means no ECH config found
328+
return nil, dns2.DefaultTTL, nil
311329
}
312330

313331
// reference github.com/OmarTariq612/goech

transport/internet/tls/ech_test.go

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package tls
22

33
import (
4-
"fmt"
54
"io"
65
"net/http"
76
"strings"
@@ -41,7 +40,7 @@ func TestECHDial(t *testing.T) {
4140
}
4241
wg.Wait()
4342
// check cache
44-
echConfigCache, ok := GlobalECHConfigCache.Load("encryptedsni.com|udp://1.1.1.1" + "|" + fmt.Sprintf("%p", config.EchSocketSettings))
43+
echConfigCache, ok := GlobalECHConfigCache.Load(ECHCacheKey("udp://1.1.1.1", "encryptedsni.com", nil))
4544
if !ok {
4645
t.Error("ECH config cache not found")
4746

@@ -60,22 +59,12 @@ func TestECHDial(t *testing.T) {
6059
func TestECHDialFail(t *testing.T) {
6160
config := &Config{
6261
ServerName: "cloudflare.com",
63-
EchConfigList: "udp://1.1.1.1",
62+
EchConfigList: "udp://127.0.0.1",
63+
EchForceQuery: "half",
6464
}
65-
TLSConfig := config.GetTLSConfig()
66-
TLSConfig.NextProtos = []string{"http/1.1"}
67-
client := &http.Client{
68-
Transport: &http.Transport{
69-
TLSClientConfig: TLSConfig,
70-
},
71-
}
72-
resp, err := client.Get("https://cloudflare.com/cdn-cgi/trace")
73-
common.Must(err)
74-
defer resp.Body.Close()
75-
_, err = io.ReadAll(resp.Body)
76-
common.Must(err)
65+
config.GetTLSConfig()
7766
// check cache
78-
echConfigCache, ok := GlobalECHConfigCache.Load("cloudflare.com|udp://1.1.1.1" + "|" + fmt.Sprintf("%p", config.EchSocketSettings))
67+
echConfigCache, ok := GlobalECHConfigCache.Load(ECHCacheKey("udp://127.0.0.1", "cloudflare.com", nil))
7968
if !ok {
8069
t.Error("ECH config cache not found")
8170
}

0 commit comments

Comments
 (0)