Browse Source

Add rule-set

世界 1 year ago
parent
commit
4b43acfec0
51 changed files with 2959 additions and 249 deletions
  1. 1 0
      .gitignore
  2. 65 1
      adapter/experimental.go
  3. 15 2
      adapter/inbound.go
  4. 28 2
      adapter/router.go
  5. 4 0
      box.go
  6. 0 39
      cmd/sing-box/cmd_format.go
  7. 43 0
      cmd/sing-box/cmd_geoip.go
  8. 98 0
      cmd/sing-box/cmd_geoip_export.go
  9. 31 0
      cmd/sing-box/cmd_geoip_list.go
  10. 47 0
      cmd/sing-box/cmd_geoip_lookup.go
  11. 41 0
      cmd/sing-box/cmd_geosite.go
  12. 81 0
      cmd/sing-box/cmd_geosite_export.go
  13. 50 0
      cmd/sing-box/cmd_geosite_list.go
  14. 97 0
      cmd/sing-box/cmd_geosite_lookup.go
  15. 56 0
      cmd/sing-box/cmd_geosite_matcher.go
  16. 1 1
      cmd/sing-box/cmd_merge.go
  17. 14 0
      cmd/sing-box/cmd_rule_set.go
  18. 84 0
      cmd/sing-box/cmd_rule_set_compile.go
  19. 83 0
      cmd/sing-box/cmd_rule_set_format.go
  20. 1 5
      cmd/sing-box/cmd_tools.go
  21. 1 1
      cmd/sing-box/cmd_tools_connect.go
  22. 10 2
      common/dialer/router.go
  23. 487 0
      common/srs/binary.go
  24. 116 0
      common/srs/ip_set.go
  25. 8 0
      constant/rule.go
  26. 35 0
      experimental/cachefile/cache.go
  27. 1 1
      experimental/cachefile/fakeip.go
  28. 4 2
      experimental/clashapi/proxies.go
  29. 5 1
      experimental/clashapi/server_resources.go
  30. 6 2
      experimental/clashapi/trafficontrol/tracker.go
  31. 4 4
      option/experimental.go
  32. 1 0
      option/route.go
  33. 30 28
      option/rule.go
  34. 1 0
      option/rule_dns.go
  35. 230 0
      option/rule_set.go
  36. 226 0
      option/time_unit.go
  37. 9 1
      option/types.go
  38. 121 49
      route/router.go
  39. 1 0
      route/router_dns.go
  40. 0 70
      route/router_geo_resources.go
  41. 99 0
      route/router_rule.go
  42. 47 34
      route/rule_abstract.go
  43. 6 1
      route/rule_default.go
  44. 6 1
      route/rule_dns.go
  45. 173 0
      route/rule_headless.go
  46. 17 2
      route/rule_item_cidr.go
  47. 7 0
      route/rule_item_domain.go
  48. 55 0
      route/rule_item_rule_set.go
  49. 67 0
      route/rule_set.go
  50. 84 0
      route/rule_set_local.go
  51. 262 0
      route/rule_set_remote.go

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 /.idea/
 /vendor/
 /*.json
+/*.srs
 /*.db
 /site/
 /bin/

+ 65 - 1
adapter/experimental.go

@@ -1,11 +1,16 @@
 package adapter
 
 import (
+	"bytes"
 	"context"
+	"encoding/binary"
+	"io"
 	"net"
+	"time"
 
 	"github.com/sagernet/sing-box/common/urltest"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/rw"
 )
 
 type ClashServer interface {
@@ -23,6 +28,7 @@ type CacheFile interface {
 	PreStarter
 
 	StoreFakeIP() bool
+	FakeIPStorage
 
 	LoadMode() string
 	StoreMode(mode string) error
@@ -30,7 +36,65 @@ type CacheFile interface {
 	StoreSelected(group string, selected string) error
 	LoadGroupExpand(group string) (isExpand bool, loaded bool)
 	StoreGroupExpand(group string, expand bool) error
-	FakeIPStorage
+	LoadRuleSet(tag string) *SavedRuleSet
+	SaveRuleSet(tag string, set *SavedRuleSet) error
+}
+
+type SavedRuleSet struct {
+	Content     []byte
+	LastUpdated time.Time
+	LastEtag    string
+}
+
+func (s *SavedRuleSet) MarshalBinary() ([]byte, error) {
+	var buffer bytes.Buffer
+	err := binary.Write(&buffer, binary.BigEndian, uint8(1))
+	if err != nil {
+		return nil, err
+	}
+	err = rw.WriteUVariant(&buffer, uint64(len(s.Content)))
+	if err != nil {
+		return nil, err
+	}
+	buffer.Write(s.Content)
+	err = binary.Write(&buffer, binary.BigEndian, s.LastUpdated.Unix())
+	if err != nil {
+		return nil, err
+	}
+	err = rw.WriteVString(&buffer, s.LastEtag)
+	if err != nil {
+		return nil, err
+	}
+	return buffer.Bytes(), nil
+}
+
+func (s *SavedRuleSet) UnmarshalBinary(data []byte) error {
+	reader := bytes.NewReader(data)
+	var version uint8
+	err := binary.Read(reader, binary.BigEndian, &version)
+	if err != nil {
+		return err
+	}
+	contentLen, err := rw.ReadUVariant(reader)
+	if err != nil {
+		return err
+	}
+	s.Content = make([]byte, contentLen)
+	_, err = io.ReadFull(reader, s.Content)
+	if err != nil {
+		return err
+	}
+	var lastUpdated int64
+	err = binary.Read(reader, binary.BigEndian, &lastUpdated)
+	if err != nil {
+		return err
+	}
+	s.LastUpdated = time.Unix(lastUpdated, 0)
+	s.LastEtag, err = rw.ReadVString(reader)
+	if err != nil {
+		return err
+	}
+	return nil
 }
 
 type Tracker interface {

+ 15 - 2
adapter/inbound.go

@@ -46,11 +46,24 @@ type InboundContext struct {
 	SourceGeoIPCode      string
 	GeoIPCode            string
 	ProcessInfo          *process.Info
+	QueryType            uint16
 	FakeIP               bool
 
-	// dns cache
+	// rule cache
 
-	QueryType uint16
+	IPCIDRMatchSource       bool
+	SourceAddressMatch      bool
+	SourcePortMatch         bool
+	DestinationAddressMatch bool
+	DestinationPortMatch    bool
+}
+
+func (c *InboundContext) ResetRuleCache() {
+	c.IPCIDRMatchSource = false
+	c.SourceAddressMatch = false
+	c.SourcePortMatch = false
+	c.DestinationAddressMatch = false
+	c.DestinationPortMatch = false
 }
 
 type inboundContextKey struct{}

+ 28 - 2
adapter/router.go

@@ -2,12 +2,14 @@ package adapter
 
 import (
 	"context"
+	"net/http"
 	"net/netip"
 
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing/common/control"
+	N "github.com/sagernet/sing/common/network"
 	"github.com/sagernet/sing/service"
 
 	mdns "github.com/miekg/dns"
@@ -19,7 +21,7 @@ type Router interface {
 
 	Outbounds() []Outbound
 	Outbound(tag string) (Outbound, bool)
-	DefaultOutbound(network string) Outbound
+	DefaultOutbound(network string) (Outbound, error)
 
 	FakeIPStore() FakeIPStore
 
@@ -28,6 +30,8 @@ type Router interface {
 	GeoIPReader() *geoip.Reader
 	LoadGeosite(code string) (Rule, error)
 
+	RuleSet(tag string) (RuleSet, bool)
+
 	Exchange(ctx context.Context, message *mdns.Msg) (*mdns.Msg, error)
 	Lookup(ctx context.Context, domain string, strategy dns.DomainStrategy) ([]netip.Addr, error)
 	LookupDefault(ctx context.Context, domain string) ([]netip.Addr, error)
@@ -62,11 +66,15 @@ func RouterFromContext(ctx context.Context) Router {
 	return service.FromContext[Router](ctx)
 }
 
+type HeadlessRule interface {
+	Match(metadata *InboundContext) bool
+}
+
 type Rule interface {
+	HeadlessRule
 	Service
 	Type() string
 	UpdateGeosite() error
-	Match(metadata *InboundContext) bool
 	Outbound() string
 	String() string
 }
@@ -77,6 +85,24 @@ type DNSRule interface {
 	RewriteTTL() *uint32
 }
 
+type RuleSet interface {
+	StartContext(ctx context.Context, startContext RuleSetStartContext) error
+	PostStart() error
+	Metadata() RuleSetMetadata
+	Close() error
+	HeadlessRule
+}
+
+type RuleSetMetadata struct {
+	ContainsProcessRule bool
+	ContainsWIFIRule    bool
+}
+
+type RuleSetStartContext interface {
+	HTTPClient(detour string, dialer N.Dialer) *http.Client
+	Close()
+}
+
 type InterfaceUpdateListener interface {
 	InterfaceUpdated()
 }

+ 4 - 0
box.go

@@ -308,6 +308,10 @@ func (s *Box) postStart() error {
 		}
 	}
 	s.logger.Trace("post-starting router")
+	err := s.router.PostStart()
+	if err != nil {
+		return E.Cause(err, "post-start router")
+	}
 	return s.router.PostStart()
 }
 

+ 0 - 39
cmd/sing-box/cmd_format.go

@@ -7,7 +7,6 @@ import (
 
 	"github.com/sagernet/sing-box/common/json"
 	"github.com/sagernet/sing-box/log"
-	"github.com/sagernet/sing-box/option"
 	E "github.com/sagernet/sing/common/exceptions"
 
 	"github.com/spf13/cobra"
@@ -69,41 +68,3 @@ func format() error {
 	}
 	return nil
 }
-
-func formatOne(configPath string) error {
-	configContent, err := os.ReadFile(configPath)
-	if err != nil {
-		return E.Cause(err, "read config")
-	}
-	var options option.Options
-	err = options.UnmarshalJSON(configContent)
-	if err != nil {
-		return E.Cause(err, "decode config")
-	}
-	buffer := new(bytes.Buffer)
-	encoder := json.NewEncoder(buffer)
-	encoder.SetIndent("", "  ")
-	err = encoder.Encode(options)
-	if err != nil {
-		return E.Cause(err, "encode config")
-	}
-	if !commandFormatFlagWrite {
-		os.Stdout.WriteString(buffer.String() + "\n")
-		return nil
-	}
-	if bytes.Equal(configContent, buffer.Bytes()) {
-		return nil
-	}
-	output, err := os.Create(configPath)
-	if err != nil {
-		return E.Cause(err, "open output")
-	}
-	_, err = output.Write(buffer.Bytes())
-	output.Close()
-	if err != nil {
-		return E.Cause(err, "write output")
-	}
-	outputPath, _ := filepath.Abs(configPath)
-	os.Stderr.WriteString(outputPath + "\n")
-	return nil
-}

+ 43 - 0
cmd/sing-box/cmd_geoip.go

@@ -0,0 +1,43 @@
+package main
+
+import (
+	"github.com/sagernet/sing-box/log"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/oschwald/maxminddb-golang"
+	"github.com/spf13/cobra"
+)
+
+var (
+	geoipReader          *maxminddb.Reader
+	commandGeoIPFlagFile string
+)
+
+var commandGeoip = &cobra.Command{
+	Use:   "geoip",
+	Short: "GeoIP tools",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {
+		err := geoipPreRun()
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoip.PersistentFlags().StringVarP(&commandGeoIPFlagFile, "file", "f", "geoip.db", "geoip file")
+	mainCommand.AddCommand(commandGeoip)
+}
+
+func geoipPreRun() error {
+	reader, err := maxminddb.Open(commandGeoIPFlagFile)
+	if err != nil {
+		return err
+	}
+	if reader.Metadata.DatabaseType != "sing-geoip" {
+		reader.Close()
+		return E.New("incorrect database type, expected sing-geoip, got ", reader.Metadata.DatabaseType)
+	}
+	geoipReader = reader
+	return nil
+}

+ 98 - 0
cmd/sing-box/cmd_geoip_export.go

@@ -0,0 +1,98 @@
+package main
+
+import (
+	"io"
+	"net"
+	"os"
+	"strings"
+
+	"github.com/sagernet/sing-box/common/json"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/oschwald/maxminddb-golang"
+	"github.com/spf13/cobra"
+)
+
+var flagGeoipExportOutput string
+
+const flagGeoipExportDefaultOutput = "geoip-<country>.srs"
+
+var commandGeoipExport = &cobra.Command{
+	Use:   "export <country>",
+	Short: "Export geoip country as rule-set",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := geoipExport(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoipExport.Flags().StringVarP(&flagGeoipExportOutput, "output", "o", flagGeoipExportDefaultOutput, "Output path")
+	commandGeoip.AddCommand(commandGeoipExport)
+}
+
+func geoipExport(countryCode string) error {
+	networks := geoipReader.Networks(maxminddb.SkipAliasedNetworks)
+	countryMap := make(map[string][]*net.IPNet)
+	var (
+		ipNet           *net.IPNet
+		nextCountryCode string
+		err             error
+	)
+	for networks.Next() {
+		ipNet, err = networks.Network(&nextCountryCode)
+		if err != nil {
+			return err
+		}
+		countryMap[nextCountryCode] = append(countryMap[nextCountryCode], ipNet)
+	}
+	ipNets := countryMap[strings.ToLower(countryCode)]
+	if len(ipNets) == 0 {
+		return E.New("country code not found: ", countryCode)
+	}
+
+	var (
+		outputFile   *os.File
+		outputWriter io.Writer
+	)
+	if flagGeoipExportOutput == "stdout" {
+		outputWriter = os.Stdout
+	} else if flagGeoipExportOutput == flagGeoipExportDefaultOutput {
+		outputFile, err = os.Create("geoip-" + countryCode + ".json")
+		if err != nil {
+			return err
+		}
+		defer outputFile.Close()
+		outputWriter = outputFile
+	} else {
+		outputFile, err = os.Create(flagGeoipExportOutput)
+		if err != nil {
+			return err
+		}
+		defer outputFile.Close()
+		outputWriter = outputFile
+	}
+
+	encoder := json.NewEncoder(outputWriter)
+	encoder.SetIndent("", "  ")
+	var headlessRule option.DefaultHeadlessRule
+	headlessRule.IPCIDR = make([]string, 0, len(ipNets))
+	for _, cidr := range ipNets {
+		headlessRule.IPCIDR = append(headlessRule.IPCIDR, cidr.String())
+	}
+	var plainRuleSet option.PlainRuleSetCompat
+	plainRuleSet.Version = C.RuleSetVersion1
+	plainRuleSet.Options.Rules = []option.HeadlessRule{
+		{
+			Type:           C.RuleTypeDefault,
+			DefaultOptions: headlessRule,
+		},
+	}
+	return encoder.Encode(plainRuleSet)
+}

+ 31 - 0
cmd/sing-box/cmd_geoip_list.go

@@ -0,0 +1,31 @@
+package main
+
+import (
+	"os"
+
+	"github.com/sagernet/sing-box/log"
+
+	"github.com/spf13/cobra"
+)
+
+var commandGeoipList = &cobra.Command{
+	Use:   "list",
+	Short: "List geoip country codes",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := listGeoip()
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoip.AddCommand(commandGeoipList)
+}
+
+func listGeoip() error {
+	for _, code := range geoipReader.Metadata.Languages {
+		os.Stdout.WriteString(code + "\n")
+	}
+	return nil
+}

+ 47 - 0
cmd/sing-box/cmd_geoip_lookup.go

@@ -0,0 +1,47 @@
+package main
+
+import (
+	"net/netip"
+	"os"
+
+	"github.com/sagernet/sing-box/log"
+	E "github.com/sagernet/sing/common/exceptions"
+	N "github.com/sagernet/sing/common/network"
+
+	"github.com/spf13/cobra"
+)
+
+var commandGeoipLookup = &cobra.Command{
+	Use:   "lookup <address>",
+	Short: "Lookup if an IP address is contained in the GeoIP database",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := geoipLookup(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoip.AddCommand(commandGeoipLookup)
+}
+
+func geoipLookup(address string) error {
+	addr, err := netip.ParseAddr(address)
+	if err != nil {
+		return E.Cause(err, "parse address")
+	}
+	if !N.IsPublicAddr(addr) {
+		os.Stdout.WriteString("private\n")
+		return nil
+	}
+	var code string
+	_ = geoipReader.Lookup(addr.AsSlice(), &code)
+	if code != "" {
+		os.Stdout.WriteString(code + "\n")
+		return nil
+	}
+	os.Stdout.WriteString("unknown\n")
+	return nil
+}

+ 41 - 0
cmd/sing-box/cmd_geosite.go

@@ -0,0 +1,41 @@
+package main
+
+import (
+	"github.com/sagernet/sing-box/common/geosite"
+	"github.com/sagernet/sing-box/log"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/spf13/cobra"
+)
+
+var (
+	commandGeoSiteFlagFile string
+	geositeReader          *geosite.Reader
+	geositeCodeList        []string
+)
+
+var commandGeoSite = &cobra.Command{
+	Use:   "geosite",
+	Short: "Geosite tools",
+	PersistentPreRun: func(cmd *cobra.Command, args []string) {
+		err := geositePreRun()
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoSite.PersistentFlags().StringVarP(&commandGeoSiteFlagFile, "file", "f", "geosite.db", "geosite file")
+	mainCommand.AddCommand(commandGeoSite)
+}
+
+func geositePreRun() error {
+	reader, codeList, err := geosite.Open(commandGeoSiteFlagFile)
+	if err != nil {
+		return E.Cause(err, "open geosite file")
+	}
+	geositeReader = reader
+	geositeCodeList = codeList
+	return nil
+}

+ 81 - 0
cmd/sing-box/cmd_geosite_export.go

@@ -0,0 +1,81 @@
+package main
+
+import (
+	"encoding/json"
+	"io"
+	"os"
+
+	"github.com/sagernet/sing-box/common/geosite"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+
+	"github.com/spf13/cobra"
+)
+
+var commandGeositeExportOutput string
+
+const commandGeositeExportDefaultOutput = "geosite-<category>.json"
+
+var commandGeositeExport = &cobra.Command{
+	Use:   "export <category>",
+	Short: "Export geosite category as rule-set",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := geositeExport(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeositeExport.Flags().StringVarP(&commandGeositeExportOutput, "output", "o", commandGeositeExportDefaultOutput, "Output path")
+	commandGeoSite.AddCommand(commandGeositeExport)
+}
+
+func geositeExport(category string) error {
+	sourceSet, err := geositeReader.Read(category)
+	if err != nil {
+		return err
+	}
+	var (
+		outputFile   *os.File
+		outputWriter io.Writer
+	)
+	if commandGeositeExportOutput == "stdout" {
+		outputWriter = os.Stdout
+	} else if commandGeositeExportOutput == commandGeositeExportDefaultOutput {
+		outputFile, err = os.Create("geosite-" + category + ".json")
+		if err != nil {
+			return err
+		}
+		defer outputFile.Close()
+		outputWriter = outputFile
+	} else {
+		outputFile, err = os.Create(commandGeositeExportOutput)
+		if err != nil {
+			return err
+		}
+		defer outputFile.Close()
+		outputWriter = outputFile
+	}
+
+	encoder := json.NewEncoder(outputWriter)
+	encoder.SetIndent("", "  ")
+	var headlessRule option.DefaultHeadlessRule
+	defaultRule := geosite.Compile(sourceSet)
+	headlessRule.Domain = defaultRule.Domain
+	headlessRule.DomainSuffix = defaultRule.DomainSuffix
+	headlessRule.DomainKeyword = defaultRule.DomainKeyword
+	headlessRule.DomainRegex = defaultRule.DomainRegex
+	var plainRuleSet option.PlainRuleSetCompat
+	plainRuleSet.Version = C.RuleSetVersion1
+	plainRuleSet.Options.Rules = []option.HeadlessRule{
+		{
+			Type:           C.RuleTypeDefault,
+			DefaultOptions: headlessRule,
+		},
+	}
+	return encoder.Encode(plainRuleSet)
+}

+ 50 - 0
cmd/sing-box/cmd_geosite_list.go

@@ -0,0 +1,50 @@
+package main
+
+import (
+	"os"
+	"sort"
+
+	"github.com/sagernet/sing-box/log"
+	F "github.com/sagernet/sing/common/format"
+
+	"github.com/spf13/cobra"
+)
+
+var commandGeositeList = &cobra.Command{
+	Use:   "list <category>",
+	Short: "List geosite categories",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := geositeList()
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoSite.AddCommand(commandGeositeList)
+}
+
+func geositeList() error {
+	var geositeEntry []struct {
+		category string
+		items    int
+	}
+	for _, category := range geositeCodeList {
+		sourceSet, err := geositeReader.Read(category)
+		if err != nil {
+			return err
+		}
+		geositeEntry = append(geositeEntry, struct {
+			category string
+			items    int
+		}{category, len(sourceSet)})
+	}
+	sort.SliceStable(geositeEntry, func(i, j int) bool {
+		return geositeEntry[i].items < geositeEntry[j].items
+	})
+	for _, entry := range geositeEntry {
+		os.Stdout.WriteString(F.ToString(entry.category, " (", entry.items, ")\n"))
+	}
+	return nil
+}

+ 97 - 0
cmd/sing-box/cmd_geosite_lookup.go

@@ -0,0 +1,97 @@
+package main
+
+import (
+	"os"
+	"sort"
+
+	"github.com/sagernet/sing-box/log"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/spf13/cobra"
+)
+
+var commandGeositeLookup = &cobra.Command{
+	Use:   "lookup [category] <domain>",
+	Short: "Check if a domain is in the geosite",
+	Args:  cobra.RangeArgs(1, 2),
+	Run: func(cmd *cobra.Command, args []string) {
+		var (
+			source string
+			target string
+		)
+		switch len(args) {
+		case 1:
+			target = args[0]
+		case 2:
+			source = args[0]
+			target = args[1]
+		}
+		err := geositeLookup(source, target)
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandGeoSite.AddCommand(commandGeositeLookup)
+}
+
+func geositeLookup(source string, target string) error {
+	var sourceMatcherList []struct {
+		code    string
+		matcher *searchGeositeMatcher
+	}
+	if source != "" {
+		sourceSet, err := geositeReader.Read(source)
+		if err != nil {
+			return err
+		}
+		sourceMatcher, err := newSearchGeositeMatcher(sourceSet)
+		if err != nil {
+			return E.Cause(err, "compile code: "+source)
+		}
+		sourceMatcherList = []struct {
+			code    string
+			matcher *searchGeositeMatcher
+		}{
+			{
+				code:    source,
+				matcher: sourceMatcher,
+			},
+		}
+
+	} else {
+		for _, code := range geositeCodeList {
+			sourceSet, err := geositeReader.Read(code)
+			if err != nil {
+				return err
+			}
+			sourceMatcher, err := newSearchGeositeMatcher(sourceSet)
+			if err != nil {
+				return E.Cause(err, "compile code: "+code)
+			}
+			sourceMatcherList = append(sourceMatcherList, struct {
+				code    string
+				matcher *searchGeositeMatcher
+			}{
+				code:    code,
+				matcher: sourceMatcher,
+			})
+		}
+	}
+	sort.SliceStable(sourceMatcherList, func(i, j int) bool {
+		return sourceMatcherList[i].code < sourceMatcherList[j].code
+	})
+
+	for _, matcherItem := range sourceMatcherList {
+		if matchRule := matcherItem.matcher.Match(target); matchRule != "" {
+			os.Stdout.WriteString("Match code (")
+			os.Stdout.WriteString(matcherItem.code)
+			os.Stdout.WriteString(") ")
+			os.Stdout.WriteString(matchRule)
+			os.Stdout.WriteString("\n")
+		}
+	}
+	return nil
+}

+ 56 - 0
cmd/sing-box/cmd_geosite_matcher.go

@@ -0,0 +1,56 @@
+package main
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/sagernet/sing-box/common/geosite"
+)
+
+type searchGeositeMatcher struct {
+	domainMap   map[string]bool
+	suffixList  []string
+	keywordList []string
+	regexList   []string
+}
+
+func newSearchGeositeMatcher(items []geosite.Item) (*searchGeositeMatcher, error) {
+	options := geosite.Compile(items)
+	domainMap := make(map[string]bool)
+	for _, domain := range options.Domain {
+		domainMap[domain] = true
+	}
+	rule := &searchGeositeMatcher{
+		domainMap:   domainMap,
+		suffixList:  options.DomainSuffix,
+		keywordList: options.DomainKeyword,
+		regexList:   options.DomainRegex,
+	}
+	return rule, nil
+}
+
+func (r *searchGeositeMatcher) Match(domain string) string {
+	if r.domainMap[domain] {
+		return "domain=" + domain
+	}
+	for _, suffix := range r.suffixList {
+		if strings.HasSuffix(domain, suffix) {
+			return "domain_suffix=" + suffix
+		}
+	}
+	for _, keyword := range r.keywordList {
+		if strings.Contains(domain, keyword) {
+			return "domain_keyword=" + keyword
+		}
+	}
+	for _, regexStr := range r.regexList {
+		regex, err := regexp.Compile(regexStr)
+		if err != nil {
+			continue
+		}
+		if regex.MatchString(domain) {
+			return "domain_regex=" + regexStr
+		}
+	}
+	return ""
+}

+ 1 - 1
cmd/sing-box/cmd_merge.go

@@ -18,7 +18,7 @@ import (
 )
 
 var commandMerge = &cobra.Command{
-	Use:   "merge [output]",
+	Use:   "merge <output>",
 	Short: "Merge configurations",
 	Run: func(cmd *cobra.Command, args []string) {
 		err := merge(args[0])

+ 14 - 0
cmd/sing-box/cmd_rule_set.go

@@ -0,0 +1,14 @@
+package main
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var commandRuleSet = &cobra.Command{
+	Use:   "rule-set",
+	Short: "Manage rule sets",
+}
+
+func init() {
+	mainCommand.AddCommand(commandRuleSet)
+}

+ 84 - 0
cmd/sing-box/cmd_rule_set_compile.go

@@ -0,0 +1,84 @@
+package main
+
+import (
+	"io"
+	"os"
+	"strings"
+
+	"github.com/sagernet/sing-box/common/json"
+	"github.com/sagernet/sing-box/common/srs"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+
+	"github.com/spf13/cobra"
+)
+
+var flagRuleSetCompileOutput string
+
+const flagRuleSetCompileDefaultOutput = "<file_name>.srs"
+
+var commandRuleSetCompile = &cobra.Command{
+	Use:   "compile [source-path]",
+	Short: "Compile rule-set json to binary",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := compileRuleSet(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandRuleSet.AddCommand(commandRuleSetCompile)
+	commandRuleSetCompile.Flags().StringVarP(&flagRuleSetCompileOutput, "output", "o", flagRuleSetCompileDefaultOutput, "Output file")
+}
+
+func compileRuleSet(sourcePath string) error {
+	var (
+		reader io.Reader
+		err    error
+	)
+	if sourcePath == "stdin" {
+		reader = os.Stdin
+	} else {
+		reader, err = os.Open(sourcePath)
+		if err != nil {
+			return err
+		}
+	}
+	content, err := io.ReadAll(reader)
+	if err != nil {
+		return err
+	}
+	plainRuleSet, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content)
+	if err != nil {
+		return err
+	}
+	if err != nil {
+		return err
+	}
+	ruleSet := plainRuleSet.Upgrade()
+	var outputPath string
+	if flagRuleSetCompileOutput == flagRuleSetCompileDefaultOutput {
+		if strings.HasSuffix(sourcePath, ".json") {
+			outputPath = sourcePath[:len(sourcePath)-5] + ".srs"
+		} else {
+			outputPath = sourcePath + ".srs"
+		}
+	} else {
+		outputPath = flagRuleSetCompileOutput
+	}
+	outputFile, err := os.Create(outputPath)
+	if err != nil {
+		return err
+	}
+	err = srs.Write(outputFile, ruleSet)
+	if err != nil {
+		outputFile.Close()
+		os.Remove(outputPath)
+		return err
+	}
+	outputFile.Close()
+	return nil
+}

+ 83 - 0
cmd/sing-box/cmd_rule_set_format.go

@@ -0,0 +1,83 @@
+package main
+
+import (
+	"bytes"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/sagernet/sing-box/common/json"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+
+	"github.com/spf13/cobra"
+)
+
+var commandRuleSetFormatFlagWrite bool
+
+var commandRuleSetFormat = &cobra.Command{
+	Use:   "format <source-path>",
+	Short: "Format rule-set json",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := formatRuleSet(args[0])
+		if err != nil {
+			log.Fatal(err)
+		}
+	},
+}
+
+func init() {
+	commandRuleSetFormat.Flags().BoolVarP(&commandRuleSetFormatFlagWrite, "write", "w", false, "write result to (source) file instead of stdout")
+	commandRuleSet.AddCommand(commandRuleSetFormat)
+}
+
+func formatRuleSet(sourcePath string) error {
+	var (
+		reader io.Reader
+		err    error
+	)
+	if sourcePath == "stdin" {
+		reader = os.Stdin
+	} else {
+		reader, err = os.Open(sourcePath)
+		if err != nil {
+			return err
+		}
+	}
+	content, err := io.ReadAll(reader)
+	if err != nil {
+		return err
+	}
+	plainRuleSet, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content)
+	if err != nil {
+		return err
+	}
+	buffer := new(bytes.Buffer)
+	encoder := json.NewEncoder(buffer)
+	encoder.SetIndent("", "  ")
+	err = encoder.Encode(plainRuleSet)
+	if err != nil {
+		return E.Cause(err, "encode config")
+	}
+	outputPath, _ := filepath.Abs(sourcePath)
+	if !commandRuleSetFormatFlagWrite || sourcePath == "stdin" {
+		os.Stdout.WriteString(buffer.String() + "\n")
+		return nil
+	}
+	if bytes.Equal(content, buffer.Bytes()) {
+		return nil
+	}
+	output, err := os.Create(sourcePath)
+	if err != nil {
+		return E.Cause(err, "open output")
+	}
+	_, err = output.Write(buffer.Bytes())
+	output.Close()
+	if err != nil {
+		return E.Cause(err, "write output")
+	}
+	os.Stderr.WriteString(outputPath + "\n")
+	return nil
+}

+ 1 - 5
cmd/sing-box/cmd_tools.go

@@ -38,11 +38,7 @@ func createPreStartedClient() (*box.Box, error) {
 
 func createDialer(instance *box.Box, network string, outboundTag string) (N.Dialer, error) {
 	if outboundTag == "" {
-		outbound := instance.Router().DefaultOutbound(N.NetworkName(network))
-		if outbound == nil {
-			return nil, E.New("missing default outbound")
-		}
-		return outbound, nil
+		return instance.Router().DefaultOutbound(N.NetworkName(network))
 	} else {
 		outbound, loaded := instance.Router().Outbound(outboundTag)
 		if !loaded {

+ 1 - 1
cmd/sing-box/cmd_tools_connect.go

@@ -18,7 +18,7 @@ import (
 var commandConnectFlagNetwork string
 
 var commandConnect = &cobra.Command{
-	Use:   "connect [address]",
+	Use:   "connect <address>",
 	Short: "Connect to an address",
 	Args:  cobra.ExactArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {

+ 10 - 2
common/dialer/router.go

@@ -18,11 +18,19 @@ func NewRouter(router adapter.Router) N.Dialer {
 }
 
 func (d *RouterDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
-	return d.router.DefaultOutbound(network).DialContext(ctx, network, destination)
+	dialer, err := d.router.DefaultOutbound(network)
+	if err != nil {
+		return nil, err
+	}
+	return dialer.DialContext(ctx, network, destination)
 }
 
 func (d *RouterDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
-	return d.router.DefaultOutbound(N.NetworkUDP).ListenPacket(ctx, destination)
+	dialer, err := d.router.DefaultOutbound(N.NetworkUDP)
+	if err != nil {
+		return nil, err
+	}
+	return dialer.ListenPacket(ctx, destination)
 }
 
 func (d *RouterDialer) Upstream() any {

+ 487 - 0
common/srs/binary.go

@@ -0,0 +1,487 @@
+package srs
+
+import (
+	"compress/zlib"
+	"encoding/binary"
+	"io"
+	"net/netip"
+
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/domain"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/rw"
+
+	"go4.org/netipx"
+)
+
+var MagicBytes = [3]byte{0x53, 0x52, 0x53} // SRS
+
+const (
+	ruleItemQueryType uint8 = iota
+	ruleItemNetwork
+	ruleItemDomain
+	ruleItemDomainKeyword
+	ruleItemDomainRegex
+	ruleItemSourceIPCIDR
+	ruleItemIPCIDR
+	ruleItemSourcePort
+	ruleItemSourcePortRange
+	ruleItemPort
+	ruleItemPortRange
+	ruleItemProcessName
+	ruleItemProcessPath
+	ruleItemPackageName
+	ruleItemWIFISSID
+	ruleItemWIFIBSSID
+	ruleItemFinal uint8 = 0xFF
+)
+
+func Read(reader io.Reader, recovery bool) (ruleSet option.PlainRuleSet, err error) {
+	var magicBytes [3]byte
+	_, err = io.ReadFull(reader, magicBytes[:])
+	if err != nil {
+		return
+	}
+	if magicBytes != MagicBytes {
+		err = E.New("invalid sing-box rule set file")
+		return
+	}
+	var version uint8
+	err = binary.Read(reader, binary.BigEndian, &version)
+	if err != nil {
+		return ruleSet, err
+	}
+	if version != 1 {
+		return ruleSet, E.New("unsupported version: ", version)
+	}
+	zReader, err := zlib.NewReader(reader)
+	if err != nil {
+		return
+	}
+	length, err := rw.ReadUVariant(zReader)
+	if err != nil {
+		return
+	}
+	ruleSet.Rules = make([]option.HeadlessRule, length)
+	for i := uint64(0); i < length; i++ {
+		ruleSet.Rules[i], err = readRule(zReader, recovery)
+		if err != nil {
+			err = E.Cause(err, "read rule[", i, "]")
+			return
+		}
+	}
+	return
+}
+
+func Write(writer io.Writer, ruleSet option.PlainRuleSet) error {
+	_, err := writer.Write(MagicBytes[:])
+	if err != nil {
+		return err
+	}
+	err = binary.Write(writer, binary.BigEndian, uint8(1))
+	if err != nil {
+		return err
+	}
+	zWriter, err := zlib.NewWriterLevel(writer, zlib.BestCompression)
+	if err != nil {
+		return err
+	}
+	err = rw.WriteUVariant(zWriter, uint64(len(ruleSet.Rules)))
+	if err != nil {
+		return err
+	}
+	for _, rule := range ruleSet.Rules {
+		err = writeRule(zWriter, rule)
+		if err != nil {
+			return err
+		}
+	}
+	return zWriter.Close()
+}
+
+func readRule(reader io.Reader, recovery bool) (rule option.HeadlessRule, err error) {
+	var ruleType uint8
+	err = binary.Read(reader, binary.BigEndian, &ruleType)
+	if err != nil {
+		return
+	}
+	switch ruleType {
+	case 0:
+		rule.Type = C.RuleTypeDefault
+		rule.DefaultOptions, err = readDefaultRule(reader, recovery)
+	case 1:
+		rule.Type = C.RuleTypeLogical
+		rule.LogicalOptions, err = readLogicalRule(reader, recovery)
+	default:
+		err = E.New("unknown rule type: ", ruleType)
+	}
+	return
+}
+
+func writeRule(writer io.Writer, rule option.HeadlessRule) error {
+	switch rule.Type {
+	case C.RuleTypeDefault:
+		return writeDefaultRule(writer, rule.DefaultOptions)
+	case C.RuleTypeLogical:
+		return writeLogicalRule(writer, rule.LogicalOptions)
+	default:
+		panic("unknown rule type: " + rule.Type)
+	}
+}
+
+func readDefaultRule(reader io.Reader, recovery bool) (rule option.DefaultHeadlessRule, err error) {
+	var lastItemType uint8
+	for {
+		var itemType uint8
+		err = binary.Read(reader, binary.BigEndian, &itemType)
+		if err != nil {
+			return
+		}
+		switch itemType {
+		case ruleItemQueryType:
+			var rawQueryType []uint16
+			rawQueryType, err = readRuleItemUint16(reader)
+			if err != nil {
+				return
+			}
+			rule.QueryType = common.Map(rawQueryType, func(it uint16) option.DNSQueryType {
+				return option.DNSQueryType(it)
+			})
+		case ruleItemNetwork:
+			rule.Network, err = readRuleItemString(reader)
+		case ruleItemDomain:
+			var matcher *domain.Matcher
+			matcher, err = domain.ReadMatcher(reader)
+			if err != nil {
+				return
+			}
+			rule.DomainMatcher = matcher
+		case ruleItemDomainKeyword:
+			rule.DomainKeyword, err = readRuleItemString(reader)
+		case ruleItemDomainRegex:
+			rule.DomainRegex, err = readRuleItemString(reader)
+		case ruleItemSourceIPCIDR:
+			rule.SourceIPSet, err = readIPSet(reader)
+			if err != nil {
+				return
+			}
+			if recovery {
+				rule.SourceIPCIDR = common.Map(rule.SourceIPSet.Prefixes(), netip.Prefix.String)
+			}
+		case ruleItemIPCIDR:
+			rule.IPSet, err = readIPSet(reader)
+			if err != nil {
+				return
+			}
+			if recovery {
+				rule.IPCIDR = common.Map(rule.IPSet.Prefixes(), netip.Prefix.String)
+			}
+		case ruleItemSourcePort:
+			rule.SourcePort, err = readRuleItemUint16(reader)
+		case ruleItemSourcePortRange:
+			rule.SourcePortRange, err = readRuleItemString(reader)
+		case ruleItemPort:
+			rule.Port, err = readRuleItemUint16(reader)
+		case ruleItemPortRange:
+			rule.PortRange, err = readRuleItemString(reader)
+		case ruleItemProcessName:
+			rule.ProcessName, err = readRuleItemString(reader)
+		case ruleItemProcessPath:
+			rule.ProcessPath, err = readRuleItemString(reader)
+		case ruleItemPackageName:
+			rule.PackageName, err = readRuleItemString(reader)
+		case ruleItemWIFISSID:
+			rule.WIFISSID, err = readRuleItemString(reader)
+		case ruleItemWIFIBSSID:
+			rule.WIFIBSSID, err = readRuleItemString(reader)
+		case ruleItemFinal:
+			err = binary.Read(reader, binary.BigEndian, &rule.Invert)
+			return
+		default:
+			err = E.New("unknown rule item type: ", itemType, ", last type: ", lastItemType)
+		}
+		if err != nil {
+			return
+		}
+		lastItemType = itemType
+	}
+}
+
+func writeDefaultRule(writer io.Writer, rule option.DefaultHeadlessRule) error {
+	err := binary.Write(writer, binary.BigEndian, uint8(0))
+	if err != nil {
+		return err
+	}
+	if len(rule.QueryType) > 0 {
+		err = writeRuleItemUint16(writer, ruleItemQueryType, common.Map(rule.QueryType, func(it option.DNSQueryType) uint16 {
+			return uint16(it)
+		}))
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.Network) > 0 {
+		err = writeRuleItemString(writer, ruleItemNetwork, rule.Network)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.Domain) > 0 || len(rule.DomainSuffix) > 0 {
+		err = binary.Write(writer, binary.BigEndian, ruleItemDomain)
+		if err != nil {
+			return err
+		}
+		err = domain.NewMatcher(rule.Domain, rule.DomainSuffix).Write(writer)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.DomainKeyword) > 0 {
+		err = writeRuleItemString(writer, ruleItemDomainKeyword, rule.DomainKeyword)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.DomainRegex) > 0 {
+		err = writeRuleItemString(writer, ruleItemDomainRegex, rule.DomainRegex)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.SourceIPCIDR) > 0 {
+		err = writeRuleItemCIDR(writer, ruleItemSourceIPCIDR, rule.SourceIPCIDR)
+		if err != nil {
+			return E.Cause(err, "source_ipcidr")
+		}
+	}
+	if len(rule.IPCIDR) > 0 {
+		err = writeRuleItemCIDR(writer, ruleItemIPCIDR, rule.IPCIDR)
+		if err != nil {
+			return E.Cause(err, "ipcidr")
+		}
+	}
+	if len(rule.SourcePort) > 0 {
+		err = writeRuleItemUint16(writer, ruleItemSourcePort, rule.SourcePort)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.SourcePortRange) > 0 {
+		err = writeRuleItemString(writer, ruleItemSourcePortRange, rule.SourcePortRange)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.Port) > 0 {
+		err = writeRuleItemUint16(writer, ruleItemPort, rule.Port)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.PortRange) > 0 {
+		err = writeRuleItemString(writer, ruleItemPortRange, rule.PortRange)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.ProcessName) > 0 {
+		err = writeRuleItemString(writer, ruleItemProcessName, rule.ProcessName)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.ProcessPath) > 0 {
+		err = writeRuleItemString(writer, ruleItemProcessPath, rule.ProcessPath)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.PackageName) > 0 {
+		err = writeRuleItemString(writer, ruleItemPackageName, rule.PackageName)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.WIFISSID) > 0 {
+		err = writeRuleItemString(writer, ruleItemWIFISSID, rule.WIFISSID)
+		if err != nil {
+			return err
+		}
+	}
+	if len(rule.WIFIBSSID) > 0 {
+		err = writeRuleItemString(writer, ruleItemWIFIBSSID, rule.WIFIBSSID)
+		if err != nil {
+			return err
+		}
+	}
+	err = binary.Write(writer, binary.BigEndian, ruleItemFinal)
+	if err != nil {
+		return err
+	}
+	err = binary.Write(writer, binary.BigEndian, rule.Invert)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func readRuleItemString(reader io.Reader) ([]string, error) {
+	length, err := rw.ReadUVariant(reader)
+	if err != nil {
+		return nil, err
+	}
+	value := make([]string, length)
+	for i := uint64(0); i < length; i++ {
+		value[i], err = rw.ReadVString(reader)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return value, nil
+}
+
+func writeRuleItemString(writer io.Writer, itemType uint8, value []string) error {
+	err := binary.Write(writer, binary.BigEndian, itemType)
+	if err != nil {
+		return err
+	}
+	err = rw.WriteUVariant(writer, uint64(len(value)))
+	if err != nil {
+		return err
+	}
+	for _, item := range value {
+		err = rw.WriteVString(writer, item)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func readRuleItemUint16(reader io.Reader) ([]uint16, error) {
+	length, err := rw.ReadUVariant(reader)
+	if err != nil {
+		return nil, err
+	}
+	value := make([]uint16, length)
+	for i := uint64(0); i < length; i++ {
+		err = binary.Read(reader, binary.BigEndian, &value[i])
+		if err != nil {
+			return nil, err
+		}
+	}
+	return value, nil
+}
+
+func writeRuleItemUint16(writer io.Writer, itemType uint8, value []uint16) error {
+	err := binary.Write(writer, binary.BigEndian, itemType)
+	if err != nil {
+		return err
+	}
+	err = rw.WriteUVariant(writer, uint64(len(value)))
+	if err != nil {
+		return err
+	}
+	for _, item := range value {
+		err = binary.Write(writer, binary.BigEndian, item)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func writeRuleItemCIDR(writer io.Writer, itemType uint8, value []string) error {
+	var builder netipx.IPSetBuilder
+	for i, prefixString := range value {
+		prefix, err := netip.ParsePrefix(prefixString)
+		if err == nil {
+			builder.AddPrefix(prefix)
+			continue
+		}
+		addr, addrErr := netip.ParseAddr(prefixString)
+		if addrErr == nil {
+			builder.Add(addr)
+			continue
+		}
+		return E.Cause(err, "parse [", i, "]")
+	}
+	ipSet, err := builder.IPSet()
+	if err != nil {
+		return err
+	}
+	err = binary.Write(writer, binary.BigEndian, itemType)
+	if err != nil {
+		return err
+	}
+	return writeIPSet(writer, ipSet)
+}
+
+func readLogicalRule(reader io.Reader, recovery bool) (logicalRule option.LogicalHeadlessRule, err error) {
+	var mode uint8
+	err = binary.Read(reader, binary.BigEndian, &mode)
+	if err != nil {
+		return
+	}
+	switch mode {
+	case 0:
+		logicalRule.Mode = C.LogicalTypeAnd
+	case 1:
+		logicalRule.Mode = C.LogicalTypeOr
+	default:
+		err = E.New("unknown logical mode: ", mode)
+		return
+	}
+	length, err := rw.ReadUVariant(reader)
+	if err != nil {
+		return
+	}
+	logicalRule.Rules = make([]option.HeadlessRule, length)
+	for i := uint64(0); i < length; i++ {
+		logicalRule.Rules[i], err = readRule(reader, recovery)
+		if err != nil {
+			err = E.Cause(err, "read logical rule [", i, "]")
+			return
+		}
+	}
+	err = binary.Read(reader, binary.BigEndian, &logicalRule.Invert)
+	if err != nil {
+		return
+	}
+	return
+}
+
+func writeLogicalRule(writer io.Writer, logicalRule option.LogicalHeadlessRule) error {
+	err := binary.Write(writer, binary.BigEndian, uint8(1))
+	if err != nil {
+		return err
+	}
+	switch logicalRule.Mode {
+	case C.LogicalTypeAnd:
+		err = binary.Write(writer, binary.BigEndian, uint8(0))
+	case C.LogicalTypeOr:
+		err = binary.Write(writer, binary.BigEndian, uint8(1))
+	default:
+		panic("unknown logical mode: " + logicalRule.Mode)
+	}
+	if err != nil {
+		return err
+	}
+	err = rw.WriteUVariant(writer, uint64(len(logicalRule.Rules)))
+	if err != nil {
+		return err
+	}
+	for _, rule := range logicalRule.Rules {
+		err = writeRule(writer, rule)
+		if err != nil {
+			return err
+		}
+	}
+	err = binary.Write(writer, binary.BigEndian, logicalRule.Invert)
+	if err != nil {
+		return err
+	}
+	return nil
+}

+ 116 - 0
common/srs/ip_set.go

@@ -0,0 +1,116 @@
+package srs
+
+import (
+	"encoding/binary"
+	"io"
+	"net/netip"
+	"unsafe"
+
+	"github.com/sagernet/sing/common/rw"
+
+	"go4.org/netipx"
+)
+
+type myIPSet struct {
+	rr []myIPRange
+}
+
+type myIPRange struct {
+	from netip.Addr
+	to   netip.Addr
+}
+
+func readIPSet(reader io.Reader) (*netipx.IPSet, error) {
+	var version uint8
+	err := binary.Read(reader, binary.BigEndian, &version)
+	if err != nil {
+		return nil, err
+	}
+	var length uint64
+	err = binary.Read(reader, binary.BigEndian, &length)
+	if err != nil {
+		return nil, err
+	}
+	mySet := &myIPSet{
+		rr: make([]myIPRange, length),
+	}
+	for i := uint64(0); i < length; i++ {
+		var (
+			fromLen  uint64
+			toLen    uint64
+			fromAddr netip.Addr
+			toAddr   netip.Addr
+		)
+		fromLen, err = rw.ReadUVariant(reader)
+		if err != nil {
+			return nil, err
+		}
+		fromBytes := make([]byte, fromLen)
+		_, err = io.ReadFull(reader, fromBytes)
+		if err != nil {
+			return nil, err
+		}
+		err = fromAddr.UnmarshalBinary(fromBytes)
+		if err != nil {
+			return nil, err
+		}
+		toLen, err = rw.ReadUVariant(reader)
+		if err != nil {
+			return nil, err
+		}
+		toBytes := make([]byte, toLen)
+		_, err = io.ReadFull(reader, toBytes)
+		if err != nil {
+			return nil, err
+		}
+		err = toAddr.UnmarshalBinary(toBytes)
+		if err != nil {
+			return nil, err
+		}
+		mySet.rr[i] = myIPRange{fromAddr, toAddr}
+	}
+	return (*netipx.IPSet)(unsafe.Pointer(mySet)), nil
+}
+
+func writeIPSet(writer io.Writer, set *netipx.IPSet) error {
+	err := binary.Write(writer, binary.BigEndian, uint8(1))
+	if err != nil {
+		return err
+	}
+	mySet := (*myIPSet)(unsafe.Pointer(set))
+	err = binary.Write(writer, binary.BigEndian, uint64(len(mySet.rr)))
+	if err != nil {
+		return err
+	}
+	for _, rr := range mySet.rr {
+		var (
+			fromBinary []byte
+			toBinary   []byte
+		)
+		fromBinary, err = rr.from.MarshalBinary()
+		if err != nil {
+			return err
+		}
+		err = rw.WriteUVariant(writer, uint64(len(fromBinary)))
+		if err != nil {
+			return err
+		}
+		_, err = writer.Write(fromBinary)
+		if err != nil {
+			return err
+		}
+		toBinary, err = rr.to.MarshalBinary()
+		if err != nil {
+			return err
+		}
+		err = rw.WriteUVariant(writer, uint64(len(toBinary)))
+		if err != nil {
+			return err
+		}
+		_, err = writer.Write(toBinary)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 8 - 0
constant/rule.go

@@ -9,3 +9,11 @@ const (
 	LogicalTypeAnd = "and"
 	LogicalTypeOr  = "or"
 )
+
+const (
+	RuleSetTypeLocal    = "local"
+	RuleSetTypeRemote   = "remote"
+	RuleSetVersion1     = 1
+	RuleSetFormatSource = "source"
+	RuleSetFormatBinary = "binary"
+)

+ 35 - 0
experimental/cachefile/cache.go

@@ -22,11 +22,13 @@ var (
 	bucketSelected = []byte("selected")
 	bucketExpand   = []byte("group_expand")
 	bucketMode     = []byte("clash_mode")
+	bucketRuleSet  = []byte("rule_set")
 
 	bucketNameList = []string{
 		string(bucketSelected),
 		string(bucketExpand),
 		string(bucketMode),
+		string(bucketRuleSet),
 	}
 
 	cacheIDDefault = []byte("default")
@@ -257,3 +259,36 @@ func (c *CacheFile) StoreGroupExpand(group string, isExpand bool) error {
 		}
 	})
 }
+
+func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedRuleSet {
+	var savedSet adapter.SavedRuleSet
+	err := c.DB.View(func(t *bbolt.Tx) error {
+		bucket := c.bucket(t, bucketRuleSet)
+		if bucket == nil {
+			return os.ErrNotExist
+		}
+		setBinary := bucket.Get([]byte(tag))
+		if len(setBinary) == 0 {
+			return os.ErrInvalid
+		}
+		return savedSet.UnmarshalBinary(setBinary)
+	})
+	if err != nil {
+		return nil
+	}
+	return &savedSet
+}
+
+func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedRuleSet) error {
+	return c.DB.Batch(func(t *bbolt.Tx) error {
+		bucket, err := c.createBucket(t, bucketRuleSet)
+		if err != nil {
+			return err
+		}
+		setBinary, err := set.MarshalBinary()
+		if err != nil {
+			return err
+		}
+		return bucket.Put([]byte(tag), setBinary)
+	})
+}

+ 1 - 1
experimental/cachefile/fakeip.go

@@ -25,7 +25,7 @@ func (c *CacheFile) FakeIPMetadata() *adapter.FakeIPMetadata {
 	err := c.DB.Batch(func(tx *bbolt.Tx) error {
 		bucket := tx.Bucket(bucketFakeIP)
 		if bucket == nil {
-			return nil
+			return os.ErrNotExist
 		}
 		metadataBinary := bucket.Get(keyMetadata)
 		if len(metadataBinary) == 0 {

+ 4 - 2
experimental/clashapi/proxies.go

@@ -100,8 +100,10 @@ func getProxies(server *Server, router adapter.Router) func(w http.ResponseWrite
 			allProxies = append(allProxies, detour.Tag())
 		}
 
-		defaultTag := router.DefaultOutbound(N.NetworkTCP).Tag()
-		if defaultTag == "" {
+		var defaultTag string
+		if defaultOutbound, err := router.DefaultOutbound(N.NetworkTCP); err == nil {
+			defaultTag = defaultOutbound.Tag()
+		} else {
 			defaultTag = allProxies[0]
 		}
 

+ 5 - 1
experimental/clashapi/server_resources.go

@@ -51,7 +51,11 @@ func (s *Server) downloadExternalUI() error {
 		}
 		detour = outbound
 	} else {
-		detour = s.router.DefaultOutbound(N.NetworkTCP)
+		outbound, err := s.router.DefaultOutbound(N.NetworkTCP)
+		if err != nil {
+			return err
+		}
+		detour = outbound
 	}
 	httpClient := &http.Client{
 		Transport: &http.Transport{

+ 6 - 2
experimental/clashapi/trafficontrol/tracker.go

@@ -94,7 +94,9 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, router ad
 	var chain []string
 	var next string
 	if rule == nil {
-		next = router.DefaultOutbound(N.NetworkTCP).Tag()
+		if defaultOutbound, err := router.DefaultOutbound(N.NetworkTCP); err == nil {
+			next = defaultOutbound.Tag()
+		}
 	} else {
 		next = rule.Outbound()
 	}
@@ -181,7 +183,9 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, route
 	var chain []string
 	var next string
 	if rule == nil {
-		next = router.DefaultOutbound(N.NetworkUDP).Tag()
+		if defaultOutbound, err := router.DefaultOutbound(N.NetworkUDP); err == nil {
+			next = defaultOutbound.Tag()
+		}
 	} else {
 		next = rule.Outbound()
 	}

+ 4 - 4
option/experimental.go

@@ -23,16 +23,16 @@ type ClashAPIOptions struct {
 	DefaultMode              string   `json:"default_mode,omitempty"`
 	ModeList                 []string `json:"-"`
 
+	// Deprecated: migrated to global cache file
+	CacheFile string `json:"cache_file,omitempty"`
+	// Deprecated: migrated to global cache file
+	CacheID string `json:"cache_id,omitempty"`
 	// Deprecated: migrated to global cache file
 	StoreMode bool `json:"store_mode,omitempty"`
 	// Deprecated: migrated to global cache file
 	StoreSelected bool `json:"store_selected,omitempty"`
 	// Deprecated: migrated to global cache file
 	StoreFakeIP bool `json:"store_fakeip,omitempty"`
-	// Deprecated: migrated to global cache file
-	CacheFile string `json:"cache_file,omitempty"`
-	// Deprecated: migrated to global cache file
-	CacheID string `json:"cache_id,omitempty"`
 }
 
 type V2RayAPIOptions struct {

+ 1 - 0
option/route.go

@@ -4,6 +4,7 @@ type RouteOptions struct {
 	GeoIP               *GeoIPOptions   `json:"geoip,omitempty"`
 	Geosite             *GeositeOptions `json:"geosite,omitempty"`
 	Rules               []Rule          `json:"rules,omitempty"`
+	RuleSet             []RuleSet       `json:"rule_set,omitempty"`
 	Final               string          `json:"final,omitempty"`
 	FindProcess         bool            `json:"find_process,omitempty"`
 	AutoDetectInterface bool            `json:"auto_detect_interface,omitempty"`

+ 30 - 28
option/rule.go

@@ -65,34 +65,36 @@ func (r Rule) IsValid() bool {
 }
 
 type DefaultRule struct {
-	Inbound         Listable[string] `json:"inbound,omitempty"`
-	IPVersion       int              `json:"ip_version,omitempty"`
-	Network         Listable[string] `json:"network,omitempty"`
-	AuthUser        Listable[string] `json:"auth_user,omitempty"`
-	Protocol        Listable[string] `json:"protocol,omitempty"`
-	Domain          Listable[string] `json:"domain,omitempty"`
-	DomainSuffix    Listable[string] `json:"domain_suffix,omitempty"`
-	DomainKeyword   Listable[string] `json:"domain_keyword,omitempty"`
-	DomainRegex     Listable[string] `json:"domain_regex,omitempty"`
-	Geosite         Listable[string] `json:"geosite,omitempty"`
-	SourceGeoIP     Listable[string] `json:"source_geoip,omitempty"`
-	GeoIP           Listable[string] `json:"geoip,omitempty"`
-	SourceIPCIDR    Listable[string] `json:"source_ip_cidr,omitempty"`
-	IPCIDR          Listable[string] `json:"ip_cidr,omitempty"`
-	SourcePort      Listable[uint16] `json:"source_port,omitempty"`
-	SourcePortRange Listable[string] `json:"source_port_range,omitempty"`
-	Port            Listable[uint16] `json:"port,omitempty"`
-	PortRange       Listable[string] `json:"port_range,omitempty"`
-	ProcessName     Listable[string] `json:"process_name,omitempty"`
-	ProcessPath     Listable[string] `json:"process_path,omitempty"`
-	PackageName     Listable[string] `json:"package_name,omitempty"`
-	User            Listable[string] `json:"user,omitempty"`
-	UserID          Listable[int32]  `json:"user_id,omitempty"`
-	ClashMode       string           `json:"clash_mode,omitempty"`
-	WIFISSID        Listable[string] `json:"wifi_ssid,omitempty"`
-	WIFIBSSID       Listable[string] `json:"wifi_bssid,omitempty"`
-	Invert          bool             `json:"invert,omitempty"`
-	Outbound        string           `json:"outbound,omitempty"`
+	Inbound                  Listable[string] `json:"inbound,omitempty"`
+	IPVersion                int              `json:"ip_version,omitempty"`
+	Network                  Listable[string] `json:"network,omitempty"`
+	AuthUser                 Listable[string] `json:"auth_user,omitempty"`
+	Protocol                 Listable[string] `json:"protocol,omitempty"`
+	Domain                   Listable[string] `json:"domain,omitempty"`
+	DomainSuffix             Listable[string] `json:"domain_suffix,omitempty"`
+	DomainKeyword            Listable[string] `json:"domain_keyword,omitempty"`
+	DomainRegex              Listable[string] `json:"domain_regex,omitempty"`
+	Geosite                  Listable[string] `json:"geosite,omitempty"`
+	SourceGeoIP              Listable[string] `json:"source_geoip,omitempty"`
+	GeoIP                    Listable[string] `json:"geoip,omitempty"`
+	SourceIPCIDR             Listable[string] `json:"source_ip_cidr,omitempty"`
+	IPCIDR                   Listable[string] `json:"ip_cidr,omitempty"`
+	SourcePort               Listable[uint16] `json:"source_port,omitempty"`
+	SourcePortRange          Listable[string] `json:"source_port_range,omitempty"`
+	Port                     Listable[uint16] `json:"port,omitempty"`
+	PortRange                Listable[string] `json:"port_range,omitempty"`
+	ProcessName              Listable[string] `json:"process_name,omitempty"`
+	ProcessPath              Listable[string] `json:"process_path,omitempty"`
+	PackageName              Listable[string] `json:"package_name,omitempty"`
+	User                     Listable[string] `json:"user,omitempty"`
+	UserID                   Listable[int32]  `json:"user_id,omitempty"`
+	ClashMode                string           `json:"clash_mode,omitempty"`
+	WIFISSID                 Listable[string] `json:"wifi_ssid,omitempty"`
+	WIFIBSSID                Listable[string] `json:"wifi_bssid,omitempty"`
+	RuleSet                  Listable[string] `json:"rule_set,omitempty"`
+	RuleSetIPCIDRMatchSource bool             `json:"rule_set_ipcidr_match_source,omitempty"`
+	Invert                   bool             `json:"invert,omitempty"`
+	Outbound                 string           `json:"outbound,omitempty"`
 }
 
 func (r DefaultRule) IsValid() bool {

+ 1 - 0
option/rule_dns.go

@@ -91,6 +91,7 @@ type DefaultDNSRule struct {
 	ClashMode       string                 `json:"clash_mode,omitempty"`
 	WIFISSID        Listable[string]       `json:"wifi_ssid,omitempty"`
 	WIFIBSSID       Listable[string]       `json:"wifi_bssid,omitempty"`
+	RuleSet         Listable[string]       `json:"rule_set,omitempty"`
 	Invert          bool                   `json:"invert,omitempty"`
 	Server          string                 `json:"server,omitempty"`
 	DisableCache    bool                   `json:"disable_cache,omitempty"`

+ 230 - 0
option/rule_set.go

@@ -0,0 +1,230 @@
+package option
+
+import (
+	"reflect"
+
+	"github.com/sagernet/sing-box/common/json"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/domain"
+	E "github.com/sagernet/sing/common/exceptions"
+	F "github.com/sagernet/sing/common/format"
+
+	"go4.org/netipx"
+)
+
+type _RuleSet struct {
+	Type          string        `json:"type"`
+	Tag           string        `json:"tag"`
+	Format        string        `json:"format"`
+	LocalOptions  LocalRuleSet  `json:"-"`
+	RemoteOptions RemoteRuleSet `json:"-"`
+}
+
+type RuleSet _RuleSet
+
+func (r RuleSet) MarshalJSON() ([]byte, error) {
+	var v any
+	switch r.Type {
+	case C.RuleSetTypeLocal:
+		v = r.LocalOptions
+	case C.RuleSetTypeRemote:
+		v = r.RemoteOptions
+	default:
+		return nil, E.New("unknown rule set type: " + r.Type)
+	}
+	return MarshallObjects((_RuleSet)(r), v)
+}
+
+func (r *RuleSet) UnmarshalJSON(bytes []byte) error {
+	err := json.Unmarshal(bytes, (*_RuleSet)(r))
+	if err != nil {
+		return err
+	}
+	if r.Tag == "" {
+		return E.New("missing tag")
+	}
+	switch r.Format {
+	case "":
+		return E.New("missing format")
+	case C.RuleSetFormatSource, C.RuleSetFormatBinary:
+	default:
+		return E.New("unknown rule set format: " + r.Format)
+	}
+	var v any
+	switch r.Type {
+	case C.RuleSetTypeLocal:
+		v = &r.LocalOptions
+	case C.RuleSetTypeRemote:
+		v = &r.RemoteOptions
+	case "":
+		return E.New("missing type")
+	default:
+		return E.New("unknown rule set type: " + r.Type)
+	}
+	err = UnmarshallExcluded(bytes, (*_RuleSet)(r), v)
+	if err != nil {
+		return E.Cause(err, "rule set")
+	}
+	return nil
+}
+
+type LocalRuleSet struct {
+	Path string `json:"path,omitempty"`
+}
+
+type RemoteRuleSet struct {
+	URL            string   `json:"url"`
+	DownloadDetour string   `json:"download_detour,omitempty"`
+	UpdateInterval Duration `json:"update_interval,omitempty"`
+}
+
+type _HeadlessRule struct {
+	Type           string              `json:"type,omitempty"`
+	DefaultOptions DefaultHeadlessRule `json:"-"`
+	LogicalOptions LogicalHeadlessRule `json:"-"`
+}
+
+type HeadlessRule _HeadlessRule
+
+func (r HeadlessRule) MarshalJSON() ([]byte, error) {
+	var v any
+	switch r.Type {
+	case C.RuleTypeDefault:
+		r.Type = ""
+		v = r.DefaultOptions
+	case C.RuleTypeLogical:
+		v = r.LogicalOptions
+	default:
+		return nil, E.New("unknown rule type: " + r.Type)
+	}
+	return MarshallObjects((_HeadlessRule)(r), v)
+}
+
+func (r *HeadlessRule) UnmarshalJSON(bytes []byte) error {
+	err := json.Unmarshal(bytes, (*_HeadlessRule)(r))
+	if err != nil {
+		return err
+	}
+	var v any
+	switch r.Type {
+	case "", C.RuleTypeDefault:
+		r.Type = C.RuleTypeDefault
+		v = &r.DefaultOptions
+	case C.RuleTypeLogical:
+		v = &r.LogicalOptions
+	default:
+		return E.New("unknown rule type: " + r.Type)
+	}
+	err = UnmarshallExcluded(bytes, (*_HeadlessRule)(r), v)
+	if err != nil {
+		return E.Cause(err, "route rule-set rule")
+	}
+	return nil
+}
+
+func (r HeadlessRule) IsValid() bool {
+	switch r.Type {
+	case C.RuleTypeDefault, "":
+		return r.DefaultOptions.IsValid()
+	case C.RuleTypeLogical:
+		return r.LogicalOptions.IsValid()
+	default:
+		panic("unknown rule type: " + r.Type)
+	}
+}
+
+type DefaultHeadlessRule struct {
+	QueryType       Listable[DNSQueryType] `json:"query_type,omitempty"`
+	Network         Listable[string]       `json:"network,omitempty"`
+	Domain          Listable[string]       `json:"domain,omitempty"`
+	DomainSuffix    Listable[string]       `json:"domain_suffix,omitempty"`
+	DomainKeyword   Listable[string]       `json:"domain_keyword,omitempty"`
+	DomainRegex     Listable[string]       `json:"domain_regex,omitempty"`
+	SourceIPCIDR    Listable[string]       `json:"source_ip_cidr,omitempty"`
+	IPCIDR          Listable[string]       `json:"ip_cidr,omitempty"`
+	SourcePort      Listable[uint16]       `json:"source_port,omitempty"`
+	SourcePortRange Listable[string]       `json:"source_port_range,omitempty"`
+	Port            Listable[uint16]       `json:"port,omitempty"`
+	PortRange       Listable[string]       `json:"port_range,omitempty"`
+	ProcessName     Listable[string]       `json:"process_name,omitempty"`
+	ProcessPath     Listable[string]       `json:"process_path,omitempty"`
+	PackageName     Listable[string]       `json:"package_name,omitempty"`
+	WIFISSID        Listable[string]       `json:"wifi_ssid,omitempty"`
+	WIFIBSSID       Listable[string]       `json:"wifi_bssid,omitempty"`
+	Invert          bool                   `json:"invert,omitempty"`
+
+	DomainMatcher *domain.Matcher `json:"-"`
+	SourceIPSet   *netipx.IPSet   `json:"-"`
+	IPSet         *netipx.IPSet   `json:"-"`
+}
+
+func (r DefaultHeadlessRule) IsValid() bool {
+	var defaultValue DefaultHeadlessRule
+	defaultValue.Invert = r.Invert
+	return !reflect.DeepEqual(r, defaultValue)
+}
+
+type LogicalHeadlessRule struct {
+	Mode   string         `json:"mode"`
+	Rules  []HeadlessRule `json:"rules,omitempty"`
+	Invert bool           `json:"invert,omitempty"`
+}
+
+func (r LogicalHeadlessRule) IsValid() bool {
+	return len(r.Rules) > 0 && common.All(r.Rules, HeadlessRule.IsValid)
+}
+
+type _PlainRuleSetCompat struct {
+	Version int          `json:"version"`
+	Options PlainRuleSet `json:"-"`
+}
+
+type PlainRuleSetCompat _PlainRuleSetCompat
+
+func (r PlainRuleSetCompat) MarshalJSON() ([]byte, error) {
+	var v any
+	switch r.Version {
+	case C.RuleSetVersion1:
+		v = r.Options
+	default:
+		return nil, E.New("unknown rule set version: ", r.Version)
+	}
+	return MarshallObjects((_PlainRuleSetCompat)(r), v)
+}
+
+func (r *PlainRuleSetCompat) UnmarshalJSON(bytes []byte) error {
+	err := json.Unmarshal(bytes, (*_PlainRuleSetCompat)(r))
+	if err != nil {
+		return err
+	}
+	var v any
+	switch r.Version {
+	case C.RuleSetVersion1:
+		v = &r.Options
+	case 0:
+		return E.New("missing rule set version")
+	default:
+		return E.New("unknown rule set version: ", r.Version)
+	}
+	err = UnmarshallExcluded(bytes, (*_PlainRuleSetCompat)(r), v)
+	if err != nil {
+		return E.Cause(err, "rule set")
+	}
+	return nil
+}
+
+func (r PlainRuleSetCompat) Upgrade() PlainRuleSet {
+	var result PlainRuleSet
+	switch r.Version {
+	case C.RuleSetVersion1:
+		result = r.Options
+	default:
+		panic("unknown rule set version: " + F.ToString(r.Version))
+	}
+	return result
+}
+
+type PlainRuleSet struct {
+	Rules []HeadlessRule `json:"rules,omitempty"`
+}

+ 226 - 0
option/time_unit.go

@@ -0,0 +1,226 @@
+package option
+
+import (
+	"errors"
+	"time"
+)
+
+// Copyright 2010 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+const durationDay = 24 * time.Hour
+
+var unitMap = map[string]uint64{
+	"ns": uint64(time.Nanosecond),
+	"us": uint64(time.Microsecond),
+	"µs": uint64(time.Microsecond), // U+00B5 = micro symbol
+	"μs": uint64(time.Microsecond), // U+03BC = Greek letter mu
+	"ms": uint64(time.Millisecond),
+	"s":  uint64(time.Second),
+	"m":  uint64(time.Minute),
+	"h":  uint64(time.Hour),
+	"d":  uint64(durationDay),
+}
+
+// ParseDuration parses a duration string.
+// A duration string is a possibly signed sequence of
+// decimal numbers, each with optional fraction and a unit suffix,
+// such as "300ms", "-1.5h" or "2h45m".
+// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
+func ParseDuration(s string) (Duration, error) {
+	// [-+]?([0-9]*(\.[0-9]*)?[a-z]+)+
+	orig := s
+	var d uint64
+	neg := false
+
+	// Consume [-+]?
+	if s != "" {
+		c := s[0]
+		if c == '-' || c == '+' {
+			neg = c == '-'
+			s = s[1:]
+		}
+	}
+	// Special case: if all that is left is "0", this is zero.
+	if s == "0" {
+		return 0, nil
+	}
+	if s == "" {
+		return 0, errors.New("time: invalid duration " + quote(orig))
+	}
+	for s != "" {
+		var (
+			v, f  uint64      // integers before, after decimal point
+			scale float64 = 1 // value = v + f/scale
+		)
+
+		var err error
+
+		// The next character must be [0-9.]
+		if !(s[0] == '.' || '0' <= s[0] && s[0] <= '9') {
+			return 0, errors.New("time: invalid duration " + quote(orig))
+		}
+		// Consume [0-9]*
+		pl := len(s)
+		v, s, err = leadingInt(s)
+		if err != nil {
+			return 0, errors.New("time: invalid duration " + quote(orig))
+		}
+		pre := pl != len(s) // whether we consumed anything before a period
+
+		// Consume (\.[0-9]*)?
+		post := false
+		if s != "" && s[0] == '.' {
+			s = s[1:]
+			pl := len(s)
+			f, scale, s = leadingFraction(s)
+			post = pl != len(s)
+		}
+		if !pre && !post {
+			// no digits (e.g. ".s" or "-.s")
+			return 0, errors.New("time: invalid duration " + quote(orig))
+		}
+
+		// Consume unit.
+		i := 0
+		for ; i < len(s); i++ {
+			c := s[i]
+			if c == '.' || '0' <= c && c <= '9' {
+				break
+			}
+		}
+		if i == 0 {
+			return 0, errors.New("time: missing unit in duration " + quote(orig))
+		}
+		u := s[:i]
+		s = s[i:]
+		unit, ok := unitMap[u]
+		if !ok {
+			return 0, errors.New("time: unknown unit " + quote(u) + " in duration " + quote(orig))
+		}
+		if v > 1<<63/unit {
+			// overflow
+			return 0, errors.New("time: invalid duration " + quote(orig))
+		}
+		v *= unit
+		if f > 0 {
+			// float64 is needed to be nanosecond accurate for fractions of hours.
+			// v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit)
+			v += uint64(float64(f) * (float64(unit) / scale))
+			if v > 1<<63 {
+				// overflow
+				return 0, errors.New("time: invalid duration " + quote(orig))
+			}
+		}
+		d += v
+		if d > 1<<63 {
+			return 0, errors.New("time: invalid duration " + quote(orig))
+		}
+	}
+	if neg {
+		return -Duration(d), nil
+	}
+	if d > 1<<63-1 {
+		return 0, errors.New("time: invalid duration " + quote(orig))
+	}
+	return Duration(d), nil
+}
+
+var errLeadingInt = errors.New("time: bad [0-9]*") // never printed
+
+// leadingInt consumes the leading [0-9]* from s.
+func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, err error) {
+	i := 0
+	for ; i < len(s); i++ {
+		c := s[i]
+		if c < '0' || c > '9' {
+			break
+		}
+		if x > 1<<63/10 {
+			// overflow
+			return 0, rem, errLeadingInt
+		}
+		x = x*10 + uint64(c) - '0'
+		if x > 1<<63 {
+			// overflow
+			return 0, rem, errLeadingInt
+		}
+	}
+	return x, s[i:], nil
+}
+
+// leadingFraction consumes the leading [0-9]* from s.
+// It is used only for fractions, so does not return an error on overflow,
+// it just stops accumulating precision.
+func leadingFraction(s string) (x uint64, scale float64, rem string) {
+	i := 0
+	scale = 1
+	overflow := false
+	for ; i < len(s); i++ {
+		c := s[i]
+		if c < '0' || c > '9' {
+			break
+		}
+		if overflow {
+			continue
+		}
+		if x > (1<<63-1)/10 {
+			// It's possible for overflow to give a positive number, so take care.
+			overflow = true
+			continue
+		}
+		y := x*10 + uint64(c) - '0'
+		if y > 1<<63 {
+			overflow = true
+			continue
+		}
+		x = y
+		scale *= 10
+	}
+	return x, scale, s[i:]
+}
+
+// These are borrowed from unicode/utf8 and strconv and replicate behavior in
+// that package, since we can't take a dependency on either.
+const (
+	lowerhex  = "0123456789abcdef"
+	runeSelf  = 0x80
+	runeError = '\uFFFD'
+)
+
+func quote(s string) string {
+	buf := make([]byte, 1, len(s)+2) // slice will be at least len(s) + quotes
+	buf[0] = '"'
+	for i, c := range s {
+		if c >= runeSelf || c < ' ' {
+			// This means you are asking us to parse a time.Duration or
+			// time.Location with unprintable or non-ASCII characters in it.
+			// We don't expect to hit this case very often. We could try to
+			// reproduce strconv.Quote's behavior with full fidelity but
+			// given how rarely we expect to hit these edge cases, speed and
+			// conciseness are better.
+			var width int
+			if c == runeError {
+				width = 1
+				if i+2 < len(s) && s[i:i+3] == string(runeError) {
+					width = 3
+				}
+			} else {
+				width = len(string(c))
+			}
+			for j := 0; j < width; j++ {
+				buf = append(buf, `\x`...)
+				buf = append(buf, lowerhex[s[i+j]>>4])
+				buf = append(buf, lowerhex[s[i+j]&0xF])
+			}
+		} else {
+			if c == '"' || c == '\\' {
+				buf = append(buf, '\\')
+			}
+			buf = append(buf, string(c)...)
+		}
+	}
+	buf = append(buf, '"')
+	return string(buf)
+}

+ 9 - 1
option/types.go

@@ -164,7 +164,7 @@ func (d *Duration) UnmarshalJSON(bytes []byte) error {
 	if err != nil {
 		return err
 	}
-	duration, err := time.ParseDuration(value)
+	duration, err := ParseDuration(value)
 	if err != nil {
 		return err
 	}
@@ -174,6 +174,14 @@ func (d *Duration) UnmarshalJSON(bytes []byte) error {
 
 type DNSQueryType uint16
 
+func (t DNSQueryType) String() string {
+	typeName, loaded := mDNS.TypeToString[uint16(t)]
+	if loaded {
+		return typeName
+	}
+	return F.ToString(uint16(t))
+}
+
 func (t DNSQueryType) MarshalJSON() ([]byte, error) {
 	typeName, loaded := mDNS.TypeToString[uint16(t)]
 	if loaded {

+ 121 - 49
route/router.go

@@ -39,6 +39,7 @@ import (
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	serviceNTP "github.com/sagernet/sing/common/ntp"
+	"github.com/sagernet/sing/common/task"
 	"github.com/sagernet/sing/common/uot"
 	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service/pause"
@@ -64,9 +65,12 @@ type Router struct {
 	geoIPReader                        *geoip.Reader
 	geositeReader                      *geosite.Reader
 	geositeCache                       map[string]adapter.Rule
+	needFindProcess                    bool
 	dnsClient                          *dns.Client
 	defaultDomainStrategy              dns.DomainStrategy
 	dnsRules                           []adapter.DNSRule
+	ruleSets                           []adapter.RuleSet
+	ruleSetMap                         map[string]adapter.RuleSet
 	defaultTransport                   dns.Transport
 	transports                         []dns.Transport
 	transportMap                       map[string]dns.Transport
@@ -107,11 +111,13 @@ func NewRouter(
 		outboundByTag:         make(map[string]adapter.Outbound),
 		rules:                 make([]adapter.Rule, 0, len(options.Rules)),
 		dnsRules:              make([]adapter.DNSRule, 0, len(dnsOptions.Rules)),
+		ruleSetMap:            make(map[string]adapter.RuleSet),
 		needGeoIPDatabase:     hasRule(options.Rules, isGeoIPRule) || hasDNSRule(dnsOptions.Rules, isGeoIPDNSRule),
 		needGeositeDatabase:   hasRule(options.Rules, isGeositeRule) || hasDNSRule(dnsOptions.Rules, isGeositeDNSRule),
 		geoIPOptions:          common.PtrValueOrDefault(options.GeoIP),
 		geositeOptions:        common.PtrValueOrDefault(options.Geosite),
 		geositeCache:          make(map[string]adapter.Rule),
+		needFindProcess:       hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess,
 		defaultDetour:         options.Final,
 		defaultDomainStrategy: dns.DomainStrategy(dnsOptions.Strategy),
 		autoDetectInterface:   options.AutoDetectInterface,
@@ -141,6 +147,17 @@ func NewRouter(
 		}
 		router.dnsRules = append(router.dnsRules, dnsRule)
 	}
+	for i, ruleSetOptions := range options.RuleSet {
+		if _, exists := router.ruleSetMap[ruleSetOptions.Tag]; exists {
+			return nil, E.New("duplicate rule-set tag: ", ruleSetOptions.Tag)
+		}
+		ruleSet, err := NewRuleSet(ctx, router, router.logger, ruleSetOptions)
+		if err != nil {
+			return nil, E.Cause(err, "parse rule-set[", i, "]")
+		}
+		router.ruleSets = append(router.ruleSets, ruleSet)
+		router.ruleSetMap[ruleSetOptions.Tag] = ruleSet
+	}
 
 	transports := make([]dns.Transport, len(dnsOptions.Servers))
 	dummyTransportMap := make(map[string]dns.Transport)
@@ -296,34 +313,6 @@ func NewRouter(
 		router.interfaceMonitor = interfaceMonitor
 	}
 
-	needFindProcess := hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess
-	needPackageManager := C.IsAndroid && platformInterface == nil && (needFindProcess || common.Any(inbounds, func(inbound option.Inbound) bool {
-		return len(inbound.TunOptions.IncludePackage) > 0 || len(inbound.TunOptions.ExcludePackage) > 0
-	}))
-	if needPackageManager {
-		packageManager, err := tun.NewPackageManager(router)
-		if err != nil {
-			return nil, E.Cause(err, "create package manager")
-		}
-		router.packageManager = packageManager
-	}
-	if needFindProcess {
-		if platformInterface != nil {
-			router.processSearcher = platformInterface
-		} else {
-			searcher, err := process.NewSearcher(process.Config{
-				Logger:         logFactory.NewLogger("router/process"),
-				PackageManager: router.packageManager,
-			})
-			if err != nil {
-				if err != os.ErrInvalid {
-					router.logger.Warn(E.Cause(err, "create process searcher"))
-				}
-			} else {
-				router.processSearcher = searcher
-			}
-		}
-	}
 	if ntpOptions.Enabled {
 		timeService, err := ntp.NewService(ctx, router, logFactory.NewLogger("ntp"), ntpOptions)
 		if err != nil {
@@ -332,11 +321,6 @@ func NewRouter(
 		service.ContextWith[serviceNTP.TimeService](ctx, timeService)
 		router.timeService = timeService
 	}
-	if platformInterface != nil && router.interfaceMonitor != nil && router.needWIFIState {
-		router.interfaceMonitor.RegisterCallback(func(_ int) {
-			router.updateWIFIState()
-		})
-	}
 	return router, nil
 }
 
@@ -451,12 +435,6 @@ func (r *Router) Start() error {
 			return err
 		}
 	}
-	if r.packageManager != nil {
-		err := r.packageManager.Start()
-		if err != nil {
-			return err
-		}
-	}
 	if r.needGeositeDatabase {
 		for _, rule := range r.rules {
 			err := rule.UpdateGeosite()
@@ -477,9 +455,89 @@ func (r *Router) Start() error {
 		r.geositeCache = nil
 		r.geositeReader = nil
 	}
-	if r.needWIFIState {
+	if r.fakeIPStore != nil {
+		err := r.fakeIPStore.Start()
+		if err != nil {
+			return err
+		}
+	}
+	if len(r.ruleSets) > 0 {
+		ruleSetStartContext := NewRuleSetStartContext()
+		var ruleSetStartGroup task.Group
+		for i, ruleSet := range r.ruleSets {
+			ruleSetInPlace := ruleSet
+			ruleSetStartGroup.Append0(func(ctx context.Context) error {
+				err := ruleSetInPlace.StartContext(ctx, ruleSetStartContext)
+				if err != nil {
+					return E.Cause(err, "initialize rule-set[", i, "]")
+				}
+				return nil
+			})
+		}
+		ruleSetStartGroup.Concurrency(5)
+		ruleSetStartGroup.FastFail()
+		err := ruleSetStartGroup.Run(r.ctx)
+		if err != nil {
+			return err
+		}
+		ruleSetStartContext.Close()
+	}
+
+	var (
+		needProcessFromRuleSet   bool
+		needWIFIStateFromRuleSet bool
+	)
+	for _, ruleSet := range r.ruleSets {
+		metadata := ruleSet.Metadata()
+		if metadata.ContainsProcessRule {
+			needProcessFromRuleSet = true
+		}
+		if metadata.ContainsWIFIRule {
+			needWIFIStateFromRuleSet = true
+		}
+	}
+	if needProcessFromRuleSet || r.needFindProcess {
+		needPackageManager := C.IsAndroid && r.platformInterface == nil
+
+		if needPackageManager {
+			packageManager, err := tun.NewPackageManager(r)
+			if err != nil {
+				return E.Cause(err, "create package manager")
+			}
+			if packageManager != nil {
+				err = packageManager.Start()
+				if err != nil {
+					return err
+				}
+			}
+			r.packageManager = packageManager
+		}
+
+		if r.platformInterface != nil {
+			r.processSearcher = r.platformInterface
+		} else {
+			searcher, err := process.NewSearcher(process.Config{
+				Logger:         r.logger,
+				PackageManager: r.packageManager,
+			})
+			if err != nil {
+				if err != os.ErrInvalid {
+					r.logger.Warn(E.Cause(err, "create process searcher"))
+				}
+			} else {
+				r.processSearcher = searcher
+			}
+		}
+	}
+	if needWIFIStateFromRuleSet || r.needWIFIState {
+		if r.platformInterface != nil && r.interfaceMonitor != nil {
+			r.interfaceMonitor.RegisterCallback(func(_ int) {
+				r.updateWIFIState()
+			})
+		}
 		r.updateWIFIState()
 	}
+
 	for i, rule := range r.rules {
 		err := rule.Start()
 		if err != nil {
@@ -492,12 +550,6 @@ func (r *Router) Start() error {
 			return E.Cause(err, "initialize DNS rule[", i, "]")
 		}
 	}
-	if r.fakeIPStore != nil {
-		err := r.fakeIPStore.Start()
-		if err != nil {
-			return err
-		}
-	}
 	for i, transport := range r.transports {
 		err := transport.Start()
 		if err != nil {
@@ -573,6 +625,14 @@ func (r *Router) Close() error {
 }
 
 func (r *Router) PostStart() error {
+	if len(r.ruleSets) > 0 {
+		for i, ruleSet := range r.ruleSets {
+			err := ruleSet.PostStart()
+			if err != nil {
+				return E.Cause(err, "post start rule-set[", i, "]")
+			}
+		}
+	}
 	r.started = true
 	return nil
 }
@@ -582,11 +642,17 @@ func (r *Router) Outbound(tag string) (adapter.Outbound, bool) {
 	return outbound, loaded
 }
 
-func (r *Router) DefaultOutbound(network string) adapter.Outbound {
+func (r *Router) DefaultOutbound(network string) (adapter.Outbound, error) {
 	if network == N.NetworkTCP {
-		return r.defaultOutboundForConnection
+		if r.defaultOutboundForConnection == nil {
+			return nil, E.New("missing default outbound for TCP connections")
+		}
+		return r.defaultOutboundForConnection, nil
 	} else {
-		return r.defaultOutboundForPacketConnection
+		if r.defaultOutboundForPacketConnection == nil {
+			return nil, E.New("missing default outbound for UDP connections")
+		}
+		return r.defaultOutboundForPacketConnection, nil
 	}
 }
 
@@ -594,6 +660,11 @@ func (r *Router) FakeIPStore() adapter.FakeIPStore {
 	return r.fakeIPStore
 }
 
+func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) {
+	ruleSet, loaded := r.ruleSetMap[tag]
+	return ruleSet, loaded
+}
+
 func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
 	if metadata.InboundDetour != "" {
 		if metadata.LastInbound == metadata.InboundDetour {
@@ -882,6 +953,7 @@ func (r *Router) match0(ctx context.Context, metadata *adapter.InboundContext, d
 		}
 	}
 	for i, rule := range r.rules {
+		metadata.ResetRuleCache()
 		if rule.Match(metadata) {
 			detour := rule.Outbound()
 			r.logger.DebugContext(ctx, "match[", i, "] ", rule.String(), " => ", detour)

+ 1 - 0
route/router_dns.go

@@ -43,6 +43,7 @@ func (r *Router) matchDNS(ctx context.Context) (context.Context, dns.Transport,
 		panic("no context")
 	}
 	for i, rule := range r.dnsRules {
+		metadata.ResetRuleCache()
 		if rule.Match(metadata) {
 			detour := rule.Outbound()
 			transport, loaded := r.transportMap[detour]

+ 0 - 70
route/router_geo_resources.go

@@ -13,8 +13,6 @@ import (
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-box/common/geosite"
 	C "github.com/sagernet/sing-box/constant"
-	"github.com/sagernet/sing-box/option"
-	"github.com/sagernet/sing/common"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	"github.com/sagernet/sing/common/rw"
@@ -243,71 +241,3 @@ func (r *Router) downloadGeositeDatabase(savePath string) error {
 	}
 	return err
 }
-
-func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool {
-	for _, rule := range rules {
-		switch rule.Type {
-		case C.RuleTypeDefault:
-			if cond(rule.DefaultOptions) {
-				return true
-			}
-		case C.RuleTypeLogical:
-			if hasRule(rule.LogicalOptions.Rules, cond) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool {
-	for _, rule := range rules {
-		switch rule.Type {
-		case C.RuleTypeDefault:
-			if cond(rule.DefaultOptions) {
-				return true
-			}
-		case C.RuleTypeLogical:
-			if hasDNSRule(rule.LogicalOptions.Rules, cond) {
-				return true
-			}
-		}
-	}
-	return false
-}
-
-func isGeoIPRule(rule option.DefaultRule) bool {
-	return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode) || len(rule.GeoIP) > 0 && common.Any(rule.GeoIP, notPrivateNode)
-}
-
-func isGeoIPDNSRule(rule option.DefaultDNSRule) bool {
-	return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode)
-}
-
-func isGeositeRule(rule option.DefaultRule) bool {
-	return len(rule.Geosite) > 0
-}
-
-func isGeositeDNSRule(rule option.DefaultDNSRule) bool {
-	return len(rule.Geosite) > 0
-}
-
-func isProcessRule(rule option.DefaultRule) bool {
-	return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0
-}
-
-func isProcessDNSRule(rule option.DefaultDNSRule) bool {
-	return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0
-}
-
-func notPrivateNode(code string) bool {
-	return code != "private"
-}
-
-func isWIFIRule(rule option.DefaultRule) bool {
-	return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
-}
-
-func isWIFIDNSRule(rule option.DefaultDNSRule) bool {
-	return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
-}

+ 99 - 0
route/router_rule.go

@@ -0,0 +1,99 @@
+package route
+
+import (
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+)
+
+func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool {
+	for _, rule := range rules {
+		switch rule.Type {
+		case C.RuleTypeDefault:
+			if cond(rule.DefaultOptions) {
+				return true
+			}
+		case C.RuleTypeLogical:
+			if hasRule(rule.LogicalOptions.Rules, cond) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool {
+	for _, rule := range rules {
+		switch rule.Type {
+		case C.RuleTypeDefault:
+			if cond(rule.DefaultOptions) {
+				return true
+			}
+		case C.RuleTypeLogical:
+			if hasDNSRule(rule.LogicalOptions.Rules, cond) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func hasHeadlessRule(rules []option.HeadlessRule, cond func(rule option.DefaultHeadlessRule) bool) bool {
+	for _, rule := range rules {
+		switch rule.Type {
+		case C.RuleTypeDefault:
+			if cond(rule.DefaultOptions) {
+				return true
+			}
+		case C.RuleTypeLogical:
+			if hasHeadlessRule(rule.LogicalOptions.Rules, cond) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
+func isGeoIPRule(rule option.DefaultRule) bool {
+	return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode) || len(rule.GeoIP) > 0 && common.Any(rule.GeoIP, notPrivateNode)
+}
+
+func isGeoIPDNSRule(rule option.DefaultDNSRule) bool {
+	return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode)
+}
+
+func isGeositeRule(rule option.DefaultRule) bool {
+	return len(rule.Geosite) > 0
+}
+
+func isGeositeDNSRule(rule option.DefaultDNSRule) bool {
+	return len(rule.Geosite) > 0
+}
+
+func isProcessRule(rule option.DefaultRule) bool {
+	return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0
+}
+
+func isProcessDNSRule(rule option.DefaultDNSRule) bool {
+	return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0
+}
+
+func isProcessHeadlessRule(rule option.DefaultHeadlessRule) bool {
+	return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0
+}
+
+func notPrivateNode(code string) bool {
+	return code != "private"
+}
+
+func isWIFIRule(rule option.DefaultRule) bool {
+	return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
+}
+
+func isWIFIDNSRule(rule option.DefaultDNSRule) bool {
+	return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
+}
+
+func isWIFIHeadlessRule(rule option.DefaultHeadlessRule) bool {
+	return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0
+}

+ 47 - 34
route/rule_abstract.go

@@ -1,6 +1,7 @@
 package route
 
 import (
+	"io"
 	"strings"
 
 	"github.com/sagernet/sing-box/adapter"
@@ -16,6 +17,7 @@ type abstractDefaultRule struct {
 	destinationAddressItems []RuleItem
 	destinationPortItems    []RuleItem
 	allItems                []RuleItem
+	ruleSetItem             RuleItem
 	invert                  bool
 	outbound                string
 }
@@ -61,64 +63,64 @@ func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool {
 		return true
 	}
 
-	for _, item := range r.items {
-		if !item.Match(metadata) {
-			return r.invert
-		}
-	}
-
-	if len(r.sourceAddressItems) > 0 {
-		var sourceAddressMatch bool
+	if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch {
 		for _, item := range r.sourceAddressItems {
 			if item.Match(metadata) {
-				sourceAddressMatch = true
+				metadata.SourceAddressMatch = true
 				break
 			}
 		}
-		if !sourceAddressMatch {
-			return r.invert
-		}
 	}
 
-	if len(r.sourcePortItems) > 0 {
-		var sourcePortMatch bool
+	if len(r.sourcePortItems) > 0 && !metadata.SourceAddressMatch {
 		for _, item := range r.sourcePortItems {
 			if item.Match(metadata) {
-				sourcePortMatch = true
+				metadata.SourcePortMatch = true
 				break
 			}
 		}
-		if !sourcePortMatch {
-			return r.invert
-		}
 	}
 
-	if len(r.destinationAddressItems) > 0 {
-		var destinationAddressMatch bool
+	if len(r.destinationAddressItems) > 0 && !metadata.SourceAddressMatch {
 		for _, item := range r.destinationAddressItems {
 			if item.Match(metadata) {
-				destinationAddressMatch = true
+				metadata.DestinationAddressMatch = true
 				break
 			}
 		}
-		if !destinationAddressMatch {
-			return r.invert
-		}
 	}
 
-	if len(r.destinationPortItems) > 0 {
-		var destinationPortMatch bool
+	if len(r.destinationPortItems) > 0 && !metadata.SourceAddressMatch {
 		for _, item := range r.destinationPortItems {
 			if item.Match(metadata) {
-				destinationPortMatch = true
+				metadata.DestinationPortMatch = true
 				break
 			}
 		}
-		if !destinationPortMatch {
+	}
+
+	for _, item := range r.items {
+		if !item.Match(metadata) {
 			return r.invert
 		}
 	}
 
+	if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch {
+		return r.invert
+	}
+
+	if len(r.sourcePortItems) > 0 && !metadata.SourcePortMatch {
+		return r.invert
+	}
+
+	if len(r.destinationAddressItems) > 0 && !metadata.DestinationAddressMatch {
+		return r.invert
+	}
+
+	if len(r.destinationPortItems) > 0 && !metadata.DestinationPortMatch {
+		return r.invert
+	}
+
 	return !r.invert
 }
 
@@ -135,7 +137,7 @@ func (r *abstractDefaultRule) String() string {
 }
 
 type abstractLogicalRule struct {
-	rules    []adapter.Rule
+	rules    []adapter.HeadlessRule
 	mode     string
 	invert   bool
 	outbound string
@@ -146,7 +148,10 @@ func (r *abstractLogicalRule) Type() string {
 }
 
 func (r *abstractLogicalRule) UpdateGeosite() error {
-	for _, rule := range r.rules {
+	for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (adapter.Rule, bool) {
+		rule, loaded := it.(adapter.Rule)
+		return rule, loaded
+	}) {
 		err := rule.UpdateGeosite()
 		if err != nil {
 			return err
@@ -156,7 +161,10 @@ func (r *abstractLogicalRule) UpdateGeosite() error {
 }
 
 func (r *abstractLogicalRule) Start() error {
-	for _, rule := range r.rules {
+	for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (common.Starter, bool) {
+		rule, loaded := it.(common.Starter)
+		return rule, loaded
+	}) {
 		err := rule.Start()
 		if err != nil {
 			return err
@@ -166,7 +174,10 @@ func (r *abstractLogicalRule) Start() error {
 }
 
 func (r *abstractLogicalRule) Close() error {
-	for _, rule := range r.rules {
+	for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (io.Closer, bool) {
+		rule, loaded := it.(io.Closer)
+		return rule, loaded
+	}) {
 		err := rule.Close()
 		if err != nil {
 			return err
@@ -177,11 +188,13 @@ func (r *abstractLogicalRule) Close() error {
 
 func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool {
 	if r.mode == C.LogicalTypeAnd {
-		return common.All(r.rules, func(it adapter.Rule) bool {
+		return common.All(r.rules, func(it adapter.HeadlessRule) bool {
+			metadata.ResetRuleCache()
 			return it.Match(metadata)
 		}) != r.invert
 	} else {
-		return common.Any(r.rules, func(it adapter.Rule) bool {
+		return common.Any(r.rules, func(it adapter.HeadlessRule) bool {
+			metadata.ResetRuleCache()
 			return it.Match(metadata)
 		}) != r.invert
 	}

+ 6 - 1
route/rule_default.go

@@ -194,6 +194,11 @@ func NewDefaultRule(router adapter.Router, logger log.ContextLogger, options opt
 		rule.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, item)
 	}
+	if len(options.RuleSet) > 0 {
+		item := NewRuleSetItem(router, options.RuleSet, options.RuleSetIPCIDRMatchSource)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
 	return rule, nil
 }
 
@@ -206,7 +211,7 @@ type LogicalRule struct {
 func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) {
 	r := &LogicalRule{
 		abstractLogicalRule{
-			rules:    make([]adapter.Rule, len(options.Rules)),
+			rules:    make([]adapter.HeadlessRule, len(options.Rules)),
 			invert:   options.Invert,
 			outbound: options.Outbound,
 		},

+ 6 - 1
route/rule_dns.go

@@ -190,6 +190,11 @@ func NewDefaultDNSRule(router adapter.Router, logger log.ContextLogger, options
 		rule.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, item)
 	}
+	if len(options.RuleSet) > 0 {
+		item := NewRuleSetItem(router, options.RuleSet, false)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
 	return rule, nil
 }
 
@@ -212,7 +217,7 @@ type LogicalDNSRule struct {
 func NewLogicalDNSRule(router adapter.Router, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) {
 	r := &LogicalDNSRule{
 		abstractLogicalRule: abstractLogicalRule{
-			rules:    make([]adapter.Rule, len(options.Rules)),
+			rules:    make([]adapter.HeadlessRule, len(options.Rules)),
 			invert:   options.Invert,
 			outbound: options.Server,
 		},

+ 173 - 0
route/rule_headless.go

@@ -0,0 +1,173 @@
+package route
+
+import (
+	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+func NewHeadlessRule(router adapter.Router, options option.HeadlessRule) (adapter.HeadlessRule, error) {
+	switch options.Type {
+	case "", C.RuleTypeDefault:
+		if !options.DefaultOptions.IsValid() {
+			return nil, E.New("missing conditions")
+		}
+		return NewDefaultHeadlessRule(router, options.DefaultOptions)
+	case C.RuleTypeLogical:
+		if !options.LogicalOptions.IsValid() {
+			return nil, E.New("missing conditions")
+		}
+		return NewLogicalHeadlessRule(router, options.LogicalOptions)
+	default:
+		return nil, E.New("unknown rule type: ", options.Type)
+	}
+}
+
+var _ adapter.HeadlessRule = (*DefaultHeadlessRule)(nil)
+
+type DefaultHeadlessRule struct {
+	abstractDefaultRule
+}
+
+func NewDefaultHeadlessRule(router adapter.Router, options option.DefaultHeadlessRule) (*DefaultHeadlessRule, error) {
+	rule := &DefaultHeadlessRule{
+		abstractDefaultRule{
+			invert: options.Invert,
+		},
+	}
+	if len(options.Network) > 0 {
+		item := NewNetworkItem(options.Network)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.Domain) > 0 || len(options.DomainSuffix) > 0 {
+		item := NewDomainItem(options.Domain, options.DomainSuffix)
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	} else if options.DomainMatcher != nil {
+		item := NewRawDomainItem(options.DomainMatcher)
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.DomainKeyword) > 0 {
+		item := NewDomainKeywordItem(options.DomainKeyword)
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.DomainRegex) > 0 {
+		item, err := NewDomainRegexItem(options.DomainRegex)
+		if err != nil {
+			return nil, E.Cause(err, "domain_regex")
+		}
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.SourceIPCIDR) > 0 {
+		item, err := NewIPCIDRItem(true, options.SourceIPCIDR)
+		if err != nil {
+			return nil, E.Cause(err, "source_ipcidr")
+		}
+		rule.sourceAddressItems = append(rule.sourceAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	} else if options.SourceIPSet != nil {
+		item := NewRawIPCIDRItem(true, options.SourceIPSet)
+		rule.sourceAddressItems = append(rule.sourceAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.IPCIDR) > 0 {
+		item, err := NewIPCIDRItem(false, options.IPCIDR)
+		if err != nil {
+			return nil, E.Cause(err, "ipcidr")
+		}
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	} else if options.IPSet != nil {
+		item := NewRawIPCIDRItem(false, options.IPSet)
+		rule.destinationAddressItems = append(rule.destinationAddressItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.SourcePort) > 0 {
+		item := NewPortItem(true, options.SourcePort)
+		rule.sourcePortItems = append(rule.sourcePortItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.SourcePortRange) > 0 {
+		item, err := NewPortRangeItem(true, options.SourcePortRange)
+		if err != nil {
+			return nil, E.Cause(err, "source_port_range")
+		}
+		rule.sourcePortItems = append(rule.sourcePortItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.Port) > 0 {
+		item := NewPortItem(false, options.Port)
+		rule.destinationPortItems = append(rule.destinationPortItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.PortRange) > 0 {
+		item, err := NewPortRangeItem(false, options.PortRange)
+		if err != nil {
+			return nil, E.Cause(err, "port_range")
+		}
+		rule.destinationPortItems = append(rule.destinationPortItems, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.ProcessName) > 0 {
+		item := NewProcessItem(options.ProcessName)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.ProcessPath) > 0 {
+		item := NewProcessPathItem(options.ProcessPath)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.PackageName) > 0 {
+		item := NewPackageNameItem(options.PackageName)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.WIFISSID) > 0 {
+		item := NewWIFISSIDItem(router, options.WIFISSID)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	if len(options.WIFIBSSID) > 0 {
+		item := NewWIFIBSSIDItem(router, options.WIFIBSSID)
+		rule.items = append(rule.items, item)
+		rule.allItems = append(rule.allItems, item)
+	}
+	return rule, nil
+}
+
+var _ adapter.HeadlessRule = (*LogicalHeadlessRule)(nil)
+
+type LogicalHeadlessRule struct {
+	abstractLogicalRule
+}
+
+func NewLogicalHeadlessRule(router adapter.Router, options option.LogicalHeadlessRule) (*LogicalHeadlessRule, error) {
+	r := &LogicalHeadlessRule{
+		abstractLogicalRule{
+			rules:  make([]adapter.HeadlessRule, len(options.Rules)),
+			invert: options.Invert,
+		},
+	}
+	switch options.Mode {
+	case C.LogicalTypeAnd:
+		r.mode = C.LogicalTypeAnd
+	case C.LogicalTypeOr:
+		r.mode = C.LogicalTypeOr
+	default:
+		return nil, E.New("unknown logical mode: ", options.Mode)
+	}
+	for i, subRule := range options.Rules {
+		rule, err := NewHeadlessRule(router, subRule)
+		if err != nil {
+			return nil, E.Cause(err, "sub rule[", i, "]")
+		}
+		r.rules[i] = rule
+	}
+	return r, nil
+}

+ 17 - 2
route/rule_item_cidr.go

@@ -31,7 +31,7 @@ func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) {
 			builder.Add(addr)
 			continue
 		}
-		return nil, E.Cause(err, "parse ip_cidr [", i, "]")
+		return nil, E.Cause(err, "parse [", i, "]")
 	}
 	var description string
 	if isSource {
@@ -57,8 +57,23 @@ func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) {
 	}, nil
 }
 
+func NewRawIPCIDRItem(isSource bool, ipSet *netipx.IPSet) *IPCIDRItem {
+	var description string
+	if isSource {
+		description = "source_ipcidr="
+	} else {
+		description = "ipcidr="
+	}
+	description += "<binary>"
+	return &IPCIDRItem{
+		ipSet:       ipSet,
+		isSource:    isSource,
+		description: description,
+	}
+}
+
 func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool {
-	if r.isSource {
+	if r.isSource || metadata.QueryType != 0 || metadata.IPCIDRMatchSource {
 		return r.ipSet.Contains(metadata.Source.Addr)
 	} else {
 		if metadata.Destination.IsIP() {

+ 7 - 0
route/rule_item_domain.go

@@ -43,6 +43,13 @@ func NewDomainItem(domains []string, domainSuffixes []string) *DomainItem {
 	}
 }
 
+func NewRawDomainItem(matcher *domain.Matcher) *DomainItem {
+	return &DomainItem{
+		matcher,
+		"domain/domain_suffix=<binary>",
+	}
+}
+
 func (r *DomainItem) Match(metadata *adapter.InboundContext) bool {
 	var domainHost string
 	if metadata.Domain != "" {

+ 55 - 0
route/rule_item_rule_set.go

@@ -0,0 +1,55 @@
+package route
+
+import (
+	"strings"
+
+	"github.com/sagernet/sing-box/adapter"
+	E "github.com/sagernet/sing/common/exceptions"
+	F "github.com/sagernet/sing/common/format"
+)
+
+var _ RuleItem = (*RuleSetItem)(nil)
+
+type RuleSetItem struct {
+	router            adapter.Router
+	tagList           []string
+	setList           []adapter.HeadlessRule
+	ipcidrMatchSource bool
+}
+
+func NewRuleSetItem(router adapter.Router, tagList []string, ipCIDRMatchSource bool) *RuleSetItem {
+	return &RuleSetItem{
+		router:            router,
+		tagList:           tagList,
+		ipcidrMatchSource: ipCIDRMatchSource,
+	}
+}
+
+func (r *RuleSetItem) Start() error {
+	for _, tag := range r.tagList {
+		ruleSet, loaded := r.router.RuleSet(tag)
+		if !loaded {
+			return E.New("rule-set not found: ", tag)
+		}
+		r.setList = append(r.setList, ruleSet)
+	}
+	return nil
+}
+
+func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool {
+	metadata.IPCIDRMatchSource = r.ipcidrMatchSource
+	for _, ruleSet := range r.setList {
+		if ruleSet.Match(metadata) {
+			return true
+		}
+	}
+	return false
+}
+
+func (r *RuleSetItem) String() string {
+	if len(r.tagList) == 1 {
+		return F.ToString("rule_set=", r.tagList[0])
+	} else {
+		return F.ToString("rule_set=[", strings.Join(r.tagList, " "), "]")
+	}
+}

+ 67 - 0
route/rule_set.go

@@ -0,0 +1,67 @@
+package route
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"sync"
+
+	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/logger"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+func NewRuleSet(ctx context.Context, router adapter.Router, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) {
+	switch options.Type {
+	case C.RuleSetTypeLocal:
+		return NewLocalRuleSet(router, options)
+	case C.RuleSetTypeRemote:
+		return NewRemoteRuleSet(ctx, router, logger, options), nil
+	default:
+		return nil, E.New("unknown rule set type: ", options.Type)
+	}
+}
+
+var _ adapter.RuleSetStartContext = (*RuleSetStartContext)(nil)
+
+type RuleSetStartContext struct {
+	access          sync.Mutex
+	httpClientCache map[string]*http.Client
+}
+
+func NewRuleSetStartContext() *RuleSetStartContext {
+	return &RuleSetStartContext{
+		httpClientCache: make(map[string]*http.Client),
+	}
+}
+
+func (c *RuleSetStartContext) HTTPClient(detour string, dialer N.Dialer) *http.Client {
+	c.access.Lock()
+	defer c.access.Unlock()
+	if httpClient, loaded := c.httpClientCache[detour]; loaded {
+		return httpClient
+	}
+	httpClient := &http.Client{
+		Transport: &http.Transport{
+			ForceAttemptHTTP2:   true,
+			TLSHandshakeTimeout: C.TCPTimeout,
+			DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+				return dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
+			},
+		},
+	}
+	c.httpClientCache[detour] = httpClient
+	return httpClient
+}
+
+func (c *RuleSetStartContext) Close() {
+	c.access.Lock()
+	defer c.access.Unlock()
+	for _, client := range c.httpClientCache {
+		client.CloseIdleConnections()
+	}
+}

+ 84 - 0
route/rule_set_local.go

@@ -0,0 +1,84 @@
+package route
+
+import (
+	"context"
+	"os"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/json"
+	"github.com/sagernet/sing-box/common/srs"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+)
+
+var _ adapter.RuleSet = (*LocalRuleSet)(nil)
+
+type LocalRuleSet struct {
+	rules    []adapter.HeadlessRule
+	metadata adapter.RuleSetMetadata
+}
+
+func NewLocalRuleSet(router adapter.Router, options option.RuleSet) (*LocalRuleSet, error) {
+	var plainRuleSet option.PlainRuleSet
+	switch options.Format {
+	case C.RuleSetFormatSource, "":
+		content, err := os.ReadFile(options.LocalOptions.Path)
+		if err != nil {
+			return nil, err
+		}
+		compat, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content)
+		if err != nil {
+			return nil, err
+		}
+		plainRuleSet = compat.Upgrade()
+	case C.RuleSetFormatBinary:
+		setFile, err := os.Open(options.LocalOptions.Path)
+		if err != nil {
+			return nil, err
+		}
+		plainRuleSet, err = srs.Read(setFile, false)
+		if err != nil {
+			return nil, err
+		}
+	default:
+		return nil, E.New("unknown rule set format: ", options.Format)
+	}
+	rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules))
+	var err error
+	for i, ruleOptions := range plainRuleSet.Rules {
+		rules[i], err = NewHeadlessRule(router, ruleOptions)
+		if err != nil {
+			return nil, E.Cause(err, "parse rule_set.rules.[", i, "]")
+		}
+	}
+	var metadata adapter.RuleSetMetadata
+	metadata.ContainsProcessRule = hasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule)
+	metadata.ContainsWIFIRule = hasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule)
+	return &LocalRuleSet{rules, metadata}, nil
+}
+
+func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool {
+	for _, rule := range s.rules {
+		if rule.Match(metadata) {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *LocalRuleSet) StartContext(ctx context.Context, startContext adapter.RuleSetStartContext) error {
+	return nil
+}
+
+func (s *LocalRuleSet) PostStart() error {
+	return nil
+}
+
+func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata {
+	return s.metadata
+}
+
+func (s *LocalRuleSet) Close() error {
+	return nil
+}

+ 262 - 0
route/rule_set_remote.go

@@ -0,0 +1,262 @@
+package route
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"net"
+	"net/http"
+	"runtime"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/json"
+	"github.com/sagernet/sing-box/common/srs"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/option"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/logger"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/service"
+	"github.com/sagernet/sing/service/pause"
+)
+
+var _ adapter.RuleSet = (*RemoteRuleSet)(nil)
+
+type RemoteRuleSet struct {
+	ctx            context.Context
+	cancel         context.CancelFunc
+	router         adapter.Router
+	logger         logger.ContextLogger
+	options        option.RuleSet
+	metadata       adapter.RuleSetMetadata
+	updateInterval time.Duration
+	dialer         N.Dialer
+	rules          []adapter.HeadlessRule
+	lastUpdated    time.Time
+	lastEtag       string
+	updateTicker   *time.Ticker
+	pauseManager   pause.Manager
+}
+
+func NewRemoteRuleSet(ctx context.Context, router adapter.Router, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet {
+	ctx, cancel := context.WithCancel(ctx)
+	var updateInterval time.Duration
+	if options.RemoteOptions.UpdateInterval > 0 {
+		updateInterval = time.Duration(options.RemoteOptions.UpdateInterval)
+	} else {
+		updateInterval = 24 * time.Hour
+	}
+	return &RemoteRuleSet{
+		ctx:            ctx,
+		cancel:         cancel,
+		router:         router,
+		logger:         logger,
+		options:        options,
+		updateInterval: updateInterval,
+		pauseManager:   pause.ManagerFromContext(ctx),
+	}
+}
+
+func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool {
+	for _, rule := range s.rules {
+		if rule.Match(metadata) {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext adapter.RuleSetStartContext) error {
+	var dialer N.Dialer
+	if s.options.RemoteOptions.DownloadDetour != "" {
+		outbound, loaded := s.router.Outbound(s.options.RemoteOptions.DownloadDetour)
+		if !loaded {
+			return E.New("download_detour not found: ", s.options.RemoteOptions.DownloadDetour)
+		}
+		dialer = outbound
+	} else {
+		outbound, err := s.router.DefaultOutbound(N.NetworkTCP)
+		if err != nil {
+			return err
+		}
+		dialer = outbound
+	}
+	s.dialer = dialer
+	cacheFile := service.FromContext[adapter.CacheFile](s.ctx)
+	if cacheFile != nil {
+		if savedSet := cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil {
+			err := s.loadBytes(savedSet.Content)
+			if err != nil {
+				return E.Cause(err, "restore cached rule-set")
+			}
+			s.lastUpdated = savedSet.LastUpdated
+			s.lastEtag = savedSet.LastEtag
+		}
+	}
+	if s.lastUpdated.IsZero() {
+		err := s.fetchOnce(ctx, startContext)
+		if err != nil {
+			return E.Cause(err, "initial rule-set: ", s.options.Tag)
+		}
+	}
+	s.updateTicker = time.NewTicker(s.updateInterval)
+	go s.loopUpdate()
+	return nil
+}
+
+func (s *RemoteRuleSet) PostStart() error {
+	if s.lastUpdated.IsZero() {
+		err := s.fetchOnce(s.ctx, nil)
+		if err != nil {
+			s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
+		}
+	}
+	return nil
+}
+
+func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata {
+	return s.metadata
+}
+
+func (s *RemoteRuleSet) loadBytes(content []byte) error {
+	var (
+		plainRuleSet option.PlainRuleSet
+		err          error
+	)
+	switch s.options.Format {
+	case C.RuleSetFormatSource, "":
+		var compat option.PlainRuleSetCompat
+		compat, err = json.UnmarshalExtended[option.PlainRuleSetCompat](content)
+		if err != nil {
+			return err
+		}
+		plainRuleSet = compat.Upgrade()
+	case C.RuleSetFormatBinary:
+		plainRuleSet, err = srs.Read(bytes.NewReader(content), false)
+		if err != nil {
+			return err
+		}
+	default:
+		return E.New("unknown rule set format: ", s.options.Format)
+	}
+	rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules))
+	for i, ruleOptions := range plainRuleSet.Rules {
+		rules[i], err = NewHeadlessRule(s.router, ruleOptions)
+		if err != nil {
+			return E.Cause(err, "parse rule_set.rules.[", i, "]")
+		}
+	}
+	s.metadata.ContainsProcessRule = hasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule)
+	s.metadata.ContainsWIFIRule = hasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule)
+	s.rules = rules
+	return nil
+}
+
+func (s *RemoteRuleSet) loopUpdate() {
+	if time.Since(s.lastUpdated) > s.updateInterval {
+		err := s.fetchOnce(s.ctx, nil)
+		if err != nil {
+			s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
+		}
+	}
+	for {
+		runtime.GC()
+		select {
+		case <-s.ctx.Done():
+			return
+		case <-s.updateTicker.C:
+			s.pauseManager.WaitActive()
+			err := s.fetchOnce(s.ctx, nil)
+			if err != nil {
+				s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
+			}
+		}
+	}
+}
+
+func (s *RemoteRuleSet) fetchOnce(ctx context.Context, startContext adapter.RuleSetStartContext) error {
+	s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL)
+	var httpClient *http.Client
+	if startContext != nil {
+		httpClient = startContext.HTTPClient(s.options.RemoteOptions.DownloadDetour, s.dialer)
+	} else {
+		httpClient = &http.Client{
+			Transport: &http.Transport{
+				ForceAttemptHTTP2:   true,
+				TLSHandshakeTimeout: C.TCPTimeout,
+				DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+					return s.dialer.DialContext(ctx, network, M.ParseSocksaddr(addr))
+				},
+			},
+		}
+	}
+	request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil)
+	if err != nil {
+		return err
+	}
+	if s.lastEtag != "" {
+		request.Header.Set("If-None-Match", s.lastEtag)
+	}
+	response, err := httpClient.Do(request.WithContext(ctx))
+	if err != nil {
+		return err
+	}
+	switch response.StatusCode {
+	case http.StatusOK:
+	case http.StatusNotModified:
+		s.lastUpdated = time.Now()
+		cacheFile := service.FromContext[adapter.CacheFile](s.ctx)
+		if cacheFile != nil {
+			savedRuleSet := cacheFile.LoadRuleSet(s.options.Tag)
+			if savedRuleSet != nil {
+				savedRuleSet.LastUpdated = s.lastUpdated
+				err = cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet)
+				if err != nil {
+					s.logger.Error("save rule-set updated time: ", err)
+					return nil
+				}
+			}
+		}
+		s.logger.Info("update rule-set ", s.options.Tag, ": not modified")
+		return nil
+	default:
+		return E.New("unexpected status: ", response.Status)
+	}
+	content, err := io.ReadAll(response.Body)
+	if err != nil {
+		response.Body.Close()
+		return err
+	}
+	err = s.loadBytes(content)
+	if err != nil {
+		response.Body.Close()
+		return err
+	}
+	response.Body.Close()
+	eTagHeader := response.Header.Get("Etag")
+	if eTagHeader != "" {
+		s.lastEtag = eTagHeader
+	}
+	s.lastUpdated = time.Now()
+	cacheFile := service.FromContext[adapter.CacheFile](s.ctx)
+	if cacheFile != nil {
+		err = cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedRuleSet{
+			LastUpdated: s.lastUpdated,
+			Content:     content,
+			LastEtag:    s.lastEtag,
+		})
+		if err != nil {
+			s.logger.Error("save rule-set cache: ", err)
+		}
+	}
+	s.logger.Info("updated rule-set ", s.options.Tag)
+	return nil
+}
+
+func (s *RemoteRuleSet) Close() error {
+	s.updateTicker.Stop()
+	s.cancel()
+	return nil
+}