Add multiple tags support to rule-sets

This commit is contained in:
世界
2026-07-16 18:57:55 +08:00
parent 653b81cfd9
commit c215021eca
10 changed files with 86 additions and 45 deletions
+2
View File
@@ -18,6 +18,8 @@ const (
RuleSetFormatBinary = "binary"
)
const RuleSetTagPlaceholder = "{tag}"
const (
RuleSetVersion1 = 1 + iota
RuleSetVersion2
+13 -3
View File
@@ -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"
+12 -3
View File
@@ -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 起"
+22 -7
View File
@@ -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
}
+10 -8
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
+4 -4
View File
@@ -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))
}
},
})
+17 -13
View File
@@ -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
}
+2 -3
View File
@@ -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,
}
}
@@ -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]{},