Origin ech query design

This commit is contained in:
Fangliding
2026-04-28 18:51:35 +08:00
parent 08301e272c
commit 4e819273b7
4 changed files with 23 additions and 53 deletions
+1 -5
View File
@@ -469,11 +469,7 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config {
if len(c.EchConfigList) > 0 || len(c.EchServerKeys) > 0 { if len(c.EchConfigList) > 0 || len(c.EchServerKeys) > 0 {
err := ApplyECH(c, config) err := ApplyECH(c, config)
if err != nil { if err != nil {
if c.EchForceQuery == "full" { errors.LogError(context.Background(), err)
errors.LogError(context.Background(), err)
} else {
errors.LogInfo(context.Background(), err)
}
} }
} }
+1
View File
@@ -81,6 +81,7 @@ message Config {
string ech_config_list = 19; string ech_config_list = 19;
// Deprecated
string ech_force_query = 20; string ech_force_query = 20;
SocketConfig ech_socket_settings = 21; SocketConfig ech_socket_settings = 21;
+15 -33
View File
@@ -17,7 +17,6 @@ import (
utls "github.com/refraction-networking/utls" utls "github.com/refraction-networking/utls"
"github.com/xtls/xray-core/common/crypto" "github.com/xtls/xray-core/common/crypto"
dns2 "github.com/xtls/xray-core/features/dns"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -49,20 +48,10 @@ func ApplyECH(c *Config, config *tls.Config) error {
// for client // for client
if len(c.EchConfigList) != 0 { if len(c.EchConfigList) != 0 {
ECHForceQuery := c.EchForceQuery
switch ECHForceQuery {
case "none", "half", "full":
case "":
ECHForceQuery = "full" // default to full
default:
panic("Invalid ECHForceQuery: " + c.EchForceQuery)
}
defer func() { defer func() {
// if failed to get ECHConfig, use an invalid one to make connection fail // if failed to get ECHConfig, use an invalid one to make connection fail
if err != nil || len(ECHConfig) == 0 { if len(ECHConfig) == 0 {
if ECHForceQuery == "full" { ECHConfig = []byte{1, 1, 4, 5, 1, 4}
ECHConfig = []byte{1, 1, 4, 5, 1, 4}
}
} }
config.EncryptedClientHelloConfigList = ECHConfig config.EncryptedClientHelloConfigList = ECHConfig
}() }()
@@ -83,7 +72,7 @@ func ApplyECH(c *Config, config *tls.Config) error {
if nameToQuery == "" { if nameToQuery == "" {
return errors.New("Using DNS for ECH Config needs serverName or use Server format example.com+https://1.1.1.1/dns-query") return errors.New("Using DNS for ECH Config needs serverName or use Server format example.com+https://1.1.1.1/dns-query")
} }
ECHConfig, err = QueryRecord(nameToQuery, DNSServer, c.EchForceQuery, c.EchSocketSettings) ECHConfig, err = QueryRecord(nameToQuery, DNSServer, c.EchSocketSettings)
if err != nil { if err != nil {
return errors.New("Failed to query ECH DNS record for domain: ", nameToQuery, " at server: ", DNSServer).Base(err) return errors.New("Failed to query ECH DNS record for domain: ", nameToQuery, " at server: ", DNSServer).Base(err)
} }
@@ -107,7 +96,6 @@ type ECHConfigCache struct {
type echConfigRecord struct { type echConfigRecord struct {
config []byte config []byte
expire time.Time expire time.Time
err error
} }
var ( var (
@@ -125,39 +113,34 @@ func ECHCacheKey(server, domain string, sockopt *internet.SocketConfig) string {
// Update updates the ECH config for given domain and server. // 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. // 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. // if isLockedUpdate is true, it will not try to acquire the lock.
func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, forceQuery string, sockopt *internet.SocketConfig) ([]byte, error) { func (c *ECHConfigCache) Update(domain string, server string, isLockedUpdate bool, sockopt *internet.SocketConfig) ([]byte, error) {
if !isLockedUpdate { if !isLockedUpdate {
c.UpdateLock.Lock() c.UpdateLock.Lock()
defer c.UpdateLock.Unlock() defer c.UpdateLock.Unlock()
} }
// Double check cache after acquiring lock // Double check cache after acquiring lock
configRecord := c.configRecord.Load() configRecord := c.configRecord.Load()
if configRecord.expire.After(time.Now()) && configRecord.err == nil { if configRecord.expire.After(time.Now()) {
errors.LogDebug(context.Background(), "Cache hit for domain after double check: ", domain) errors.LogDebug(context.Background(), "Cache hit for domain after double check: ", domain)
return configRecord.config, configRecord.err return configRecord.config, nil
} }
// Query ECH config from DNS server // Query ECH config from DNS server
errors.LogDebug(context.Background(), "Trying to query ECH config for domain: ", domain, " with ECH server: ", server) errors.LogDebug(context.Background(), "Trying to query ECH config for domain: ", domain, " with ECH server: ", server)
echConfig, ttl, err := dnsQuery(server, domain, sockopt) echConfig, ttl, err := dnsQuery(server, domain, sockopt)
// if in "full", directly return if err != nil {
if err != nil && forceQuery == "full" {
return nil, err return nil, err
} }
if ttl == 0 {
ttl = dns2.DefaultTTL
}
configRecord = &echConfigRecord{ configRecord = &echConfigRecord{
config: echConfig, config: echConfig,
expire: time.Now().Add(time.Duration(ttl) * time.Second), expire: time.Now().Add(time.Duration(ttl) * time.Second),
err: err,
} }
c.configRecord.Store(configRecord) c.configRecord.Store(configRecord)
return configRecord.config, configRecord.err return configRecord.config, nil
} }
// QueryRecord returns the ECH config for given domain. // 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. // 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 string, sockopt *internet.SocketConfig) ([]byte, error) { func QueryRecord(domain string, server string, sockopt *internet.SocketConfig) ([]byte, error) {
GlobalECHConfigCacheKey := ECHCacheKey(server, domain, sockopt) GlobalECHConfigCacheKey := ECHCacheKey(server, domain, sockopt)
echConfigCache, ok := GlobalECHConfigCache.Load(GlobalECHConfigCacheKey) echConfigCache, ok := GlobalECHConfigCache.Load(GlobalECHConfigCacheKey)
if !ok { if !ok {
@@ -166,25 +149,25 @@ func QueryRecord(domain string, server string, forceQuery string, sockopt *inter
echConfigCache, _ = GlobalECHConfigCache.LoadOrStore(GlobalECHConfigCacheKey, echConfigCache) echConfigCache, _ = GlobalECHConfigCache.LoadOrStore(GlobalECHConfigCacheKey, echConfigCache)
} }
configRecord := echConfigCache.configRecord.Load() configRecord := echConfigCache.configRecord.Load()
if configRecord.expire.After(time.Now()) && (configRecord.err == nil || forceQuery == "none") { if configRecord.expire.After(time.Now()) {
errors.LogDebug(context.Background(), "Cache hit for domain: ", domain) errors.LogDebug(context.Background(), "Cache hit for domain: ", domain)
return configRecord.config, configRecord.err return configRecord.config, nil
} }
// If expire is zero value, it means we are in initial state, wait for the query to finish // If expire is zero value, it means we are in initial state, wait for the query to finish
// otherwise return old value immediately and update in a goroutine // otherwise return old value immediately and update in a goroutine
// but if the cache is too old, wait for update // but if the cache is too old, wait for update
if configRecord.expire == (time.Time{}) || configRecord.expire.Add(time.Hour*4).Before(time.Now()) { if configRecord.expire == (time.Time{}) || configRecord.expire.Add(time.Hour*4).Before(time.Now()) {
return echConfigCache.Update(domain, server, false, forceQuery, sockopt) return echConfigCache.Update(domain, server, false, sockopt)
} else { } else {
// If someone already acquired the lock, it means it is updating, do not start another update goroutine // If someone already acquired the lock, it means it is updating, do not start another update goroutine
if echConfigCache.UpdateLock.TryLock() { if echConfigCache.UpdateLock.TryLock() {
go func() { go func() {
defer echConfigCache.UpdateLock.Unlock() defer echConfigCache.UpdateLock.Unlock()
echConfigCache.Update(domain, server, true, forceQuery, sockopt) echConfigCache.Update(domain, server, true, sockopt)
}() }()
} }
return configRecord.config, configRecord.err return configRecord.config, nil
} }
} }
@@ -322,8 +305,7 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b
} }
} }
} }
// empty is valid, means no ECH config found return nil, 0, errors.New("no valid ECH config found in DNS response")
return nil, dns2.DefaultTTL, nil
} }
var ErrInvalidLen = errors.New("goech: invalid length") var ErrInvalidLen = errors.New("goech: invalid length")
+6 -15
View File
@@ -3,6 +3,7 @@ package tls
import ( import (
"io" "io"
"net/http" "net/http"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -59,21 +60,11 @@ func TestECHDial(t *testing.T) {
func TestECHDialFail(t *testing.T) { func TestECHDialFail(t *testing.T) {
config := &Config{ config := &Config{
ServerName: "cloudflare.com", ServerName: "cloudflare.com",
EchConfigList: "udp://127.0.0.1", EchConfigList: "udp://0.0.0.0",
EchForceQuery: "half",
} }
config.GetTLSConfig() tlsConfig := config.GetTLSConfig()
// check cache ApplyECH(config, tlsConfig)
echConfigCache, ok := GlobalECHConfigCache.Load(ECHCacheKey("udp://127.0.0.1", "cloudflare.com", nil)) if !slices.Equal(tlsConfig.EncryptedClientHelloConfigList, []byte{1, 1, 4, 5, 1, 4}) {
if !ok { t.Error("ECH config should be invalid when query failed", " but got ", tlsConfig.EncryptedClientHelloConfigList)
t.Error("ECH config cache not found")
}
configRecord := echConfigCache.configRecord.Load()
if configRecord == nil {
t.Error("ECH config record not found in cache")
return
}
if configRecord.err == nil {
t.Error("unexpected nil error in ECH config record")
} }
} }