TLS ECH client: echForceQuery "full" / "half" / "none" (default) (#4973)

https://github.com/XTLS/Xray-core/pull/4971#issuecomment-3148113203
This commit is contained in:
风扇滑翔翼
2025-08-03 18:15:42 +08:00
committed by GitHub
parent 87fff12fd9
commit 7cbf5b004c
6 changed files with 54 additions and 42 deletions

View File

@@ -9,10 +9,6 @@ import (
"encoding/base64"
"encoding/binary"
"fmt"
utls "github.com/refraction-networking/utls"
"github.com/xtls/xray-core/common/crypto"
dns2 "github.com/xtls/xray-core/features/dns"
"golang.org/x/net/http2"
"io"
"net/http"
"net/url"
@@ -21,6 +17,11 @@ import (
"sync/atomic"
"time"
utls "github.com/refraction-networking/utls"
"github.com/xtls/xray-core/common/crypto"
dns2 "github.com/xtls/xray-core/features/dns"
"golang.org/x/net/http2"
"github.com/miekg/dns"
"github.com/xtls/reality"
"github.com/xtls/reality/hpke"
@@ -52,10 +53,18 @@ func ApplyECH(c *Config, config *tls.Config) error {
// for client
if len(c.EchConfigList) != 0 {
ECHForceQuery := c.EchForceQuery
switch ECHForceQuery {
case "none", "half", "full":
case "":
ECHForceQuery = "none" // default to none
default:
panic("Invalid ECHForceQuery: " + c.EchForceQuery)
}
defer func() {
// if failed to get ECHConfig, use an invalid one to make connection fail
if err != nil {
if c.EchForceQuery {
if err != nil || len(ECHConfig) == 0 {
if ECHForceQuery == "full" {
ECHConfig = []byte{1, 1, 4, 5, 1, 4}
}
}
@@ -106,32 +115,40 @@ type echConfigRecord struct {
}
var (
// key value must be like this: "example.com|udp://1.1.1.1"
// The keys for both maps must be generated by ECHCacheKey().
GlobalECHConfigCache = utils.NewTypedSyncMap[string, *ECHConfigCache]()
clientForECHDOH = utils.NewTypedSyncMap[string, *http.Client]()
)
// sockopt can be nil if not specified.
// if for clientForECHDOH, domain can be empty.
func ECHCacheKey(server, domain string, sockopt *internet.SocketConfig) string {
return server + "|" + domain + "|" + fmt.Sprintf("%p", sockopt)
}
// Update updates the ECH config for given domain and server.
// this method is concurrent safe, only one update request will be sent, others get the cache.
// if isLockedUpdate is true, it will not try to acquire the lock.
func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, forceQuery bool, sockopt *internet.SocketConfig) ([]byte, error) {
func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, forceQuery string, sockopt *internet.SocketConfig) ([]byte, error) {
if !isLockedUpdate {
c.UpdateLock.Lock()
defer c.UpdateLock.Unlock()
}
// Double check cache after acquiring lock
configRecord := c.configRecord.Load()
if configRecord.expire.After(time.Now()) {
if configRecord.expire.After(time.Now()) && configRecord.err == nil {
errors.LogDebug(context.Background(), "Cache hit for domain after double check: ", domain)
return configRecord.config, configRecord.err
}
// Query ECH config from DNS server
errors.LogDebug(context.Background(), "Trying to query ECH config for domain: ", domain, " with ECH server: ", server)
echConfig, ttl, err := dnsQuery(server, domain, sockopt)
if err != nil {
if forceQuery || ttl == 0 {
return nil, err
}
// if in "full", directly return
if err != nil && forceQuery == "full" {
return nil, err
}
if ttl == 0 {
ttl = dns2.DefaultTTL
}
configRecord = &echConfigRecord{
config: echConfig,
@@ -144,8 +161,8 @@ func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate boo
// QueryRecord returns the ECH config for given domain.
// If the record is not in cache or expired, it will query the DNS server and update the cache.
func QueryRecord(domain string, server string, forceQuery bool, sockopt *internet.SocketConfig) ([]byte, error) {
GlobalECHConfigCacheKey := domain + "|" + server + "|" + fmt.Sprintf("%p", sockopt)
func QueryRecord(domain string, server string, forceQuery string, sockopt *internet.SocketConfig) ([]byte, error) {
GlobalECHConfigCacheKey := ECHCacheKey(server, domain, sockopt)
echConfigCache, ok := GlobalECHConfigCache.Load(GlobalECHConfigCacheKey)
if !ok {
echConfigCache = &ECHConfigCache{}
@@ -153,7 +170,7 @@ func QueryRecord(domain string, server string, forceQuery bool, sockopt *interne
echConfigCache, _ = GlobalECHConfigCache.LoadOrStore(GlobalECHConfigCacheKey, echConfigCache)
}
configRecord := echConfigCache.configRecord.Load()
if configRecord.expire.After(time.Now()) {
if configRecord.expire.After(time.Now()) && (configRecord.err == nil || forceQuery == "none") {
errors.LogDebug(context.Background(), "Cache hit for domain: ", domain)
return configRecord.config, configRecord.err
}
@@ -196,7 +213,7 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b
return nil, 0, err
}
var client *http.Client
serverKey := server + "|" + fmt.Sprintf("%p", sockopt)
serverKey := ECHCacheKey(server, "", sockopt)
if client, _ = clientForECHDOH.Load(serverKey); client == nil {
// All traffic sent by core should via xray's internet.DialSystem
// This involves the behavior of some Android VPN GUI clients
@@ -307,7 +324,8 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b
}
}
}
return nil, dns2.DefaultTTL, dns2.ErrEmptyResponse
// empty is valid, means no ECH config found
return nil, dns2.DefaultTTL, nil
}
// reference github.com/OmarTariq612/goech