From 286ce7531acf08533c21d91896eea6bc13b1edae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 14 Feb 2024 20:42:58 +0800 Subject: [PATCH] Add rejected DNS response cache support --- adapter/experimental.go | 4 + docs/configuration/dns/rule.md | 6 +- docs/configuration/dns/rule.zh.md | 6 +- docs/configuration/experimental/cache-file.md | 32 +++++- .../experimental/cache-file.zh.md | 29 ++++- experimental/cachefile/cache.go | 34 ++++-- experimental/cachefile/fakeip.go | 18 ++-- experimental/cachefile/rdrc.go | 101 ++++++++++++++++++ option/experimental.go | 10 +- route/router.go | 17 ++- route/router_dns.go | 31 ++++-- transport/dhcp/server.go | 1 + 12 files changed, 253 insertions(+), 36 deletions(-) create mode 100644 experimental/cachefile/rdrc.go diff --git a/adapter/experimental.go b/adapter/experimental.go index 2a6776cd..5e1cbd9d 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -9,6 +9,7 @@ import ( "time" "github.com/sagernet/sing-box/common/urltest" + "github.com/sagernet/sing-dns" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/rw" ) @@ -30,6 +31,9 @@ type CacheFile interface { StoreFakeIP() bool FakeIPStorage + StoreRDRC() bool + dns.RDRCStore + LoadMode() string StoreMode(mode string) error LoadSelected(group string) string diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 5b42f20c..84b9b669 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -339,10 +339,14 @@ Will overrides `dns.client_subnet` and `servers.[].client_subnet`. Only takes effect for IP address requests. When the query results do not match the address filtering rule items, the current rule will be skipped. -!!! note "" +!!! info "" `ip_cidr` items in included rule sets also takes effect as an address filtering field. +!!! note "" + + Enable `experimental.cache_file.store_rdrc` to cache results. + #### geoip !!! question "Since sing-box 1.9.0" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index eaeb8e68..c7977bc1 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -337,10 +337,14 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 仅对IP地址请求生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 -!!! note "" +!!! info "" 引用的规则集中的 `ip_cidr` 项也作为地址筛选字段生效。 +!!! note "" + + 启用 `experimental.cache_file.store_rdrc` 以缓存结果。 + #### geoip !!! question "自 sing-box 1.9.0 起" diff --git a/docs/configuration/experimental/cache-file.md b/docs/configuration/experimental/cache-file.md index ca3f62e5..b30538e5 100644 --- a/docs/configuration/experimental/cache-file.md +++ b/docs/configuration/experimental/cache-file.md @@ -1,5 +1,14 @@ +--- +icon: material/new-box +--- + !!! question "Since sing-box 1.8.0" +!!! quote "Changes in sing-box 1.9.0" + + :material-plus: [store_rdrc](#store_rdrc) + :material-plus: [rdrc_timeout](#rdrc_timeout) + ### Structure ```json @@ -7,7 +16,9 @@ "enabled": true, "path": "", "cache_id": "", - "store_fakeip": false + "store_fakeip": false, + "store_rdrc": false, + "rdrc_timeout": "" } ``` @@ -25,6 +36,23 @@ Path to the cache file. #### cache_id -Identifier in cache file. +Identifier in the cache file If not empty, configuration specified data will use a separate store keyed by it. + +#### store_fakeip + +Store fakeip in the cache file + +#### store_rdrc + +Store rejected DNS response cache in the cache file + +The check results of [Address filter DNS rule items](/configuration/dns/rule/#address-filter-fields) +will be cached until expiration. + +#### rdrc_timeout + +Timeout of rejected DNS response cache. + +`7d` is used by default. diff --git a/docs/configuration/experimental/cache-file.zh.md b/docs/configuration/experimental/cache-file.zh.md index da0ce39b..6d86dc84 100644 --- a/docs/configuration/experimental/cache-file.zh.md +++ b/docs/configuration/experimental/cache-file.zh.md @@ -1,5 +1,14 @@ +--- +icon: material/new-box +--- + !!! question "自 sing-box 1.8.0 起" +!!! quote "sing-box 1.9.0 中的更改" + + :material-plus: [store_rdrc](#store_rdrc) + :material-plus: [rdrc_timeout](#rdrc_timeout) + ### 结构 ```json @@ -7,7 +16,9 @@ "enabled": true, "path": "", "cache_id": "", - "store_fakeip": false + "store_fakeip": false, + "store_rdrc": false, + "rdrc_timeout": "" } ``` @@ -26,3 +37,19 @@ 缓存文件中的标识符。 如果不为空,配置特定的数据将使用由其键控的单独存储。 + +#### store_fakeip + +将 fakeip 存储在缓存文件中。 + +#### store_rdrc + +将拒绝的 DNS 响应缓存存储在缓存文件中。 + +[地址筛选 DNS 规则项](/zh/configuration/dns/rule/#_3) 的检查结果将被缓存至过期。 + +#### rdrc_timeout + +拒绝的 DNS 响应缓存超时。 + +默认使用 `7d`。 diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 43b84562..9d45ea8e 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -29,6 +29,7 @@ var ( string(bucketExpand), string(bucketMode), string(bucketRuleSet), + string(bucketRDRC), } cacheIDDefault = []byte("default") @@ -37,17 +38,25 @@ var ( var _ adapter.CacheFile = (*CacheFile)(nil) type CacheFile struct { - ctx context.Context - path string - cacheID []byte - storeFakeIP bool - + ctx context.Context + path string + cacheID []byte + storeFakeIP bool + storeRDRC bool + rdrcTimeout time.Duration DB *bbolt.DB - saveAccess sync.RWMutex + saveMetadataTimer *time.Timer + saveFakeIPAccess sync.RWMutex saveDomain map[netip.Addr]string saveAddress4 map[string]netip.Addr saveAddress6 map[string]netip.Addr - saveMetadataTimer *time.Timer + saveRDRCAccess sync.RWMutex + saveRDRC map[saveRDRCCacheKey]bool +} + +type saveRDRCCacheKey struct { + TransportName string + QuestionName string } func New(ctx context.Context, options option.CacheFileOptions) *CacheFile { @@ -61,14 +70,25 @@ func New(ctx context.Context, options option.CacheFileOptions) *CacheFile { if options.CacheID != "" { cacheIDBytes = append([]byte{0}, []byte(options.CacheID)...) } + var rdrcTimeout time.Duration + if options.StoreRDRC { + if options.RDRCTimeout > 0 { + rdrcTimeout = time.Duration(options.RDRCTimeout) + } else { + rdrcTimeout = 7 * 24 * time.Hour + } + } return &CacheFile{ ctx: ctx, path: filemanager.BasePath(ctx, path), cacheID: cacheIDBytes, storeFakeIP: options.StoreFakeIP, + storeRDRC: options.StoreRDRC, + rdrcTimeout: rdrcTimeout, saveDomain: make(map[netip.Addr]string), saveAddress4: make(map[string]netip.Addr), saveAddress6: make(map[string]netip.Addr), + saveRDRC: make(map[saveRDRCCacheKey]bool), } } diff --git a/experimental/cachefile/fakeip.go b/experimental/cachefile/fakeip.go index 41c1dee6..8fe0f113 100644 --- a/experimental/cachefile/fakeip.go +++ b/experimental/cachefile/fakeip.go @@ -97,7 +97,7 @@ func (c *CacheFile) FakeIPStore(address netip.Addr, domain string) error { } func (c *CacheFile) FakeIPStoreAsync(address netip.Addr, domain string, logger logger.Logger) { - c.saveAccess.Lock() + c.saveFakeIPAccess.Lock() if oldDomain, loaded := c.saveDomain[address]; loaded { if address.Is4() { delete(c.saveAddress4, oldDomain) @@ -111,27 +111,27 @@ func (c *CacheFile) FakeIPStoreAsync(address netip.Addr, domain string, logger l } else { c.saveAddress6[domain] = address } - c.saveAccess.Unlock() + c.saveFakeIPAccess.Unlock() go func() { err := c.FakeIPStore(address, domain) if err != nil { - logger.Warn("save FakeIP address pair: ", err) + logger.Warn("save FakeIP cache: ", err) } - c.saveAccess.Lock() + c.saveFakeIPAccess.Lock() delete(c.saveDomain, address) if address.Is4() { delete(c.saveAddress4, domain) } else { delete(c.saveAddress6, domain) } - c.saveAccess.Unlock() + c.saveFakeIPAccess.Unlock() }() } func (c *CacheFile) FakeIPLoad(address netip.Addr) (string, bool) { - c.saveAccess.RLock() + c.saveFakeIPAccess.RLock() cachedDomain, cached := c.saveDomain[address] - c.saveAccess.RUnlock() + c.saveFakeIPAccess.RUnlock() if cached { return cachedDomain, true } @@ -152,13 +152,13 @@ func (c *CacheFile) FakeIPLoadDomain(domain string, isIPv6 bool) (netip.Addr, bo cachedAddress netip.Addr cached bool ) - c.saveAccess.RLock() + c.saveFakeIPAccess.RLock() if !isIPv6 { cachedAddress, cached = c.saveAddress4[domain] } else { cachedAddress, cached = c.saveAddress6[domain] } - c.saveAccess.RUnlock() + c.saveFakeIPAccess.RUnlock() if cached { return cachedAddress, true } diff --git a/experimental/cachefile/rdrc.go b/experimental/cachefile/rdrc.go new file mode 100644 index 00000000..836beba1 --- /dev/null +++ b/experimental/cachefile/rdrc.go @@ -0,0 +1,101 @@ +package cachefile + +import ( + "encoding/binary" + "time" + + "github.com/sagernet/bbolt" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/logger" +) + +var bucketRDRC = []byte("rdrc") + +func (c *CacheFile) StoreRDRC() bool { + return c.storeRDRC +} + +func (c *CacheFile) RDRCTimeout() time.Duration { + return c.rdrcTimeout +} + +func (c *CacheFile) LoadRDRC(transportName string, qName string) (rejected bool) { + c.saveRDRCAccess.RLock() + rejected, cached := c.saveRDRC[saveRDRCCacheKey{transportName, qName}] + c.saveRDRCAccess.RUnlock() + if cached { + return + } + var deleteCache bool + err := c.DB.View(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketRDRC) + if bucket == nil { + return nil + } + bucket = bucket.Bucket([]byte(transportName)) + if bucket == nil { + return nil + } + content := bucket.Get([]byte(qName)) + if content == nil { + return nil + } + expiresAt := time.Unix(int64(binary.BigEndian.Uint64(content)), 0) + if time.Now().After(expiresAt) { + deleteCache = true + return nil + } + rejected = true + return nil + }) + if err != nil { + return + } + if deleteCache { + c.DB.Update(func(tx *bbolt.Tx) error { + bucket := c.bucket(tx, bucketRDRC) + if bucket == nil { + return nil + } + bucket = bucket.Bucket([]byte(transportName)) + if bucket == nil { + return nil + } + return bucket.Delete([]byte(qName)) + }) + } + return +} + +func (c *CacheFile) SaveRDRC(transportName string, qName string) error { + return c.DB.Batch(func(tx *bbolt.Tx) error { + bucket, err := c.createBucket(tx, bucketRDRC) + if err != nil { + return err + } + bucket, err = bucket.CreateBucketIfNotExists([]byte(transportName)) + if err != nil { + return err + } + expiresAt := buf.Get(8) + defer buf.Put(expiresAt) + binary.BigEndian.PutUint64(expiresAt, uint64(time.Now().Add(c.rdrcTimeout).Unix())) + return bucket.Put([]byte(qName), expiresAt) + }) +} + +func (c *CacheFile) SaveRDRCAsync(transportName string, qName string, logger logger.Logger) { + saveKey := saveRDRCCacheKey{transportName, qName} + c.saveRDRCAccess.Lock() + c.saveRDRC[saveKey] = true + c.saveRDRCAccess.Unlock() + go func() { + err := c.SaveRDRC(transportName, qName) + if err != nil { + logger.Warn("save RDRC: ", err) + } + c.saveRDRCAccess.Lock() + delete(c.saveRDRC, saveKey) + c.saveRDRCAccess.Unlock() + }() +} diff --git a/option/experimental.go b/option/experimental.go index c685f51f..9f6071ba 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -8,10 +8,12 @@ type ExperimentalOptions struct { } type CacheFileOptions struct { - Enabled bool `json:"enabled,omitempty"` - Path string `json:"path,omitempty"` - CacheID string `json:"cache_id,omitempty"` - StoreFakeIP bool `json:"store_fakeip,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + CacheID string `json:"cache_id,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty"` + StoreRDRC bool `json:"store_rdrc,omitempty"` + RDRCTimeout Duration `json:"rdrc_timeout,omitempty"` } type ClashAPIOptions struct { diff --git a/route/router.go b/route/router.go index ae57fc6b..e9807bd4 100644 --- a/route/router.go +++ b/route/router.go @@ -139,7 +139,17 @@ func NewRouter( DisableCache: dnsOptions.DNSClientOptions.DisableCache, DisableExpire: dnsOptions.DNSClientOptions.DisableExpire, IndependentCache: dnsOptions.DNSClientOptions.IndependentCache, - Logger: router.dnsLogger, + RDRC: func() dns.RDRCStore { + cacheFile := service.FromContext[adapter.CacheFile](ctx) + if cacheFile == nil { + return nil + } + if !cacheFile.StoreRDRC() { + return nil + } + return cacheFile + }, + Logger: router.dnsLogger, }) for i, ruleOptions := range options.Rules { routeRule, err := NewRule(router, router.logger, ruleOptions, true) @@ -625,6 +635,11 @@ func (r *Router) Start() error { return E.Cause(err, "initialize rule[", i, "]") } } + + monitor.Start("initialize DNS client") + r.dnsClient.Start() + monitor.Finish() + for i, rule := range r.dnsRules { monitor.Start("initialize DNS rule[", i, "]") err := rule.Start() diff --git a/route/router_dns.go b/route/router_dns.go index 7114882b..4bcc4f23 100644 --- a/route/router_dns.go +++ b/route/router_dns.go @@ -139,7 +139,9 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, er } cancel() if err != nil { - if errors.Is(err, dns.ErrResponseRejected) { + if errors.Is(err, dns.ErrResponseRejectedCached) { + r.dnsLogger.DebugContext(ctx, E.Cause(err, "response rejected for ", formatQuestion(message.Question[0].String())), " (cached)") + } else if errors.Is(err, dns.ErrResponseRejected) { r.dnsLogger.DebugContext(ctx, E.Cause(err, "response rejected for ", formatQuestion(message.Question[0].String()))) } else if len(message.Question) > 0 { r.dnsLogger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", formatQuestion(message.Question[0].String()))) @@ -166,6 +168,15 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, er } func (r *Router) Lookup(ctx context.Context, domain string, strategy dns.DomainStrategy) ([]netip.Addr, error) { + var ( + responseAddrs []netip.Addr + cached bool + err error + ) + responseAddrs, cached = r.dnsClient.LookupCache(ctx, domain, strategy) + if cached { + return responseAddrs, nil + } r.dnsLogger.DebugContext(ctx, "lookup domain ", domain) ctx, metadata := adapter.AppendContext(ctx) metadata.Domain = domain @@ -174,8 +185,6 @@ func (r *Router) Lookup(ctx context.Context, domain string, strategy dns.DomainS transportStrategy dns.DomainStrategy rule adapter.DNSRule ruleIndex int - resultAddrs []netip.Addr - err error ) ruleIndex = -1 for { @@ -193,22 +202,24 @@ func (r *Router) Lookup(ctx context.Context, domain string, strategy dns.DomainS dnsCtx, cancel = context.WithTimeout(dnsCtx, C.DNSTimeout) if rule != nil && rule.WithAddressLimit() { addressLimit = true - resultAddrs, err = r.dnsClient.LookupWithResponseCheck(dnsCtx, transport, domain, strategy, func(responseAddrs []netip.Addr) bool { + responseAddrs, err = r.dnsClient.LookupWithResponseCheck(dnsCtx, transport, domain, strategy, func(responseAddrs []netip.Addr) bool { metadata.DestinationAddresses = responseAddrs return rule.MatchAddressLimit(metadata) }) } else { addressLimit = false - resultAddrs, err = r.dnsClient.Lookup(dnsCtx, transport, domain, strategy) + responseAddrs, err = r.dnsClient.Lookup(dnsCtx, transport, domain, strategy) } cancel() if err != nil { - if errors.Is(err, dns.ErrResponseRejected) { + if errors.Is(err, dns.ErrResponseRejectedCached) { + r.dnsLogger.DebugContext(ctx, "response rejected for ", domain, " (cached)") + } else if errors.Is(err, dns.ErrResponseRejected) { r.dnsLogger.DebugContext(ctx, "response rejected for ", domain) } else { r.dnsLogger.ErrorContext(ctx, E.Cause(err, "lookup failed for ", domain)) } - } else if len(resultAddrs) == 0 { + } else if len(responseAddrs) == 0 { r.dnsLogger.ErrorContext(ctx, "lookup failed for ", domain, ": empty result") err = dns.RCodeNameError } @@ -216,10 +227,10 @@ func (r *Router) Lookup(ctx context.Context, domain string, strategy dns.DomainS break } } - if len(resultAddrs) > 0 { - r.dnsLogger.InfoContext(ctx, "lookup succeed for ", domain, ": ", strings.Join(F.MapToString(resultAddrs), " ")) + if len(responseAddrs) > 0 { + r.dnsLogger.InfoContext(ctx, "lookup succeed for ", domain, ": ", strings.Join(F.MapToString(responseAddrs), " ")) } - return resultAddrs, err + return responseAddrs, err } func (r *Router) LookupDefault(ctx context.Context, domain string) ([]netip.Addr, error) { diff --git a/transport/dhcp/server.go b/transport/dhcp/server.go index 2b7346c6..8325c37b 100644 --- a/transport/dhcp/server.go +++ b/transport/dhcp/server.go @@ -58,6 +58,7 @@ func NewTransport(options dns.TransportOptions) (*Transport, error) { return nil, E.New("missing router in context") } transport := &Transport{ + options: options, router: router, interfaceName: linkURL.Host, autoInterface: linkURL.Host == "auto",