diff --git a/.gitignore b/.gitignore index 6630f428..55bdab3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.idea/ /vendor/ /*.json +/*.srs /*.db /site/ /bin/ diff --git a/adapter/experimental.go b/adapter/experimental.go index 3ba9419e..2a6776cd 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -1,11 +1,16 @@ package adapter import ( + "bytes" "context" + "encoding/binary" + "io" "net" + "time" "github.com/sagernet/sing-box/common/urltest" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/rw" ) type ClashServer interface { @@ -23,6 +28,7 @@ type CacheFile interface { PreStarter StoreFakeIP() bool + FakeIPStorage LoadMode() string StoreMode(mode string) error @@ -30,7 +36,65 @@ type CacheFile interface { StoreSelected(group string, selected string) error LoadGroupExpand(group string) (isExpand bool, loaded bool) StoreGroupExpand(group string, expand bool) error - FakeIPStorage + LoadRuleSet(tag string) *SavedRuleSet + SaveRuleSet(tag string, set *SavedRuleSet) error +} + +type SavedRuleSet struct { + Content []byte + LastUpdated time.Time + LastEtag string +} + +func (s *SavedRuleSet) MarshalBinary() ([]byte, error) { + var buffer bytes.Buffer + err := binary.Write(&buffer, binary.BigEndian, uint8(1)) + if err != nil { + return nil, err + } + err = rw.WriteUVariant(&buffer, uint64(len(s.Content))) + if err != nil { + return nil, err + } + buffer.Write(s.Content) + err = binary.Write(&buffer, binary.BigEndian, s.LastUpdated.Unix()) + if err != nil { + return nil, err + } + err = rw.WriteVString(&buffer, s.LastEtag) + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (s *SavedRuleSet) UnmarshalBinary(data []byte) error { + reader := bytes.NewReader(data) + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return err + } + contentLen, err := rw.ReadUVariant(reader) + if err != nil { + return err + } + s.Content = make([]byte, contentLen) + _, err = io.ReadFull(reader, s.Content) + if err != nil { + return err + } + var lastUpdated int64 + err = binary.Read(reader, binary.BigEndian, &lastUpdated) + if err != nil { + return err + } + s.LastUpdated = time.Unix(lastUpdated, 0) + s.LastEtag, err = rw.ReadVString(reader) + if err != nil { + return err + } + return nil } type Tracker interface { diff --git a/adapter/inbound.go b/adapter/inbound.go index 2d24083c..f32b804d 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -46,11 +46,24 @@ type InboundContext struct { SourceGeoIPCode string GeoIPCode string ProcessInfo *process.Info + QueryType uint16 FakeIP bool - // dns cache + // rule cache - QueryType uint16 + IPCIDRMatchSource bool + SourceAddressMatch bool + SourcePortMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool +} + +func (c *InboundContext) ResetRuleCache() { + c.IPCIDRMatchSource = false + c.SourceAddressMatch = false + c.SourcePortMatch = false + c.DestinationAddressMatch = false + c.DestinationPortMatch = false } type inboundContextKey struct{} diff --git a/adapter/router.go b/adapter/router.go index 3d18eb38..b4bdd143 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -2,12 +2,14 @@ package adapter import ( "context" + "net/http" "net/netip" "github.com/sagernet/sing-box/common/geoip" "github.com/sagernet/sing-dns" "github.com/sagernet/sing-tun" "github.com/sagernet/sing/common/control" + N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" mdns "github.com/miekg/dns" @@ -19,7 +21,7 @@ type Router interface { Outbounds() []Outbound Outbound(tag string) (Outbound, bool) - DefaultOutbound(network string) Outbound + DefaultOutbound(network string) (Outbound, error) FakeIPStore() FakeIPStore @@ -28,6 +30,8 @@ type Router interface { GeoIPReader() *geoip.Reader LoadGeosite(code string) (Rule, error) + RuleSet(tag string) (RuleSet, bool) + Exchange(ctx context.Context, message *mdns.Msg) (*mdns.Msg, error) Lookup(ctx context.Context, domain string, strategy dns.DomainStrategy) ([]netip.Addr, error) LookupDefault(ctx context.Context, domain string) ([]netip.Addr, error) @@ -62,11 +66,15 @@ func RouterFromContext(ctx context.Context) Router { return service.FromContext[Router](ctx) } +type HeadlessRule interface { + Match(metadata *InboundContext) bool +} + type Rule interface { + HeadlessRule Service Type() string UpdateGeosite() error - Match(metadata *InboundContext) bool Outbound() string String() string } @@ -77,6 +85,24 @@ type DNSRule interface { RewriteTTL() *uint32 } +type RuleSet interface { + StartContext(ctx context.Context, startContext RuleSetStartContext) error + PostStart() error + Metadata() RuleSetMetadata + Close() error + HeadlessRule +} + +type RuleSetMetadata struct { + ContainsProcessRule bool + ContainsWIFIRule bool +} + +type RuleSetStartContext interface { + HTTPClient(detour string, dialer N.Dialer) *http.Client + Close() +} + type InterfaceUpdateListener interface { InterfaceUpdated() } diff --git a/box.go b/box.go index 5bc8bdcf..8c1e3d4b 100644 --- a/box.go +++ b/box.go @@ -308,6 +308,10 @@ func (s *Box) postStart() error { } } s.logger.Trace("post-starting router") + err := s.router.PostStart() + if err != nil { + return E.Cause(err, "post-start router") + } return s.router.PostStart() } diff --git a/cmd/sing-box/cmd_format.go b/cmd/sing-box/cmd_format.go index 10a5497c..c5e939e4 100644 --- a/cmd/sing-box/cmd_format.go +++ b/cmd/sing-box/cmd_format.go @@ -7,7 +7,6 @@ import ( "github.com/sagernet/sing-box/common/json" "github.com/sagernet/sing-box/log" - "github.com/sagernet/sing-box/option" E "github.com/sagernet/sing/common/exceptions" "github.com/spf13/cobra" @@ -69,41 +68,3 @@ func format() error { } return nil } - -func formatOne(configPath string) error { - configContent, err := os.ReadFile(configPath) - if err != nil { - return E.Cause(err, "read config") - } - var options option.Options - err = options.UnmarshalJSON(configContent) - if err != nil { - return E.Cause(err, "decode config") - } - buffer := new(bytes.Buffer) - encoder := json.NewEncoder(buffer) - encoder.SetIndent("", " ") - err = encoder.Encode(options) - if err != nil { - return E.Cause(err, "encode config") - } - if !commandFormatFlagWrite { - os.Stdout.WriteString(buffer.String() + "\n") - return nil - } - if bytes.Equal(configContent, buffer.Bytes()) { - return nil - } - output, err := os.Create(configPath) - if err != nil { - return E.Cause(err, "open output") - } - _, err = output.Write(buffer.Bytes()) - output.Close() - if err != nil { - return E.Cause(err, "write output") - } - outputPath, _ := filepath.Abs(configPath) - os.Stderr.WriteString(outputPath + "\n") - return nil -} diff --git a/cmd/sing-box/cmd_geoip.go b/cmd/sing-box/cmd_geoip.go new file mode 100644 index 00000000..dbbbff13 --- /dev/null +++ b/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 +} diff --git a/cmd/sing-box/cmd_geoip_export.go b/cmd/sing-box/cmd_geoip_export.go new file mode 100644 index 00000000..d170d10b --- /dev/null +++ b/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-.srs" + +var commandGeoipExport = &cobra.Command{ + Use: "export ", + 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) +} diff --git a/cmd/sing-box/cmd_geoip_list.go b/cmd/sing-box/cmd_geoip_list.go new file mode 100644 index 00000000..54dd426e --- /dev/null +++ b/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 +} diff --git a/cmd/sing-box/cmd_geoip_lookup.go b/cmd/sing-box/cmd_geoip_lookup.go new file mode 100644 index 00000000..d5157bb4 --- /dev/null +++ b/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
", + 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 +} diff --git a/cmd/sing-box/cmd_geosite.go b/cmd/sing-box/cmd_geosite.go new file mode 100644 index 00000000..95db9357 --- /dev/null +++ b/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 +} diff --git a/cmd/sing-box/cmd_geosite_export.go b/cmd/sing-box/cmd_geosite_export.go new file mode 100644 index 00000000..71f1018d --- /dev/null +++ b/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-.json" + +var commandGeositeExport = &cobra.Command{ + Use: "export ", + 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) +} diff --git a/cmd/sing-box/cmd_geosite_list.go b/cmd/sing-box/cmd_geosite_list.go new file mode 100644 index 00000000..cedb7adf --- /dev/null +++ b/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 ", + 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 +} diff --git a/cmd/sing-box/cmd_geosite_lookup.go b/cmd/sing-box/cmd_geosite_lookup.go new file mode 100644 index 00000000..f648ce62 --- /dev/null +++ b/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] ", + 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 +} diff --git a/cmd/sing-box/cmd_geosite_matcher.go b/cmd/sing-box/cmd_geosite_matcher.go new file mode 100644 index 00000000..791dba24 --- /dev/null +++ b/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 "" +} diff --git a/cmd/sing-box/cmd_merge.go b/cmd/sing-box/cmd_merge.go index 0aff7501..4fb07b86 100644 --- a/cmd/sing-box/cmd_merge.go +++ b/cmd/sing-box/cmd_merge.go @@ -18,7 +18,7 @@ import ( ) var commandMerge = &cobra.Command{ - Use: "merge [output]", + Use: "merge ", Short: "Merge configurations", Run: func(cmd *cobra.Command, args []string) { err := merge(args[0]) diff --git a/cmd/sing-box/cmd_rule_set.go b/cmd/sing-box/cmd_rule_set.go new file mode 100644 index 00000000..f4112a08 --- /dev/null +++ b/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) +} diff --git a/cmd/sing-box/cmd_rule_set_compile.go b/cmd/sing-box/cmd_rule_set_compile.go new file mode 100644 index 00000000..de318095 --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_compile.go @@ -0,0 +1,80 @@ +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 = ".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 + } + } + decoder := json.NewDecoder(json.NewCommentFilter(reader)) + decoder.DisallowUnknownFields() + var plainRuleSet option.PlainRuleSetCompat + err = decoder.Decode(&plainRuleSet) + 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 +} diff --git a/cmd/sing-box/cmd_rule_set_format.go b/cmd/sing-box/cmd_rule_set_format.go new file mode 100644 index 00000000..dc3ee6aa --- /dev/null +++ b/cmd/sing-box/cmd_rule_set_format.go @@ -0,0 +1,87 @@ +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 ", + 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 + } + decoder := json.NewDecoder(json.NewCommentFilter(bytes.NewReader(content))) + decoder.DisallowUnknownFields() + var plainRuleSet option.PlainRuleSetCompat + err = decoder.Decode(&plainRuleSet) + if err != nil { + return err + } + ruleSet := plainRuleSet.Upgrade() + buffer := new(bytes.Buffer) + encoder := json.NewEncoder(buffer) + encoder.SetIndent("", " ") + err = encoder.Encode(ruleSet) + 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 +} diff --git a/cmd/sing-box/cmd_tools.go b/cmd/sing-box/cmd_tools.go index 460a50cd..c45f5855 100644 --- a/cmd/sing-box/cmd_tools.go +++ b/cmd/sing-box/cmd_tools.go @@ -38,11 +38,7 @@ func createPreStartedClient() (*box.Box, error) { func createDialer(instance *box.Box, network string, outboundTag string) (N.Dialer, error) { if outboundTag == "" { - outbound := instance.Router().DefaultOutbound(N.NetworkName(network)) - if outbound == nil { - return nil, E.New("missing default outbound") - } - return outbound, nil + return instance.Router().DefaultOutbound(N.NetworkName(network)) } else { outbound, loaded := instance.Router().Outbound(outboundTag) if !loaded { diff --git a/cmd/sing-box/cmd_tools_connect.go b/cmd/sing-box/cmd_tools_connect.go index b904ebc9..3ea04bcd 100644 --- a/cmd/sing-box/cmd_tools_connect.go +++ b/cmd/sing-box/cmd_tools_connect.go @@ -18,7 +18,7 @@ import ( var commandConnectFlagNetwork string var commandConnect = &cobra.Command{ - Use: "connect [address]", + Use: "connect
", Short: "Connect to an address", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { diff --git a/common/dialer/router.go b/common/dialer/router.go index 1d558654..25316077 100644 --- a/common/dialer/router.go +++ b/common/dialer/router.go @@ -18,11 +18,19 @@ func NewRouter(router adapter.Router) N.Dialer { } func (d *RouterDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - return d.router.DefaultOutbound(network).DialContext(ctx, network, destination) + dialer, err := d.router.DefaultOutbound(network) + if err != nil { + return nil, err + } + return dialer.DialContext(ctx, network, destination) } func (d *RouterDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - return d.router.DefaultOutbound(N.NetworkUDP).ListenPacket(ctx, destination) + dialer, err := d.router.DefaultOutbound(N.NetworkUDP) + if err != nil { + return nil, err + } + return dialer.ListenPacket(ctx, destination) } func (d *RouterDialer) Upstream() any { diff --git a/common/srs/binary.go b/common/srs/binary.go new file mode 100644 index 00000000..8d10e346 --- /dev/null +++ b/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 +} diff --git a/common/srs/ip_set.go b/common/srs/ip_set.go new file mode 100644 index 00000000..b346da26 --- /dev/null +++ b/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 +} diff --git a/constant/rule.go b/constant/rule.go index 3c741995..5a8eaf12 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -9,3 +9,11 @@ const ( LogicalTypeAnd = "and" LogicalTypeOr = "or" ) + +const ( + RuleSetTypeLocal = "local" + RuleSetTypeRemote = "remote" + RuleSetVersion1 = 1 + RuleSetFormatSource = "source" + RuleSetFormatBinary = "binary" +) diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 262d1c1e..43b84562 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -22,11 +22,13 @@ var ( bucketSelected = []byte("selected") bucketExpand = []byte("group_expand") bucketMode = []byte("clash_mode") + bucketRuleSet = []byte("rule_set") bucketNameList = []string{ string(bucketSelected), string(bucketExpand), string(bucketMode), + string(bucketRuleSet), } cacheIDDefault = []byte("default") @@ -257,3 +259,36 @@ func (c *CacheFile) StoreGroupExpand(group string, isExpand bool) error { } }) } + +func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedRuleSet { + var savedSet adapter.SavedRuleSet + err := c.DB.View(func(t *bbolt.Tx) error { + bucket := c.bucket(t, bucketRuleSet) + if bucket == nil { + return os.ErrNotExist + } + setBinary := bucket.Get([]byte(tag)) + if len(setBinary) == 0 { + return os.ErrInvalid + } + return savedSet.UnmarshalBinary(setBinary) + }) + if err != nil { + return nil + } + return &savedSet +} + +func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedRuleSet) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := c.createBucket(t, bucketRuleSet) + if err != nil { + return err + } + setBinary, err := set.MarshalBinary() + if err != nil { + return err + } + return bucket.Put([]byte(tag), setBinary) + }) +} diff --git a/experimental/cachefile/fakeip.go b/experimental/cachefile/fakeip.go index 2242342a..e998ebb8 100644 --- a/experimental/cachefile/fakeip.go +++ b/experimental/cachefile/fakeip.go @@ -25,7 +25,7 @@ func (c *CacheFile) FakeIPMetadata() *adapter.FakeIPMetadata { err := c.DB.Batch(func(tx *bbolt.Tx) error { bucket := tx.Bucket(bucketFakeIP) if bucket == nil { - return nil + return os.ErrNotExist } metadataBinary := bucket.Get(keyMetadata) if len(metadataBinary) == 0 { diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go index 050efd8d..cf96931a 100644 --- a/experimental/clashapi/proxies.go +++ b/experimental/clashapi/proxies.go @@ -100,8 +100,10 @@ func getProxies(server *Server, router adapter.Router) func(w http.ResponseWrite allProxies = append(allProxies, detour.Tag()) } - defaultTag := router.DefaultOutbound(N.NetworkTCP).Tag() - if defaultTag == "" { + var defaultTag string + if defaultOutbound, err := router.DefaultOutbound(N.NetworkTCP); err == nil { + defaultTag = defaultOutbound.Tag() + } else { defaultTag = allProxies[0] } diff --git a/experimental/clashapi/server_resources.go b/experimental/clashapi/server_resources.go index ad36641e..d6d22b53 100644 --- a/experimental/clashapi/server_resources.go +++ b/experimental/clashapi/server_resources.go @@ -51,7 +51,11 @@ func (s *Server) downloadExternalUI() error { } detour = outbound } else { - detour = s.router.DefaultOutbound(N.NetworkTCP) + outbound, err := s.router.DefaultOutbound(N.NetworkTCP) + if err != nil { + return err + } + detour = outbound } httpClient := &http.Client{ Transport: &http.Transport{ diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index 3dc5a367..b7c20eb0 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/experimental/clashapi/trafficontrol/tracker.go @@ -94,7 +94,9 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, router ad var chain []string var next string if rule == nil { - next = router.DefaultOutbound(N.NetworkTCP).Tag() + if defaultOutbound, err := router.DefaultOutbound(N.NetworkTCP); err == nil { + next = defaultOutbound.Tag() + } } else { next = rule.Outbound() } @@ -181,7 +183,9 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, route var chain []string var next string if rule == nil { - next = router.DefaultOutbound(N.NetworkUDP).Tag() + if defaultOutbound, err := router.DefaultOutbound(N.NetworkUDP); err == nil { + next = defaultOutbound.Tag() + } } else { next = rule.Outbound() } diff --git a/option/experimental.go b/option/experimental.go index 72751a59..c685f51f 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -23,16 +23,16 @@ type ClashAPIOptions struct { DefaultMode string `json:"default_mode,omitempty"` ModeList []string `json:"-"` + // Deprecated: migrated to global cache file + CacheFile string `json:"cache_file,omitempty"` + // Deprecated: migrated to global cache file + CacheID string `json:"cache_id,omitempty"` // Deprecated: migrated to global cache file StoreMode bool `json:"store_mode,omitempty"` // Deprecated: migrated to global cache file StoreSelected bool `json:"store_selected,omitempty"` // Deprecated: migrated to global cache file StoreFakeIP bool `json:"store_fakeip,omitempty"` - // Deprecated: migrated to global cache file - CacheFile string `json:"cache_file,omitempty"` - // Deprecated: migrated to global cache file - CacheID string `json:"cache_id,omitempty"` } type V2RayAPIOptions struct { diff --git a/option/route.go b/option/route.go index 43150576..e313fcf2 100644 --- a/option/route.go +++ b/option/route.go @@ -4,6 +4,7 @@ type RouteOptions struct { GeoIP *GeoIPOptions `json:"geoip,omitempty"` Geosite *GeositeOptions `json:"geosite,omitempty"` Rules []Rule `json:"rules,omitempty"` + RuleSet []RuleSet `json:"rule_set,omitempty"` Final string `json:"final,omitempty"` FindProcess bool `json:"find_process,omitempty"` AutoDetectInterface bool `json:"auto_detect_interface,omitempty"` diff --git a/option/rule.go b/option/rule.go index 4f404202..bad605a0 100644 --- a/option/rule.go +++ b/option/rule.go @@ -65,34 +65,36 @@ func (r Rule) IsValid() bool { } type DefaultRule struct { - Inbound Listable[string] `json:"inbound,omitempty"` - IPVersion int `json:"ip_version,omitempty"` - Network Listable[string] `json:"network,omitempty"` - AuthUser Listable[string] `json:"auth_user,omitempty"` - Protocol Listable[string] `json:"protocol,omitempty"` - Domain Listable[string] `json:"domain,omitempty"` - DomainSuffix Listable[string] `json:"domain_suffix,omitempty"` - DomainKeyword Listable[string] `json:"domain_keyword,omitempty"` - DomainRegex Listable[string] `json:"domain_regex,omitempty"` - Geosite Listable[string] `json:"geosite,omitempty"` - SourceGeoIP Listable[string] `json:"source_geoip,omitempty"` - GeoIP Listable[string] `json:"geoip,omitempty"` - SourceIPCIDR Listable[string] `json:"source_ip_cidr,omitempty"` - IPCIDR Listable[string] `json:"ip_cidr,omitempty"` - SourcePort Listable[uint16] `json:"source_port,omitempty"` - SourcePortRange Listable[string] `json:"source_port_range,omitempty"` - Port Listable[uint16] `json:"port,omitempty"` - PortRange Listable[string] `json:"port_range,omitempty"` - ProcessName Listable[string] `json:"process_name,omitempty"` - ProcessPath Listable[string] `json:"process_path,omitempty"` - PackageName Listable[string] `json:"package_name,omitempty"` - User Listable[string] `json:"user,omitempty"` - UserID Listable[int32] `json:"user_id,omitempty"` - ClashMode string `json:"clash_mode,omitempty"` - WIFISSID Listable[string] `json:"wifi_ssid,omitempty"` - WIFIBSSID Listable[string] `json:"wifi_bssid,omitempty"` - Invert bool `json:"invert,omitempty"` - Outbound string `json:"outbound,omitempty"` + Inbound Listable[string] `json:"inbound,omitempty"` + IPVersion int `json:"ip_version,omitempty"` + Network Listable[string] `json:"network,omitempty"` + AuthUser Listable[string] `json:"auth_user,omitempty"` + Protocol Listable[string] `json:"protocol,omitempty"` + Domain Listable[string] `json:"domain,omitempty"` + DomainSuffix Listable[string] `json:"domain_suffix,omitempty"` + DomainKeyword Listable[string] `json:"domain_keyword,omitempty"` + DomainRegex Listable[string] `json:"domain_regex,omitempty"` + Geosite Listable[string] `json:"geosite,omitempty"` + SourceGeoIP Listable[string] `json:"source_geoip,omitempty"` + GeoIP Listable[string] `json:"geoip,omitempty"` + SourceIPCIDR Listable[string] `json:"source_ip_cidr,omitempty"` + IPCIDR Listable[string] `json:"ip_cidr,omitempty"` + SourcePort Listable[uint16] `json:"source_port,omitempty"` + SourcePortRange Listable[string] `json:"source_port_range,omitempty"` + Port Listable[uint16] `json:"port,omitempty"` + PortRange Listable[string] `json:"port_range,omitempty"` + ProcessName Listable[string] `json:"process_name,omitempty"` + ProcessPath Listable[string] `json:"process_path,omitempty"` + PackageName Listable[string] `json:"package_name,omitempty"` + User Listable[string] `json:"user,omitempty"` + UserID Listable[int32] `json:"user_id,omitempty"` + ClashMode string `json:"clash_mode,omitempty"` + WIFISSID Listable[string] `json:"wifi_ssid,omitempty"` + WIFIBSSID Listable[string] `json:"wifi_bssid,omitempty"` + RuleSet Listable[string] `json:"rule_set,omitempty"` + RuleSetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` + Invert bool `json:"invert,omitempty"` + Outbound string `json:"outbound,omitempty"` } func (r DefaultRule) IsValid() bool { diff --git a/option/rule_dns.go b/option/rule_dns.go index fca34322..c02d09f7 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -91,6 +91,7 @@ type DefaultDNSRule struct { ClashMode string `json:"clash_mode,omitempty"` WIFISSID Listable[string] `json:"wifi_ssid,omitempty"` WIFIBSSID Listable[string] `json:"wifi_bssid,omitempty"` + RuleSet Listable[string] `json:"rule_set,omitempty"` Invert bool `json:"invert,omitempty"` Server string `json:"server,omitempty"` DisableCache bool `json:"disable_cache,omitempty"` diff --git a/option/rule_set.go b/option/rule_set.go new file mode 100644 index 00000000..1b758142 --- /dev/null +++ b/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"` +} diff --git a/option/time_unit.go b/option/time_unit.go new file mode 100644 index 00000000..5e531dad --- /dev/null +++ b/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) +} diff --git a/option/types.go b/option/types.go index f2fed663..2f029098 100644 --- a/option/types.go +++ b/option/types.go @@ -164,7 +164,7 @@ func (d *Duration) UnmarshalJSON(bytes []byte) error { if err != nil { return err } - duration, err := time.ParseDuration(value) + duration, err := ParseDuration(value) if err != nil { return err } @@ -174,6 +174,14 @@ func (d *Duration) UnmarshalJSON(bytes []byte) error { type DNSQueryType uint16 +func (t DNSQueryType) String() string { + typeName, loaded := mDNS.TypeToString[uint16(t)] + if loaded { + return typeName + } + return F.ToString(uint16(t)) +} + func (t DNSQueryType) MarshalJSON() ([]byte, error) { typeName, loaded := mDNS.TypeToString[uint16(t)] if loaded { diff --git a/route/router.go b/route/router.go index dc98f5e2..aa5a3eb4 100644 --- a/route/router.go +++ b/route/router.go @@ -39,6 +39,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" serviceNTP "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/common/task" "github.com/sagernet/sing/common/uot" "github.com/sagernet/sing/service" "github.com/sagernet/sing/service/pause" @@ -64,9 +65,12 @@ type Router struct { geoIPReader *geoip.Reader geositeReader *geosite.Reader geositeCache map[string]adapter.Rule + needFindProcess bool dnsClient *dns.Client defaultDomainStrategy dns.DomainStrategy dnsRules []adapter.DNSRule + ruleSets []adapter.RuleSet + ruleSetMap map[string]adapter.RuleSet defaultTransport dns.Transport transports []dns.Transport transportMap map[string]dns.Transport @@ -107,11 +111,13 @@ func NewRouter( outboundByTag: make(map[string]adapter.Outbound), rules: make([]adapter.Rule, 0, len(options.Rules)), dnsRules: make([]adapter.DNSRule, 0, len(dnsOptions.Rules)), + ruleSetMap: make(map[string]adapter.RuleSet), needGeoIPDatabase: hasRule(options.Rules, isGeoIPRule) || hasDNSRule(dnsOptions.Rules, isGeoIPDNSRule), needGeositeDatabase: hasRule(options.Rules, isGeositeRule) || hasDNSRule(dnsOptions.Rules, isGeositeDNSRule), geoIPOptions: common.PtrValueOrDefault(options.GeoIP), geositeOptions: common.PtrValueOrDefault(options.Geosite), geositeCache: make(map[string]adapter.Rule), + needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, defaultDetour: options.Final, defaultDomainStrategy: dns.DomainStrategy(dnsOptions.Strategy), autoDetectInterface: options.AutoDetectInterface, @@ -141,6 +147,17 @@ func NewRouter( } router.dnsRules = append(router.dnsRules, dnsRule) } + for i, ruleSetOptions := range options.RuleSet { + if _, exists := router.ruleSetMap[ruleSetOptions.Tag]; exists { + return nil, E.New("duplicate rule-set tag: ", ruleSetOptions.Tag) + } + ruleSet, err := NewRuleSet(ctx, router, router.logger, ruleSetOptions) + if err != nil { + return nil, E.Cause(err, "parse rule-set[", i, "]") + } + router.ruleSets = append(router.ruleSets, ruleSet) + router.ruleSetMap[ruleSetOptions.Tag] = ruleSet + } transports := make([]dns.Transport, len(dnsOptions.Servers)) dummyTransportMap := make(map[string]dns.Transport) @@ -296,34 +313,6 @@ func NewRouter( router.interfaceMonitor = interfaceMonitor } - needFindProcess := hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess - needPackageManager := C.IsAndroid && platformInterface == nil && (needFindProcess || common.Any(inbounds, func(inbound option.Inbound) bool { - return len(inbound.TunOptions.IncludePackage) > 0 || len(inbound.TunOptions.ExcludePackage) > 0 - })) - if needPackageManager { - packageManager, err := tun.NewPackageManager(router) - if err != nil { - return nil, E.Cause(err, "create package manager") - } - router.packageManager = packageManager - } - if needFindProcess { - if platformInterface != nil { - router.processSearcher = platformInterface - } else { - searcher, err := process.NewSearcher(process.Config{ - Logger: logFactory.NewLogger("router/process"), - PackageManager: router.packageManager, - }) - if err != nil { - if err != os.ErrInvalid { - router.logger.Warn(E.Cause(err, "create process searcher")) - } - } else { - router.processSearcher = searcher - } - } - } if ntpOptions.Enabled { timeService, err := ntp.NewService(ctx, router, logFactory.NewLogger("ntp"), ntpOptions) if err != nil { @@ -332,11 +321,6 @@ func NewRouter( service.ContextWith[serviceNTP.TimeService](ctx, timeService) router.timeService = timeService } - if platformInterface != nil && router.interfaceMonitor != nil && router.needWIFIState { - router.interfaceMonitor.RegisterCallback(func(_ int) { - router.updateWIFIState() - }) - } return router, nil } @@ -451,12 +435,6 @@ func (r *Router) Start() error { return err } } - if r.packageManager != nil { - err := r.packageManager.Start() - if err != nil { - return err - } - } if r.needGeositeDatabase { for _, rule := range r.rules { err := rule.UpdateGeosite() @@ -477,9 +455,89 @@ func (r *Router) Start() error { r.geositeCache = nil r.geositeReader = nil } - if r.needWIFIState { + if r.fakeIPStore != nil { + err := r.fakeIPStore.Start() + if err != nil { + return err + } + } + if len(r.ruleSets) > 0 { + ruleSetStartContext := NewRuleSetStartContext() + var ruleSetStartGroup task.Group + for i, ruleSet := range r.ruleSets { + ruleSetInPlace := ruleSet + ruleSetStartGroup.Append0(func(ctx context.Context) error { + err := ruleSetInPlace.StartContext(ctx, ruleSetStartContext) + if err != nil { + return E.Cause(err, "initialize rule-set[", i, "]") + } + return nil + }) + } + ruleSetStartGroup.Concurrency(5) + ruleSetStartGroup.FastFail() + err := ruleSetStartGroup.Run(r.ctx) + if err != nil { + return err + } + ruleSetStartContext.Close() + } + + var ( + needProcessFromRuleSet bool + needWIFIStateFromRuleSet bool + ) + for _, ruleSet := range r.ruleSets { + metadata := ruleSet.Metadata() + if metadata.ContainsProcessRule { + needProcessFromRuleSet = true + } + if metadata.ContainsWIFIRule { + needWIFIStateFromRuleSet = true + } + } + if needProcessFromRuleSet || r.needFindProcess { + needPackageManager := C.IsAndroid && r.platformInterface == nil + + if needPackageManager { + packageManager, err := tun.NewPackageManager(r) + if err != nil { + return E.Cause(err, "create package manager") + } + if packageManager != nil { + err = packageManager.Start() + if err != nil { + return err + } + } + r.packageManager = packageManager + } + + if r.platformInterface != nil { + r.processSearcher = r.platformInterface + } else { + searcher, err := process.NewSearcher(process.Config{ + Logger: r.logger, + PackageManager: r.packageManager, + }) + if err != nil { + if err != os.ErrInvalid { + r.logger.Warn(E.Cause(err, "create process searcher")) + } + } else { + r.processSearcher = searcher + } + } + } + if needWIFIStateFromRuleSet || r.needWIFIState { + if r.platformInterface != nil && r.interfaceMonitor != nil { + r.interfaceMonitor.RegisterCallback(func(_ int) { + r.updateWIFIState() + }) + } r.updateWIFIState() } + for i, rule := range r.rules { err := rule.Start() if err != nil { @@ -492,12 +550,6 @@ func (r *Router) Start() error { return E.Cause(err, "initialize DNS rule[", i, "]") } } - if r.fakeIPStore != nil { - err := r.fakeIPStore.Start() - if err != nil { - return err - } - } for i, transport := range r.transports { err := transport.Start() if err != nil { @@ -573,6 +625,14 @@ func (r *Router) Close() error { } func (r *Router) PostStart() error { + if len(r.ruleSets) > 0 { + for i, ruleSet := range r.ruleSets { + err := ruleSet.PostStart() + if err != nil { + return E.Cause(err, "post start rule-set[", i, "]") + } + } + } r.started = true return nil } @@ -582,11 +642,17 @@ func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { return outbound, loaded } -func (r *Router) DefaultOutbound(network string) adapter.Outbound { +func (r *Router) DefaultOutbound(network string) (adapter.Outbound, error) { if network == N.NetworkTCP { - return r.defaultOutboundForConnection + if r.defaultOutboundForConnection == nil { + return nil, E.New("missing default outbound for TCP connections") + } + return r.defaultOutboundForConnection, nil } else { - return r.defaultOutboundForPacketConnection + if r.defaultOutboundForPacketConnection == nil { + return nil, E.New("missing default outbound for UDP connections") + } + return r.defaultOutboundForPacketConnection, nil } } @@ -594,6 +660,11 @@ func (r *Router) FakeIPStore() adapter.FakeIPStore { return r.fakeIPStore } +func (r *Router) RuleSet(tag string) (adapter.RuleSet, bool) { + ruleSet, loaded := r.ruleSetMap[tag] + return ruleSet, loaded +} + func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { if metadata.InboundDetour != "" { if metadata.LastInbound == metadata.InboundDetour { @@ -882,6 +953,7 @@ func (r *Router) match0(ctx context.Context, metadata *adapter.InboundContext, d } } for i, rule := range r.rules { + metadata.ResetRuleCache() if rule.Match(metadata) { detour := rule.Outbound() r.logger.DebugContext(ctx, "match[", i, "] ", rule.String(), " => ", detour) diff --git a/route/router_dns.go b/route/router_dns.go index 1532df94..b52fa9cc 100644 --- a/route/router_dns.go +++ b/route/router_dns.go @@ -43,6 +43,7 @@ func (r *Router) matchDNS(ctx context.Context) (context.Context, dns.Transport, panic("no context") } for i, rule := range r.dnsRules { + metadata.ResetRuleCache() if rule.Match(metadata) { detour := rule.Outbound() transport, loaded := r.transportMap[detour] diff --git a/route/router_geo_resources.go b/route/router_geo_resources.go index 638d00df..e0a572c9 100644 --- a/route/router_geo_resources.go +++ b/route/router_geo_resources.go @@ -13,8 +13,6 @@ import ( "github.com/sagernet/sing-box/common/geoip" "github.com/sagernet/sing-box/common/geosite" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/rw" @@ -243,71 +241,3 @@ func (r *Router) downloadGeositeDatabase(savePath string) error { } return err } - -func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { - for _, rule := range rules { - switch rule.Type { - case C.RuleTypeDefault: - if cond(rule.DefaultOptions) { - return true - } - case C.RuleTypeLogical: - if hasRule(rule.LogicalOptions.Rules, cond) { - return true - } - } - } - return false -} - -func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bool) bool { - for _, rule := range rules { - switch rule.Type { - case C.RuleTypeDefault: - if cond(rule.DefaultOptions) { - return true - } - case C.RuleTypeLogical: - if hasDNSRule(rule.LogicalOptions.Rules, cond) { - return true - } - } - } - return false -} - -func isGeoIPRule(rule option.DefaultRule) bool { - return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode) || len(rule.GeoIP) > 0 && common.Any(rule.GeoIP, notPrivateNode) -} - -func isGeoIPDNSRule(rule option.DefaultDNSRule) bool { - return len(rule.SourceGeoIP) > 0 && common.Any(rule.SourceGeoIP, notPrivateNode) -} - -func isGeositeRule(rule option.DefaultRule) bool { - return len(rule.Geosite) > 0 -} - -func isGeositeDNSRule(rule option.DefaultDNSRule) bool { - return len(rule.Geosite) > 0 -} - -func isProcessRule(rule option.DefaultRule) bool { - return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 -} - -func isProcessDNSRule(rule option.DefaultDNSRule) bool { - return len(rule.ProcessName) > 0 || len(rule.ProcessPath) > 0 || len(rule.PackageName) > 0 || len(rule.User) > 0 || len(rule.UserID) > 0 -} - -func notPrivateNode(code string) bool { - return code != "private" -} - -func isWIFIRule(rule option.DefaultRule) bool { - return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 -} - -func isWIFIDNSRule(rule option.DefaultDNSRule) bool { - return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 -} diff --git a/route/router_rule.go b/route/router_rule.go new file mode 100644 index 00000000..9850b5bc --- /dev/null +++ b/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 +} diff --git a/route/rule_abstract.go b/route/rule_abstract.go index 38d4d57d..7a4a759e 100644 --- a/route/rule_abstract.go +++ b/route/rule_abstract.go @@ -1,6 +1,7 @@ package route import ( + "io" "strings" "github.com/sagernet/sing-box/adapter" @@ -16,6 +17,7 @@ type abstractDefaultRule struct { destinationAddressItems []RuleItem destinationPortItems []RuleItem allItems []RuleItem + ruleSetItem RuleItem invert bool outbound string } @@ -61,62 +63,62 @@ func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool { return true } + if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch { + for _, item := range r.sourceAddressItems { + if item.Match(metadata) { + metadata.SourceAddressMatch = true + break + } + } + } + + if len(r.sourcePortItems) > 0 && !metadata.SourceAddressMatch { + for _, item := range r.sourcePortItems { + if item.Match(metadata) { + metadata.SourcePortMatch = true + break + } + } + } + + if len(r.destinationAddressItems) > 0 && !metadata.SourceAddressMatch { + for _, item := range r.destinationAddressItems { + if item.Match(metadata) { + metadata.DestinationAddressMatch = true + break + } + } + } + + if len(r.destinationPortItems) > 0 && !metadata.SourceAddressMatch { + for _, item := range r.destinationPortItems { + if item.Match(metadata) { + metadata.DestinationPortMatch = true + break + } + } + } + for _, item := range r.items { if !item.Match(metadata) { return r.invert } } - if len(r.sourceAddressItems) > 0 { - var sourceAddressMatch bool - for _, item := range r.sourceAddressItems { - if item.Match(metadata) { - sourceAddressMatch = true - break - } - } - if !sourceAddressMatch { - return r.invert - } + if len(r.sourceAddressItems) > 0 && !metadata.SourceAddressMatch { + return r.invert } - if len(r.sourcePortItems) > 0 { - var sourcePortMatch bool - for _, item := range r.sourcePortItems { - if item.Match(metadata) { - sourcePortMatch = true - break - } - } - if !sourcePortMatch { - return r.invert - } + if len(r.sourcePortItems) > 0 && !metadata.SourcePortMatch { + return r.invert } - if len(r.destinationAddressItems) > 0 { - var destinationAddressMatch bool - for _, item := range r.destinationAddressItems { - if item.Match(metadata) { - destinationAddressMatch = true - break - } - } - if !destinationAddressMatch { - return r.invert - } + if len(r.destinationAddressItems) > 0 && !metadata.DestinationAddressMatch { + return r.invert } - if len(r.destinationPortItems) > 0 { - var destinationPortMatch bool - for _, item := range r.destinationPortItems { - if item.Match(metadata) { - destinationPortMatch = true - break - } - } - if !destinationPortMatch { - return r.invert - } + if len(r.destinationPortItems) > 0 && !metadata.DestinationPortMatch { + return r.invert } return !r.invert @@ -135,7 +137,7 @@ func (r *abstractDefaultRule) String() string { } type abstractLogicalRule struct { - rules []adapter.Rule + rules []adapter.HeadlessRule mode string invert bool outbound string @@ -146,7 +148,10 @@ func (r *abstractLogicalRule) Type() string { } func (r *abstractLogicalRule) UpdateGeosite() error { - for _, rule := range r.rules { + for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (adapter.Rule, bool) { + rule, loaded := it.(adapter.Rule) + return rule, loaded + }) { err := rule.UpdateGeosite() if err != nil { return err @@ -156,7 +161,10 @@ func (r *abstractLogicalRule) UpdateGeosite() error { } func (r *abstractLogicalRule) Start() error { - for _, rule := range r.rules { + for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (common.Starter, bool) { + rule, loaded := it.(common.Starter) + return rule, loaded + }) { err := rule.Start() if err != nil { return err @@ -166,7 +174,10 @@ func (r *abstractLogicalRule) Start() error { } func (r *abstractLogicalRule) Close() error { - for _, rule := range r.rules { + for _, rule := range common.FilterIsInstance(r.rules, func(it adapter.HeadlessRule) (io.Closer, bool) { + rule, loaded := it.(io.Closer) + return rule, loaded + }) { err := rule.Close() if err != nil { return err @@ -177,11 +188,13 @@ func (r *abstractLogicalRule) Close() error { func (r *abstractLogicalRule) Match(metadata *adapter.InboundContext) bool { if r.mode == C.LogicalTypeAnd { - return common.All(r.rules, func(it adapter.Rule) bool { + return common.All(r.rules, func(it adapter.HeadlessRule) bool { + metadata.ResetRuleCache() return it.Match(metadata) }) != r.invert } else { - return common.Any(r.rules, func(it adapter.Rule) bool { + return common.Any(r.rules, func(it adapter.HeadlessRule) bool { + metadata.ResetRuleCache() return it.Match(metadata) }) != r.invert } diff --git a/route/rule_default.go b/route/rule_default.go index 8c8473ab..c0ef9eef 100644 --- a/route/rule_default.go +++ b/route/rule_default.go @@ -194,6 +194,11 @@ func NewDefaultRule(router adapter.Router, logger log.ContextLogger, options opt rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.RuleSet) > 0 { + item := NewRuleSetItem(router, options.RuleSet, options.RuleSetIPCIDRMatchSource) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } return rule, nil } @@ -206,7 +211,7 @@ type LogicalRule struct { func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options option.LogicalRule) (*LogicalRule, error) { r := &LogicalRule{ abstractLogicalRule{ - rules: make([]adapter.Rule, len(options.Rules)), + rules: make([]adapter.HeadlessRule, len(options.Rules)), invert: options.Invert, outbound: options.Outbound, }, diff --git a/route/rule_dns.go b/route/rule_dns.go index b4449325..f5f9fd35 100644 --- a/route/rule_dns.go +++ b/route/rule_dns.go @@ -190,6 +190,11 @@ func NewDefaultDNSRule(router adapter.Router, logger log.ContextLogger, options rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.RuleSet) > 0 { + item := NewRuleSetItem(router, options.RuleSet, false) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } return rule, nil } @@ -212,7 +217,7 @@ type LogicalDNSRule struct { func NewLogicalDNSRule(router adapter.Router, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) { r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ - rules: make([]adapter.Rule, len(options.Rules)), + rules: make([]adapter.HeadlessRule, len(options.Rules)), invert: options.Invert, outbound: options.Server, }, diff --git a/route/rule_headless.go b/route/rule_headless.go new file mode 100644 index 00000000..9df2ee30 --- /dev/null +++ b/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 +} diff --git a/route/rule_item_cidr.go b/route/rule_item_cidr.go index b72d1e10..e17d87de 100644 --- a/route/rule_item_cidr.go +++ b/route/rule_item_cidr.go @@ -31,7 +31,7 @@ func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) { builder.Add(addr) continue } - return nil, E.Cause(err, "parse ip_cidr [", i, "]") + return nil, E.Cause(err, "parse [", i, "]") } var description string if isSource { @@ -57,8 +57,23 @@ func NewIPCIDRItem(isSource bool, prefixStrings []string) (*IPCIDRItem, error) { }, nil } +func NewRawIPCIDRItem(isSource bool, ipSet *netipx.IPSet) *IPCIDRItem { + var description string + if isSource { + description = "source_ipcidr=" + } else { + description = "ipcidr=" + } + description += "" + return &IPCIDRItem{ + ipSet: ipSet, + isSource: isSource, + description: description, + } +} + func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { - if r.isSource { + if r.isSource || metadata.QueryType != 0 || metadata.IPCIDRMatchSource { return r.ipSet.Contains(metadata.Source.Addr) } else { if metadata.Destination.IsIP() { diff --git a/route/rule_item_domain.go b/route/rule_item_domain.go index 6602441d..d2a11181 100644 --- a/route/rule_item_domain.go +++ b/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=", + } +} + func (r *DomainItem) Match(metadata *adapter.InboundContext) bool { var domainHost string if metadata.Domain != "" { diff --git a/route/rule_item_rule_set.go b/route/rule_item_rule_set.go new file mode 100644 index 00000000..959b2f61 --- /dev/null +++ b/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, " "), "]") + } +} diff --git a/route/rule_set.go b/route/rule_set.go new file mode 100644 index 00000000..f644fb40 --- /dev/null +++ b/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() + } +} diff --git a/route/rule_set_local.go b/route/rule_set_local.go new file mode 100644 index 00000000..d89d78c3 --- /dev/null +++ b/route/rule_set_local.go @@ -0,0 +1,82 @@ +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) { + setFile, err := os.Open(options.LocalOptions.Path) + if err != nil { + return nil, err + } + var plainRuleSet option.PlainRuleSet + switch options.Format { + case C.RuleSetFormatSource, "": + var compat option.PlainRuleSetCompat + decoder := json.NewDecoder(json.NewCommentFilter(setFile)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&compat) + if err != nil { + return nil, err + } + plainRuleSet = compat.Upgrade() + case C.RuleSetFormatBinary: + 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)) + 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 +} diff --git a/route/rule_set_remote.go b/route/rule_set_remote.go new file mode 100644 index 00000000..957e712d --- /dev/null +++ b/route/rule_set_remote.go @@ -0,0 +1,264 @@ +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 + decoder := json.NewDecoder(json.NewCommentFilter(bytes.NewReader(content))) + decoder.DisallowUnknownFields() + err = decoder.Decode(&compat) + 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 +}