Browse Source

Add rule-set

世界 2 years 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/
 /.idea/
 /vendor/
 /vendor/
 /*.json
 /*.json
+/*.srs
 /*.db
 /*.db
 /site/
 /site/
 /bin/
 /bin/

+ 65 - 1
adapter/experimental.go

@@ -1,11 +1,16 @@
 package adapter
 package adapter
 
 
 import (
 import (
+	"bytes"
 	"context"
 	"context"
+	"encoding/binary"
+	"io"
 	"net"
 	"net"
+	"time"
 
 
 	"github.com/sagernet/sing-box/common/urltest"
 	"github.com/sagernet/sing-box/common/urltest"
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/rw"
 )
 )
 
 
 type ClashServer interface {
 type ClashServer interface {
@@ -23,6 +28,7 @@ type CacheFile interface {
 	PreStarter
 	PreStarter
 
 
 	StoreFakeIP() bool
 	StoreFakeIP() bool
+	FakeIPStorage
 
 
 	LoadMode() string
 	LoadMode() string
 	StoreMode(mode string) error
 	StoreMode(mode string) error
@@ -30,7 +36,65 @@ type CacheFile interface {
 	StoreSelected(group string, selected string) error
 	StoreSelected(group string, selected string) error
 	LoadGroupExpand(group string) (isExpand bool, loaded bool)
 	LoadGroupExpand(group string) (isExpand bool, loaded bool)
 	StoreGroupExpand(group string, expand bool) error
 	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 {
 type Tracker interface {

+ 15 - 2
adapter/inbound.go

@@ -46,11 +46,24 @@ type InboundContext struct {
 	SourceGeoIPCode      string
 	SourceGeoIPCode      string
 	GeoIPCode            string
 	GeoIPCode            string
 	ProcessInfo          *process.Info
 	ProcessInfo          *process.Info
+	QueryType            uint16
 	FakeIP               bool
 	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{}
 type inboundContextKey struct{}

+ 28 - 2
adapter/router.go

@@ -2,12 +2,14 @@ package adapter
 
 
 import (
 import (
 	"context"
 	"context"
+	"net/http"
 	"net/netip"
 	"net/netip"
 
 
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing/common/control"
 	"github.com/sagernet/sing/common/control"
+	N "github.com/sagernet/sing/common/network"
 	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service"
 
 
 	mdns "github.com/miekg/dns"
 	mdns "github.com/miekg/dns"
@@ -19,7 +21,7 @@ type Router interface {
 
 
 	Outbounds() []Outbound
 	Outbounds() []Outbound
 	Outbound(tag string) (Outbound, bool)
 	Outbound(tag string) (Outbound, bool)
-	DefaultOutbound(network string) Outbound
+	DefaultOutbound(network string) (Outbound, error)
 
 
 	FakeIPStore() FakeIPStore
 	FakeIPStore() FakeIPStore
 
 
@@ -28,6 +30,8 @@ type Router interface {
 	GeoIPReader() *geoip.Reader
 	GeoIPReader() *geoip.Reader
 	LoadGeosite(code string) (Rule, error)
 	LoadGeosite(code string) (Rule, error)
 
 
+	RuleSet(tag string) (RuleSet, bool)
+
 	Exchange(ctx context.Context, message *mdns.Msg) (*mdns.Msg, error)
 	Exchange(ctx context.Context, message *mdns.Msg) (*mdns.Msg, error)
 	Lookup(ctx context.Context, domain string, strategy dns.DomainStrategy) ([]netip.Addr, error)
 	Lookup(ctx context.Context, domain string, strategy dns.DomainStrategy) ([]netip.Addr, error)
 	LookupDefault(ctx context.Context, domain string) ([]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)
 	return service.FromContext[Router](ctx)
 }
 }
 
 
+type HeadlessRule interface {
+	Match(metadata *InboundContext) bool
+}
+
 type Rule interface {
 type Rule interface {
+	HeadlessRule
 	Service
 	Service
 	Type() string
 	Type() string
 	UpdateGeosite() error
 	UpdateGeosite() error
-	Match(metadata *InboundContext) bool
 	Outbound() string
 	Outbound() string
 	String() string
 	String() string
 }
 }
@@ -77,6 +85,24 @@ type DNSRule interface {
 	RewriteTTL() *uint32
 	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 {
 type InterfaceUpdateListener interface {
 	InterfaceUpdated()
 	InterfaceUpdated()
 }
 }

+ 4 - 0
box.go

@@ -308,6 +308,10 @@ func (s *Box) postStart() error {
 		}
 		}
 	}
 	}
 	s.logger.Trace("post-starting router")
 	s.logger.Trace("post-starting router")
+	err := s.router.PostStart()
+	if err != nil {
+		return E.Cause(err, "post-start router")
+	}
 	return s.router.PostStart()
 	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/common/json"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/log"
-	"github.com/sagernet/sing-box/option"
 	E "github.com/sagernet/sing/common/exceptions"
 	E "github.com/sagernet/sing/common/exceptions"
 
 
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
@@ -69,41 +68,3 @@ func format() error {
 	}
 	}
 	return nil
 	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{
 var commandMerge = &cobra.Command{
-	Use:   "merge [output]",
+	Use:   "merge <output>",
 	Short: "Merge configurations",
 	Short: "Merge configurations",
 	Run: func(cmd *cobra.Command, args []string) {
 	Run: func(cmd *cobra.Command, args []string) {
 		err := merge(args[0])
 		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) {
 func createDialer(instance *box.Box, network string, outboundTag string) (N.Dialer, error) {
 	if outboundTag == "" {
 	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 {
 	} else {
 		outbound, loaded := instance.Router().Outbound(outboundTag)
 		outbound, loaded := instance.Router().Outbound(outboundTag)
 		if !loaded {
 		if !loaded {

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

@@ -18,7 +18,7 @@ import (
 var commandConnectFlagNetwork string
 var commandConnectFlagNetwork string
 
 
 var commandConnect = &cobra.Command{
 var commandConnect = &cobra.Command{
-	Use:   "connect [address]",
+	Use:   "connect <address>",
 	Short: "Connect to an address",
 	Short: "Connect to an address",
 	Args:  cobra.ExactArgs(1),
 	Args:  cobra.ExactArgs(1),
 	Run: func(cmd *cobra.Command, args []string) {
 	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) {
 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) {
 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 {
 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"
 	LogicalTypeAnd = "and"
 	LogicalTypeOr  = "or"
 	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")
 	bucketSelected = []byte("selected")
 	bucketExpand   = []byte("group_expand")
 	bucketExpand   = []byte("group_expand")
 	bucketMode     = []byte("clash_mode")
 	bucketMode     = []byte("clash_mode")
+	bucketRuleSet  = []byte("rule_set")
 
 
 	bucketNameList = []string{
 	bucketNameList = []string{
 		string(bucketSelected),
 		string(bucketSelected),
 		string(bucketExpand),
 		string(bucketExpand),
 		string(bucketMode),
 		string(bucketMode),
+		string(bucketRuleSet),
 	}
 	}
 
 
 	cacheIDDefault = []byte("default")
 	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 {
 	err := c.DB.Batch(func(tx *bbolt.Tx) error {
 		bucket := tx.Bucket(bucketFakeIP)
 		bucket := tx.Bucket(bucketFakeIP)
 		if bucket == nil {
 		if bucket == nil {
-			return nil
+			return os.ErrNotExist
 		}
 		}
 		metadataBinary := bucket.Get(keyMetadata)
 		metadataBinary := bucket.Get(keyMetadata)
 		if len(metadataBinary) == 0 {
 		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())
 			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]
 			defaultTag = allProxies[0]
 		}
 		}
 
 

+ 5 - 1
experimental/clashapi/server_resources.go

@@ -51,7 +51,11 @@ func (s *Server) downloadExternalUI() error {
 		}
 		}
 		detour = outbound
 		detour = outbound
 	} else {
 	} else {
-		detour = s.router.DefaultOutbound(N.NetworkTCP)
+		outbound, err := s.router.DefaultOutbound(N.NetworkTCP)
+		if err != nil {
+			return err
+		}
+		detour = outbound
 	}
 	}
 	httpClient := &http.Client{
 	httpClient := &http.Client{
 		Transport: &http.Transport{
 		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 chain []string
 	var next string
 	var next string
 	if rule == nil {
 	if rule == nil {
-		next = router.DefaultOutbound(N.NetworkTCP).Tag()
+		if defaultOutbound, err := router.DefaultOutbound(N.NetworkTCP); err == nil {
+			next = defaultOutbound.Tag()
+		}
 	} else {
 	} else {
 		next = rule.Outbound()
 		next = rule.Outbound()
 	}
 	}
@@ -181,7 +183,9 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, route
 	var chain []string
 	var chain []string
 	var next string
 	var next string
 	if rule == nil {
 	if rule == nil {
-		next = router.DefaultOutbound(N.NetworkUDP).Tag()
+		if defaultOutbound, err := router.DefaultOutbound(N.NetworkUDP); err == nil {
+			next = defaultOutbound.Tag()
+		}
 	} else {
 	} else {
 		next = rule.Outbound()
 		next = rule.Outbound()
 	}
 	}

+ 4 - 4
option/experimental.go

@@ -23,16 +23,16 @@ type ClashAPIOptions struct {
 	DefaultMode              string   `json:"default_mode,omitempty"`
 	DefaultMode              string   `json:"default_mode,omitempty"`
 	ModeList                 []string `json:"-"`
 	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
 	// Deprecated: migrated to global cache file
 	StoreMode bool `json:"store_mode,omitempty"`
 	StoreMode bool `json:"store_mode,omitempty"`
 	// Deprecated: migrated to global cache file
 	// Deprecated: migrated to global cache file
 	StoreSelected bool `json:"store_selected,omitempty"`
 	StoreSelected bool `json:"store_selected,omitempty"`
 	// Deprecated: migrated to global cache file
 	// Deprecated: migrated to global cache file
 	StoreFakeIP bool `json:"store_fakeip,omitempty"`
 	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 {
 type V2RayAPIOptions struct {

+ 1 - 0
option/route.go

@@ -4,6 +4,7 @@ type RouteOptions struct {
 	GeoIP               *GeoIPOptions   `json:"geoip,omitempty"`
 	GeoIP               *GeoIPOptions   `json:"geoip,omitempty"`
 	Geosite             *GeositeOptions `json:"geosite,omitempty"`
 	Geosite             *GeositeOptions `json:"geosite,omitempty"`
 	Rules               []Rule          `json:"rules,omitempty"`
 	Rules               []Rule          `json:"rules,omitempty"`
+	RuleSet             []RuleSet       `json:"rule_set,omitempty"`
 	Final               string          `json:"final,omitempty"`
 	Final               string          `json:"final,omitempty"`
 	FindProcess         bool            `json:"find_process,omitempty"`
 	FindProcess         bool            `json:"find_process,omitempty"`
 	AutoDetectInterface bool            `json:"auto_detect_interface,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 {
 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 {
 func (r DefaultRule) IsValid() bool {

+ 1 - 0
option/rule_dns.go

@@ -91,6 +91,7 @@ type DefaultDNSRule struct {
 	ClashMode       string                 `json:"clash_mode,omitempty"`
 	ClashMode       string                 `json:"clash_mode,omitempty"`
 	WIFISSID        Listable[string]       `json:"wifi_ssid,omitempty"`
 	WIFISSID        Listable[string]       `json:"wifi_ssid,omitempty"`
 	WIFIBSSID       Listable[string]       `json:"wifi_bssid,omitempty"`
 	WIFIBSSID       Listable[string]       `json:"wifi_bssid,omitempty"`
+	RuleSet         Listable[string]       `json:"rule_set,omitempty"`
 	Invert          bool                   `json:"invert,omitempty"`
 	Invert          bool                   `json:"invert,omitempty"`
 	Server          string                 `json:"server,omitempty"`
 	Server          string                 `json:"server,omitempty"`
 	DisableCache    bool                   `json:"disable_cache,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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	duration, err := time.ParseDuration(value)
+	duration, err := ParseDuration(value)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -174,6 +174,14 @@ func (d *Duration) UnmarshalJSON(bytes []byte) error {
 
 
 type DNSQueryType uint16
 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) {
 func (t DNSQueryType) MarshalJSON() ([]byte, error) {
 	typeName, loaded := mDNS.TypeToString[uint16(t)]
 	typeName, loaded := mDNS.TypeToString[uint16(t)]
 	if loaded {
 	if loaded {

+ 121 - 49
route/router.go

@@ -39,6 +39,7 @@ import (
 	M "github.com/sagernet/sing/common/metadata"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
 	serviceNTP "github.com/sagernet/sing/common/ntp"
 	serviceNTP "github.com/sagernet/sing/common/ntp"
+	"github.com/sagernet/sing/common/task"
 	"github.com/sagernet/sing/common/uot"
 	"github.com/sagernet/sing/common/uot"
 	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service/pause"
 	"github.com/sagernet/sing/service/pause"
@@ -64,9 +65,12 @@ type Router struct {
 	geoIPReader                        *geoip.Reader
 	geoIPReader                        *geoip.Reader
 	geositeReader                      *geosite.Reader
 	geositeReader                      *geosite.Reader
 	geositeCache                       map[string]adapter.Rule
 	geositeCache                       map[string]adapter.Rule
+	needFindProcess                    bool
 	dnsClient                          *dns.Client
 	dnsClient                          *dns.Client
 	defaultDomainStrategy              dns.DomainStrategy
 	defaultDomainStrategy              dns.DomainStrategy
 	dnsRules                           []adapter.DNSRule
 	dnsRules                           []adapter.DNSRule
+	ruleSets                           []adapter.RuleSet
+	ruleSetMap                         map[string]adapter.RuleSet
 	defaultTransport                   dns.Transport
 	defaultTransport                   dns.Transport
 	transports                         []dns.Transport
 	transports                         []dns.Transport
 	transportMap                       map[string]dns.Transport
 	transportMap                       map[string]dns.Transport
@@ -107,11 +111,13 @@ func NewRouter(
 		outboundByTag:         make(map[string]adapter.Outbound),
 		outboundByTag:         make(map[string]adapter.Outbound),
 		rules:                 make([]adapter.Rule, 0, len(options.Rules)),
 		rules:                 make([]adapter.Rule, 0, len(options.Rules)),
 		dnsRules:              make([]adapter.DNSRule, 0, len(dnsOptions.Rules)),
 		dnsRules:              make([]adapter.DNSRule, 0, len(dnsOptions.Rules)),
+		ruleSetMap:            make(map[string]adapter.RuleSet),
 		needGeoIPDatabase:     hasRule(options.Rules, isGeoIPRule) || hasDNSRule(dnsOptions.Rules, isGeoIPDNSRule),
 		needGeoIPDatabase:     hasRule(options.Rules, isGeoIPRule) || hasDNSRule(dnsOptions.Rules, isGeoIPDNSRule),
 		needGeositeDatabase:   hasRule(options.Rules, isGeositeRule) || hasDNSRule(dnsOptions.Rules, isGeositeDNSRule),
 		needGeositeDatabase:   hasRule(options.Rules, isGeositeRule) || hasDNSRule(dnsOptions.Rules, isGeositeDNSRule),
 		geoIPOptions:          common.PtrValueOrDefault(options.GeoIP),
 		geoIPOptions:          common.PtrValueOrDefault(options.GeoIP),
 		geositeOptions:        common.PtrValueOrDefault(options.Geosite),
 		geositeOptions:        common.PtrValueOrDefault(options.Geosite),
 		geositeCache:          make(map[string]adapter.Rule),
 		geositeCache:          make(map[string]adapter.Rule),
+		needFindProcess:       hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess,
 		defaultDetour:         options.Final,
 		defaultDetour:         options.Final,
 		defaultDomainStrategy: dns.DomainStrategy(dnsOptions.Strategy),
 		defaultDomainStrategy: dns.DomainStrategy(dnsOptions.Strategy),
 		autoDetectInterface:   options.AutoDetectInterface,
 		autoDetectInterface:   options.AutoDetectInterface,
@@ -141,6 +147,17 @@ func NewRouter(
 		}
 		}
 		router.dnsRules = append(router.dnsRules, dnsRule)
 		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))
 	transports := make([]dns.Transport, len(dnsOptions.Servers))
 	dummyTransportMap := make(map[string]dns.Transport)
 	dummyTransportMap := make(map[string]dns.Transport)
@@ -296,34 +313,6 @@ func NewRouter(
 		router.interfaceMonitor = interfaceMonitor
 		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 {
 	if ntpOptions.Enabled {
 		timeService, err := ntp.NewService(ctx, router, logFactory.NewLogger("ntp"), ntpOptions)
 		timeService, err := ntp.NewService(ctx, router, logFactory.NewLogger("ntp"), ntpOptions)
 		if err != nil {
 		if err != nil {
@@ -332,11 +321,6 @@ func NewRouter(
 		service.ContextWith[serviceNTP.TimeService](ctx, timeService)
 		service.ContextWith[serviceNTP.TimeService](ctx, timeService)
 		router.timeService = timeService
 		router.timeService = timeService
 	}
 	}
-	if platformInterface != nil && router.interfaceMonitor != nil && router.needWIFIState {
-		router.interfaceMonitor.RegisterCallback(func(_ int) {
-			router.updateWIFIState()
-		})
-	}
 	return router, nil
 	return router, nil
 }
 }
 
 
@@ -451,12 +435,6 @@ func (r *Router) Start() error {
 			return err
 			return err
 		}
 		}
 	}
 	}
-	if r.packageManager != nil {
-		err := r.packageManager.Start()
-		if err != nil {
-			return err
-		}
-	}
 	if r.needGeositeDatabase {
 	if r.needGeositeDatabase {
 		for _, rule := range r.rules {
 		for _, rule := range r.rules {
 			err := rule.UpdateGeosite()
 			err := rule.UpdateGeosite()
@@ -477,9 +455,89 @@ func (r *Router) Start() error {
 		r.geositeCache = nil
 		r.geositeCache = nil
 		r.geositeReader = 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()
 		r.updateWIFIState()
 	}
 	}
+
 	for i, rule := range r.rules {
 	for i, rule := range r.rules {
 		err := rule.Start()
 		err := rule.Start()
 		if err != nil {
 		if err != nil {
@@ -492,12 +550,6 @@ func (r *Router) Start() error {
 			return E.Cause(err, "initialize DNS rule[", i, "]")
 			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 {
 	for i, transport := range r.transports {
 		err := transport.Start()
 		err := transport.Start()
 		if err != nil {
 		if err != nil {
@@ -573,6 +625,14 @@ func (r *Router) Close() error {
 }
 }
 
 
 func (r *Router) PostStart() 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
 	r.started = true
 	return nil
 	return nil
 }
 }
@@ -582,11 +642,17 @@ func (r *Router) Outbound(tag string) (adapter.Outbound, bool) {
 	return outbound, loaded
 	return outbound, loaded
 }
 }
 
 
-func (r *Router) DefaultOutbound(network string) adapter.Outbound {
+func (r *Router) DefaultOutbound(network string) (adapter.Outbound, error) {
 	if network == N.NetworkTCP {
 	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 {
 	} 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
 	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 {
 func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
 	if metadata.InboundDetour != "" {
 	if metadata.InboundDetour != "" {
 		if metadata.LastInbound == 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 {
 	for i, rule := range r.rules {
+		metadata.ResetRuleCache()
 		if rule.Match(metadata) {
 		if rule.Match(metadata) {
 			detour := rule.Outbound()
 			detour := rule.Outbound()
 			r.logger.DebugContext(ctx, "match[", i, "] ", rule.String(), " => ", detour)
 			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")
 		panic("no context")
 	}
 	}
 	for i, rule := range r.dnsRules {
 	for i, rule := range r.dnsRules {
+		metadata.ResetRuleCache()
 		if rule.Match(metadata) {
 		if rule.Match(metadata) {
 			detour := rule.Outbound()
 			detour := rule.Outbound()
 			transport, loaded := r.transportMap[detour]
 			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/geoip"
 	"github.com/sagernet/sing-box/common/geosite"
 	"github.com/sagernet/sing-box/common/geosite"
 	C "github.com/sagernet/sing-box/constant"
 	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"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	M "github.com/sagernet/sing/common/metadata"
 	"github.com/sagernet/sing/common/rw"
 	"github.com/sagernet/sing/common/rw"
@@ -243,71 +241,3 @@ func (r *Router) downloadGeositeDatabase(savePath string) error {
 	}
 	}
 	return err
 	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
 package route
 
 
 import (
 import (
+	"io"
 	"strings"
 	"strings"
 
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/adapter"
@@ -16,6 +17,7 @@ type abstractDefaultRule struct {
 	destinationAddressItems []RuleItem
 	destinationAddressItems []RuleItem
 	destinationPortItems    []RuleItem
 	destinationPortItems    []RuleItem
 	allItems                []RuleItem
 	allItems                []RuleItem
+	ruleSetItem             RuleItem
 	invert                  bool
 	invert                  bool
 	outbound                string
 	outbound                string
 }
 }
@@ -61,64 +63,64 @@ func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool {
 		return true
 		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 {
 		for _, item := range r.sourceAddressItems {
 			if item.Match(metadata) {
 			if item.Match(metadata) {
-				sourceAddressMatch = true
+				metadata.SourceAddressMatch = true
 				break
 				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 {
 		for _, item := range r.sourcePortItems {
 			if item.Match(metadata) {
 			if item.Match(metadata) {
-				sourcePortMatch = true
+				metadata.SourcePortMatch = true
 				break
 				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 {
 		for _, item := range r.destinationAddressItems {
 			if item.Match(metadata) {
 			if item.Match(metadata) {
-				destinationAddressMatch = true
+				metadata.DestinationAddressMatch = true
 				break
 				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 {
 		for _, item := range r.destinationPortItems {
 			if item.Match(metadata) {
 			if item.Match(metadata) {
-				destinationPortMatch = true
+				metadata.DestinationPortMatch = true
 				break
 				break
 			}
 			}
 		}
 		}
-		if !destinationPortMatch {
+	}
+
+	for _, item := range r.items {
+		if !item.Match(metadata) {
 			return r.invert
 			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
 	return !r.invert
 }
 }
 
 
@@ -135,7 +137,7 @@ func (r *abstractDefaultRule) String() string {
 }
 }
 
 
 type abstractLogicalRule struct {
 type abstractLogicalRule struct {
-	rules    []adapter.Rule
+	rules    []adapter.HeadlessRule
 	mode     string
 	mode     string
 	invert   bool
 	invert   bool
 	outbound string
 	outbound string
@@ -146,7 +148,10 @@ func (r *abstractLogicalRule) Type() string {
 }
 }
 
 
 func (r *abstractLogicalRule) UpdateGeosite() error {
 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()
 		err := rule.UpdateGeosite()
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -156,7 +161,10 @@ func (r *abstractLogicalRule) UpdateGeosite() error {
 }
 }
 
 
 func (r *abstractLogicalRule) Start() 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()
 		err := rule.Start()
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -166,7 +174,10 @@ func (r *abstractLogicalRule) Start() error {
 }
 }
 
 
 func (r *abstractLogicalRule) Close() 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()
 		err := rule.Close()
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -177,11 +188,13 @@ func (r *abstractLogicalRule) Close() error {
 
 
 func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool {
 func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool {
 	if r.mode == C.LogicalTypeAnd {
 	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)
 			return it.Match(metadata)
 		}) != r.invert
 		}) != r.invert
 	} else {
 	} 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)
 			return it.Match(metadata)
 		}) != r.invert
 		}) != 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.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, 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
 	return rule, nil
 }
 }
 
 
@@ -206,7 +211,7 @@ type LogicalRule struct {
 func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) {
 func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) {
 	r := &LogicalRule{
 	r := &LogicalRule{
 		abstractLogicalRule{
 		abstractLogicalRule{
-			rules:    make([]adapter.Rule, len(options.Rules)),
+			rules:    make([]adapter.HeadlessRule, len(options.Rules)),
 			invert:   options.Invert,
 			invert:   options.Invert,
 			outbound: options.Outbound,
 			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.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, 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
 	return rule, nil
 }
 }
 
 
@@ -212,7 +217,7 @@ type LogicalDNSRule struct {
 func NewLogicalDNSRule(router adapter.Router, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) {
 func NewLogicalDNSRule(router adapter.Router, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) {
 	r := &LogicalDNSRule{
 	r := &LogicalDNSRule{
 		abstractLogicalRule: abstractLogicalRule{
 		abstractLogicalRule: abstractLogicalRule{
-			rules:    make([]adapter.Rule, len(options.Rules)),
+			rules:    make([]adapter.HeadlessRule, len(options.Rules)),
 			invert:   options.Invert,
 			invert:   options.Invert,
 			outbound: options.Server,
 			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)
 			builder.Add(addr)
 			continue
 			continue
 		}
 		}
-		return nil, E.Cause(err, "parse ip_cidr [", i, "]")
+		return nil, E.Cause(err, "parse [", i, "]")
 	}
 	}
 	var description string
 	var description string
 	if isSource {
 	if isSource {
@@ -57,8 +57,23 @@ func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) {
 	}, nil
 	}, 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 {
 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)
 		return r.ipSet.Contains(metadata.Source.Addr)
 	} else {
 	} else {
 		if metadata.Destination.IsIP() {
 		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 {
 func (r *DomainItem) Match(metadata *adapter.InboundContext) bool {
 	var domainHost string
 	var domainHost string
 	if metadata.Domain != "" {
 	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
+}