From c215021eca96ca99b47b48a089fb4171499e2e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 16 Jul 2026 18:57:55 +0800 Subject: [PATCH] Add multiple tags support to rule-sets --- constant/rule.go | 2 ++ docs/configuration/rule-set/index.md | 16 ++++++++-- docs/configuration/rule-set/index.zh.md | 15 ++++++++-- option/rule_set.go | 29 +++++++++++++----- route/router.go | 18 ++++++----- route/rule/rule_set.go | 6 ++-- route/rule/rule_set_local.go | 8 ++--- route/rule/rule_set_remote.go | 30 +++++++++++-------- route/rule/rule_set_semantics_test.go | 5 ++-- route/rule/rule_set_update_validation_test.go | 2 +- 10 files changed, 86 insertions(+), 45 deletions(-) diff --git a/constant/rule.go b/constant/rule.go index efd4a2d32..251ed4e9a 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -18,6 +18,8 @@ const ( RuleSetFormatBinary = "binary" ) +const RuleSetTagPlaceholder = "{tag}" + const ( RuleSetVersion1 = 1 + iota RuleSetVersion2 diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md index 108e2c109..851cab6f6 100644 --- a/docs/configuration/rule-set/index.md +++ b/docs/configuration/rule-set/index.md @@ -1,7 +1,8 @@ !!! quote "Changes in sing-box 1.14.0" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) !!! quote "Changes in sing-box 1.10.0" @@ -30,7 +31,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "path": "" } @@ -45,7 +46,7 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // or [] "format": "source", // or binary "url": "", "http_client": "", // or {} @@ -71,6 +72,15 @@ Type of rule-set, `local` or `remote`. Tag of rule-set. +!!! question "Since sing-box 1.14.0" + + `tag` also accepts a list of tags to define multiple rule-sets sharing other options at once. + + The `{tag}` placeholder in `path` or `url` is replaced by each tag, + and is required when multiple tags are set. + + Multiple tags conflict with `type: inline`. + ### Inline Fields !!! question "Since sing-box 1.10.0" diff --git a/docs/configuration/rule-set/index.zh.md b/docs/configuration/rule-set/index.zh.md index 342dc9abc..e1704087e 100644 --- a/docs/configuration/rule-set/index.zh.md +++ b/docs/configuration/rule-set/index.zh.md @@ -1,7 +1,8 @@ !!! quote "sing-box 1.14.0 中的更改" :material-plus: [http_client](#http_client) - :material-delete-clock: [download_detour](#download_detour) + :material-delete-clock: [download_detour](#download_detour) + :material-alert: [tag](#tag) !!! quote "sing-box 1.10.0 中的更改" @@ -30,7 +31,7 @@ ```json { "type": "local", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "path": "" } @@ -45,7 +46,7 @@ ```json { "type": "remote", - "tag": "", + "tag": "", // 或 [] "format": "source", // or binary "url": "", "http_client": "", // 或 {} @@ -71,6 +72,14 @@ 规则集的标签。 +!!! question "自 sing-box 1.14.0 起" + + `tag` 也接受一组标签,用于一次定义多个共享其他选项的规则集。 + + `path` 或 `url` 中的 `{tag}` 占位符将被替换为每个标签,设置多个标签时必填。 + + 多个标签与 `type: inline` 冲突。 + ### 内联字段 !!! question "自 sing-box 1.10.0 起" diff --git a/option/rule_set.go b/option/rule_set.go index 024d101f2..7389abfba 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -4,6 +4,7 @@ import ( "net/url" "path/filepath" "reflect" + "strings" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common" @@ -18,12 +19,12 @@ import ( ) type _RuleSet struct { - Type string `json:"type,omitempty"` - Tag string `json:"tag"` - Format string `json:"format,omitempty"` - InlineOptions PlainRuleSet `json:"-"` - LocalOptions LocalRuleSet `json:"-"` - RemoteOptions RemoteRuleSet `json:"-"` + Type string `json:"type,omitempty"` + Tag badoption.Listable[string] `json:"tag"` + Format string `json:"format,omitempty"` + InlineOptions PlainRuleSet `json:"-"` + LocalOptions LocalRuleSet `json:"-"` + RemoteOptions RemoteRuleSet `json:"-"` } type RuleSet _RuleSet @@ -61,7 +62,7 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { if err != nil { return err } - if r.Tag == "" { + if len(r.Tag) == 0 || common.Any(r.Tag, func(tag string) bool { return tag == "" }) { return E.New("missing tag") } var v any @@ -99,6 +100,20 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error { } else { r.Format = "" } + if len(r.Tag) > 1 { + switch r.Type { + case C.RuleSetTypeInline: + return E.New("inline rule-set does not support multiple tags") + case C.RuleSetTypeLocal: + if !strings.Contains(r.LocalOptions.Path, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in path") + } + case C.RuleSetTypeRemote: + if !strings.Contains(r.RemoteOptions.URL, C.RuleSetTagPlaceholder) { + return E.New("missing ", C.RuleSetTagPlaceholder, " placeholder in url") + } + } + } return nil } diff --git a/route/router.go b/route/router.go index bf4117cc5..aa621d3fc 100644 --- a/route/router.go +++ b/route/router.go @@ -84,15 +84,17 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro r.rules = append(r.rules, rule) } for i, options := range ruleSets { - if _, exists := r.ruleSetMap[options.Tag]; exists { - return E.New("duplicate rule-set tag: ", options.Tag) + for _, tag := range options.Tag { + if _, exists := r.ruleSetMap[tag]; exists { + return E.New("duplicate rule-set tag: ", tag) + } + ruleSet, err := R.NewRuleSet(r.ctx, r.logger, tag, options) + if err != nil { + return E.Cause(err, "parse rule-set[", i, "]") + } + r.ruleSets = append(r.ruleSets, ruleSet) + r.ruleSetMap[tag] = ruleSet } - ruleSet, err := R.NewRuleSet(r.ctx, r.logger, options) - if err != nil { - return E.Cause(err, "parse rule-set[", i, "]") - } - r.ruleSets = append(r.ruleSets, ruleSet) - r.ruleSetMap[options.Tag] = ruleSet } return nil } diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 6720e788b..4eb24654d 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -15,12 +15,12 @@ import ( "go4.org/netipx" ) -func NewRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) { +func NewRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (adapter.RuleSet, error) { switch options.Type { case C.RuleSetTypeInline, C.RuleSetTypeLocal, "": - return NewLocalRuleSet(ctx, logger, options) + return NewLocalRuleSet(ctx, logger, tag, options) case C.RuleSetTypeRemote: - return NewRemoteRuleSet(ctx, logger, options) + return NewRemoteRuleSet(ctx, logger, tag, options) default: return nil, E.New("unknown rule-set type: ", options.Type) } diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index 513b8bf90..9fcbe685b 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -38,11 +38,11 @@ type LocalRuleSet struct { refs atomic.Int32 } -func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) { +func NewLocalRuleSet(ctx context.Context, logger logger.Logger, tag string, options option.RuleSet) (*LocalRuleSet, error) { ruleSet := &LocalRuleSet{ ctx: ctx, logger: logger, - tag: options.Tag, + tag: tag, fileFormat: options.Format, } if options.Type == C.RuleSetTypeInline { @@ -54,7 +54,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R return nil, err } } else { - filePath := filemanager.BasePath(ctx, options.LocalOptions.Path) + filePath := filemanager.BasePath(ctx, strings.ReplaceAll(options.LocalOptions.Path, C.RuleSetTagPlaceholder, tag)) filePath, _ = filepath.Abs(filePath) err := ruleSet.reloadFile(filePath) if err != nil { @@ -65,7 +65,7 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R Callback: func(path string) { uErr := ruleSet.reloadFile(path) if uErr != nil { - logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag)) + logger.Error(E.Cause(uErr, "reload rule-set ", tag)) } }, }) diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index d0c6160a9..935658b32 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -34,6 +34,8 @@ type RemoteRuleSet struct { cancel context.CancelFunc logger logger.ContextLogger outbound adapter.OutboundManager + tag string + url string options option.RuleSet updateInterval time.Duration httpClient *http.Client @@ -48,7 +50,7 @@ type RemoteRuleSet struct { refs atomic.Int32 } -func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) (*RemoteRuleSet, error) { +func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, tag string, options option.RuleSet) (*RemoteRuleSet, error) { ctx, cancel := context.WithCancel(ctx) var updateInterval time.Duration if options.RemoteOptions.UpdateInterval > 0 { @@ -61,6 +63,8 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options cancel: cancel, outbound: service.FromContext[adapter.OutboundManager](ctx), logger: logger, + tag: tag, + url: strings.ReplaceAll(options.RemoteOptions.URL, C.RuleSetTagPlaceholder, tag), options: options, updateInterval: updateInterval, pauseManager: service.FromContext[pause.Manager](ctx), @@ -68,7 +72,7 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options } func (s *RemoteRuleSet) Name() string { - return s.options.Tag + return s.tag } func (s *RemoteRuleSet) String() string { @@ -84,7 +88,7 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. startContext.Register(transport) s.httpClient = &http.Client{Transport: transport} if s.cacheFile != nil { - if savedSet := s.cacheFile.LoadRuleSet(s.options.Tag); savedSet != nil { + if savedSet := s.cacheFile.LoadRuleSet(s.tag); savedSet != nil { err = s.loadBytes(savedSet.Content) if err != nil { s.logger.Warn(E.Cause(err, "restore cached rule-set, will refetch")) @@ -97,7 +101,7 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter. if s.lastUpdated.IsZero() { err = s.fetch(ctx, true) if err != nil { - return E.Cause(err, "initial rule-set: ", s.options.Tag) + return E.Cause(err, "initial rule-set: ", s.tag) } } return nil @@ -174,7 +178,7 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { } } metadata := buildRuleSetMetadata(plainRuleSet.Rules) - err = validateRuleSetMetadataUpdate(s.ctx, s.options.Tag, metadata) + err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata) if err != nil { return err } @@ -192,15 +196,15 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { func (s *RemoteRuleSet) updateOnce() { err := s.fetch(s.ctx, false) if err != nil { - s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + s.logger.Error("fetch rule-set ", s.tag, ": ", err) } else if s.refs.Load() == 0 { s.rules = nil } } func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { - s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) - request, err := http.NewRequest("GET", s.options.RemoteOptions.URL, nil) + s.logger.Debug("updating rule-set ", s.tag, " from URL: ", s.url) + request, err := http.NewRequest("GET", s.url, nil) if err != nil { return err } @@ -220,17 +224,17 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { case http.StatusNotModified: s.lastUpdated = time.Now() if s.cacheFile != nil { - savedRuleSet := s.cacheFile.LoadRuleSet(s.options.Tag) + savedRuleSet := s.cacheFile.LoadRuleSet(s.tag) if savedRuleSet != nil { savedRuleSet.LastUpdated = s.lastUpdated - err = s.cacheFile.SaveRuleSet(s.options.Tag, savedRuleSet) + err = s.cacheFile.SaveRuleSet(s.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") + s.logger.Info("update rule-set ", s.tag, ": not modified") return nil default: return E.New("unexpected status: ", response.Status) @@ -249,7 +253,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { } s.lastUpdated = time.Now() if s.cacheFile != nil { - err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{ + err = s.cacheFile.SaveRuleSet(s.tag, &adapter.SavedBinary{ LastUpdated: s.lastUpdated, Content: content, LastEtag: s.lastEtag, @@ -258,7 +262,7 @@ func (s *RemoteRuleSet) fetch(ctx context.Context, isStart bool) error { s.logger.Error("save rule-set cache: ", err) } } - s.logger.Info("updated rule-set ", s.options.Tag) + s.logger.Info("updated rule-set ", s.tag) return nil } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index d93dd420d..58f5d528e 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -10,7 +10,6 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/convertor/adguard" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" slogger "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" @@ -1104,8 +1103,8 @@ func newLocalRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *LocalRul func newRemoteRuleSetForTest(tag string, rules ...adapter.HeadlessRule) *RemoteRuleSet { return &RemoteRuleSet{ - options: option.RuleSet{Tag: tag}, - rules: rules, + tag: tag, + rules: rules, } } diff --git a/route/rule/rule_set_update_validation_test.go b/route/rule/rule_set_update_validation_test.go index 0583d7bb6..bd409095f 100644 --- a/route/rule/rule_set_update_validation_test.go +++ b/route/rule/rule_set_update_validation_test.go @@ -87,8 +87,8 @@ func TestRemoteRuleSetLoadBytesRejectsInvalidUpdateBeforeCommit(t *testing.T) { }) ruleSet := &RemoteRuleSet{ ctx: ctx, + tag: "dynamic-set", options: option.RuleSet{ - Tag: "dynamic-set", Format: C.RuleSetFormatSource, }, callbacks: list.List[adapter.RuleSetUpdateCallback]{},