Jelajahi Sumber

Add inline rule-set & Add reload for local rule-set

世界 1 tahun lalu
induk
melakukan
3a7acaa92a

+ 4 - 1
cmd/sing-box/cmd_rule_set_compile.go

@@ -56,7 +56,10 @@ func compileRuleSet(sourcePath string) error {
 	if err != nil {
 		return err
 	}
-	ruleSet := plainRuleSet.Upgrade()
+	ruleSet, err := plainRuleSet.Upgrade()
+	if err != nil {
+		return err
+	}
 	var outputPath string
 	if flagRuleSetCompileOutput == flagRuleSetCompileDefaultOutput {
 		if strings.HasSuffix(sourcePath, ".json") {

+ 4 - 1
cmd/sing-box/cmd_rule_set_match.go

@@ -63,7 +63,10 @@ func ruleSetMatch(sourcePath string, domain string) error {
 		if err != nil {
 			return err
 		}
-		plainRuleSet = compat.Upgrade()
+		plainRuleSet, err = compat.Upgrade()
+		if err != nil {
+			return err
+		}
 	case C.RuleSetFormatBinary:
 		plainRuleSet, err = srs.Read(bytes.NewReader(content), false)
 		if err != nil {

+ 4 - 1
cmd/sing-box/cmd_rule_set_upgrade.go

@@ -61,7 +61,10 @@ func upgradeRuleSet(sourcePath string) error {
 		log.Info("already up-to-date")
 		return nil
 	}
-	plainRuleSet := plainRuleSetCompat.Upgrade()
+	plainRuleSet, err := plainRuleSetCompat.Upgrade()
+	if err != nil {
+		return err
+	}
 	buffer := new(bytes.Buffer)
 	encoder := json.NewEncoder(buffer)
 	encoder.SetIndent("", "  ")

+ 58 - 127
common/tls/ech_server.go

@@ -11,12 +11,11 @@ import (
 	"strings"
 
 	cftls "github.com/sagernet/cloudflare-tls"
+	"github.com/sagernet/fswatch"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/ntp"
-
-	"github.com/fsnotify/fsnotify"
 )
 
 type echServerConfig struct {
@@ -26,9 +25,8 @@ type echServerConfig struct {
 	key             []byte
 	certificatePath string
 	keyPath         string
-	watcher         *fsnotify.Watcher
 	echKeyPath      string
-	echWatcher      *fsnotify.Watcher
+	watcher         *fswatch.Watcher
 }
 
 func (c *echServerConfig) ServerName() string {
@@ -66,146 +64,84 @@ func (c *echServerConfig) Clone() Config {
 }
 
 func (c *echServerConfig) Start() error {
-	if c.certificatePath != "" && c.keyPath != "" {
-		err := c.startWatcher()
-		if err != nil {
-			c.logger.Warn("create fsnotify watcher: ", err)
-		}
-	}
-	if c.echKeyPath != "" {
-		err := c.startECHWatcher()
-		if err != nil {
-			c.logger.Warn("create fsnotify watcher: ", err)
-		}
+	err := c.startWatcher()
+	if err != nil {
+		c.logger.Warn("create credentials watcher: ", err)
 	}
 	return nil
 }
 
 func (c *echServerConfig) startWatcher() error {
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return err
-	}
+	var watchPath []string
 	if c.certificatePath != "" {
-		err = watcher.Add(c.certificatePath)
-		if err != nil {
-			return err
-		}
+		watchPath = append(watchPath, c.certificatePath)
 	}
 	if c.keyPath != "" {
-		err = watcher.Add(c.keyPath)
-		if err != nil {
-			return err
-		}
+		watchPath = append(watchPath, c.keyPath)
+	}
+	if c.echKeyPath != "" {
+		watchPath = append(watchPath, c.echKeyPath)
+	}
+	if len(watchPath) == 0 {
+		return nil
+	}
+	watcher, err := fswatch.NewWatcher(fswatch.Options{
+		Path: watchPath,
+		Callback: func(path string) {
+			err := c.credentialsUpdated(path)
+			if err != nil {
+				c.logger.Error(E.Cause(err, "reload credentials from ", path))
+			}
+		},
+	})
+	if err != nil {
+		return err
 	}
 	c.watcher = watcher
-	go c.loopUpdate()
 	return nil
 }
 
-func (c *echServerConfig) loopUpdate() {
-	for {
-		select {
-		case event, ok := <-c.watcher.Events:
-			if !ok {
-				return
-			}
-			if event.Op&fsnotify.Write != fsnotify.Write {
-				continue
-			}
-			err := c.reloadKeyPair()
+func (c *echServerConfig) credentialsUpdated(path string) error {
+	if path == c.certificatePath || path == c.keyPath {
+		if path == c.certificatePath {
+			certificate, err := os.ReadFile(c.certificatePath)
 			if err != nil {
-				c.logger.Error(E.Cause(err, "reload TLS key pair"))
+				return err
 			}
-		case err, ok := <-c.watcher.Errors:
-			if !ok {
-				return
+			c.certificate = certificate
+		} else {
+			key, err := os.ReadFile(c.keyPath)
+			if err != nil {
+				return err
 			}
-			c.logger.Error(E.Cause(err, "fsnotify error"))
+			c.key = key
 		}
-	}
-}
-
-func (c *echServerConfig) reloadKeyPair() error {
-	if c.certificatePath != "" {
-		certificate, err := os.ReadFile(c.certificatePath)
+		keyPair, err := cftls.X509KeyPair(c.certificate, c.key)
 		if err != nil {
-			return E.Cause(err, "reload certificate from ", c.certificatePath)
+			return E.Cause(err, "parse key pair")
 		}
-		c.certificate = certificate
-	}
-	if c.keyPath != "" {
-		key, err := os.ReadFile(c.keyPath)
+		c.config.Certificates = []cftls.Certificate{keyPair}
+		c.logger.Info("reloaded TLS certificate")
+	} else {
+		echKeyContent, err := os.ReadFile(c.echKeyPath)
 		if err != nil {
-			return E.Cause(err, "reload key from ", c.keyPath)
+			return err
 		}
-		c.key = key
-	}
-	keyPair, err := cftls.X509KeyPair(c.certificate, c.key)
-	if err != nil {
-		return E.Cause(err, "reload key pair")
-	}
-	c.config.Certificates = []cftls.Certificate{keyPair}
-	c.logger.Info("reloaded TLS certificate")
-	return nil
-}
-
-func (c *echServerConfig) startECHWatcher() error {
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return err
-	}
-	err = watcher.Add(c.echKeyPath)
-	if err != nil {
-		return err
-	}
-	c.echWatcher = watcher
-	go c.loopECHUpdate()
-	return nil
-}
-
-func (c *echServerConfig) loopECHUpdate() {
-	for {
-		select {
-		case event, ok := <-c.echWatcher.Events:
-			if !ok {
-				return
-			}
-			if event.Op&fsnotify.Write != fsnotify.Write {
-				continue
-			}
-			err := c.reloadECHKey()
-			if err != nil {
-				c.logger.Error(E.Cause(err, "reload ECH key"))
-			}
-		case err, ok := <-c.echWatcher.Errors:
-			if !ok {
-				return
-			}
-			c.logger.Error(E.Cause(err, "fsnotify error"))
+		block, rest := pem.Decode(echKeyContent)
+		if block == nil || block.Type != "ECH KEYS" || len(rest) > 0 {
+			return E.New("invalid ECH keys pem")
 		}
+		echKeys, err := cftls.EXP_UnmarshalECHKeys(block.Bytes)
+		if err != nil {
+			return E.Cause(err, "parse ECH keys")
+		}
+		echKeySet, err := cftls.EXP_NewECHKeySet(echKeys)
+		if err != nil {
+			return E.Cause(err, "create ECH key set")
+		}
+		c.config.ServerECHProvider = echKeySet
+		c.logger.Info("reloaded ECH keys")
 	}
-}
-
-func (c *echServerConfig) reloadECHKey() error {
-	echKeyContent, err := os.ReadFile(c.echKeyPath)
-	if err != nil {
-		return err
-	}
-	block, rest := pem.Decode(echKeyContent)
-	if block == nil || block.Type != "ECH KEYS" || len(rest) > 0 {
-		return E.New("invalid ECH keys pem")
-	}
-	echKeys, err := cftls.EXP_UnmarshalECHKeys(block.Bytes)
-	if err != nil {
-		return E.Cause(err, "parse ECH keys")
-	}
-	echKeySet, err := cftls.EXP_NewECHKeySet(echKeys)
-	if err != nil {
-		return E.Cause(err, "create ECH key set")
-	}
-	c.config.ServerECHProvider = echKeySet
-	c.logger.Info("reloaded ECH keys")
 	return nil
 }
 
@@ -213,12 +149,7 @@ func (c *echServerConfig) Close() error {
 	var err error
 	if c.watcher != nil {
 		err = E.Append(err, c.watcher.Close(), func(err error) error {
-			return E.Cause(err, "close certificate watcher")
-		})
-	}
-	if c.echWatcher != nil {
-		err = E.Append(err, c.echWatcher.Close(), func(err error) error {
-			return E.Cause(err, "close ECH key watcher")
+			return E.Cause(err, "close credentials watcher")
 		})
 	}
 	return err

+ 19 - 42
common/tls/std_server.go

@@ -7,14 +7,13 @@ import (
 	"os"
 	"strings"
 
+	"github.com/sagernet/fswatch"
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	"github.com/sagernet/sing/common"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/ntp"
-
-	"github.com/fsnotify/fsnotify"
 )
 
 var errInsecureUnused = E.New("tls: insecure unused")
@@ -27,7 +26,7 @@ type STDServerConfig struct {
 	key             []byte
 	certificatePath string
 	keyPath         string
-	watcher         *fsnotify.Watcher
+	watcher         *fswatch.Watcher
 }
 
 func (c *STDServerConfig) ServerName() string {
@@ -88,59 +87,37 @@ func (c *STDServerConfig) Start() error {
 }
 
 func (c *STDServerConfig) startWatcher() error {
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return err
-	}
+	var watchPath []string
 	if c.certificatePath != "" {
-		err = watcher.Add(c.certificatePath)
-		if err != nil {
-			return err
-		}
+		watchPath = append(watchPath, c.certificatePath)
 	}
 	if c.keyPath != "" {
-		err = watcher.Add(c.keyPath)
-		if err != nil {
-			return err
-		}
+		watchPath = append(watchPath, c.keyPath)
 	}
-	c.watcher = watcher
-	go c.loopUpdate()
-	return nil
-}
-
-func (c *STDServerConfig) loopUpdate() {
-	for {
-		select {
-		case event, ok := <-c.watcher.Events:
-			if !ok {
-				return
-			}
-			if event.Op&fsnotify.Write != fsnotify.Write {
-				continue
-			}
-			err := c.reloadKeyPair()
+	watcher, err := fswatch.NewWatcher(fswatch.Options{
+		Path: watchPath,
+		Callback: func(path string) {
+			err := c.certificateUpdated(path)
 			if err != nil {
-				c.logger.Error(E.Cause(err, "reload TLS key pair"))
-			}
-		case err, ok := <-c.watcher.Errors:
-			if !ok {
-				return
+				c.logger.Error(err)
 			}
-			c.logger.Error(E.Cause(err, "fsnotify error"))
-		}
+		},
+	})
+	if err != nil {
+		return err
 	}
+	c.watcher = watcher
+	return nil
 }
 
-func (c *STDServerConfig) reloadKeyPair() error {
-	if c.certificatePath != "" {
+func (c *STDServerConfig) certificateUpdated(path string) error {
+	if path == c.certificatePath {
 		certificate, err := os.ReadFile(c.certificatePath)
 		if err != nil {
 			return E.Cause(err, "reload certificate from ", c.certificatePath)
 		}
 		c.certificate = certificate
-	}
-	if c.keyPath != "" {
+	} else if path == c.keyPath {
 		key, err := os.ReadFile(c.keyPath)
 		if err != nil {
 			return E.Cause(err, "reload key from ", c.keyPath)

+ 1 - 0
constant/rule.go

@@ -11,6 +11,7 @@ const (
 )
 
 const (
+	RuleSetTypeInline   = "inline"
 	RuleSetTypeLocal    = "local"
 	RuleSetTypeRemote   = "remote"
 	RuleSetFormatSource = "source"

+ 64 - 40
docs/configuration/rule-set/index.md

@@ -1,48 +1,56 @@
+---
+icon: material/new-box
+---
+
+!!! quote "Changes in sing-box 1.10.0"
+
+    :material-plus: `type: inline`
+
 # rule-set
 
 !!! question "Since sing-box 1.8.0"
 
 ### Structure
 
-```json
-{
-  "type": "",
-  "tag": "",
-  "format": "",
-  
-  ... // Typed Fields
-}
-```
-
-#### Local Structure
-
-```json
-{
-  "type": "local",
-  
-  ...
-  
-  "path": ""
-}
-```
-
-#### Remote Structure
-
-!!! info ""
-
-    Remote rule-set will be cached if `experimental.cache_file.enabled`.
-
-```json
-{
-  "type": "remote",
-  
-  ...,
-  
-  "url": "",
-  "download_detour": "",
-  "update_interval": ""
-}
-```
+=== "Inline"
+
+    !!! question "Since sing-box 1.10.0"
+
+    ```json
+    {
+      "type": "inline", // optional
+      "tag": "",
+      "rules": []
+    }
+    ```
+
+=== "Local File"
+
+    ```json
+    {
+      "type": "local",
+      "tag": "",
+      "format": "source", // or binary
+      "path": ""
+    }
+    ```
+
+=== "Remote File"
+
+    !!! info ""
+    
+        Remote rule-set will be cached if `experimental.cache_file.enabled`.
+
+    ```json
+    {
+      "type": "remote",
+      "tag": "",
+      "format": "source", // or binary
+      "url": "",
+      "download_detour": "", // optional
+      "update_interval": "" // optional
+    }
+    ```
 
 ### Fields
 
@@ -58,11 +66,23 @@ Type of rule-set, `local` or `remote`.
 
 Tag of rule-set.
 
+### Inline Fields
+
+!!! question "Since sing-box 1.10.0"
+
+#### rules
+
+==Required==
+
+List of [Headless Rule](./headless-rule.md/).
+
+### Local or Remote Fields
+
 #### format
 
 ==Required==
 
-Format of rule-set, `source` or `binary`.
+Format of rule-set file, `source` or `binary`.
 
 ### Local Fields
 
@@ -70,6 +90,10 @@ Format of rule-set, `source` or `binary`.
 
 ==Required==
 
+!!! note ""
+
+    Will be automatically reloaded if file modified since sing-box 1.10.0.
+
 File path of rule-set.
 
 ### Remote Fields

+ 13 - 5
docs/configuration/shared/tls.md

@@ -178,6 +178,10 @@ The server certificate line array, in PEM format.
 
 #### certificate_path
 
+!!! note ""
+
+    Will be automatically reloaded if file modified.
+
 The path to the server certificate, in PEM format.
 
 #### key
@@ -190,6 +194,10 @@ The server private key line array, in PEM format.
 
 ==Server only==
 
+!!! note ""
+
+    Will be automatically reloaded if file modified.
+
 The path to the server private key, in PEM format.
 
 ## Custom TLS support
@@ -266,6 +274,10 @@ ECH key line array, in PEM format.
 
 ==Server only==
 
+!!! note ""
+
+    Will be automatically reloaded if file modified.
+
 The path to ECH key, in PEM format.
 
 #### config
@@ -397,8 +409,4 @@ A hexadecimal string with zero to eight digits.
 
 The maximum time difference between the server and the client.
 
-Check disabled if empty.
-
-### Reload
-
-For server configuration, certificate, key and ECH key will be automatically reloaded if modified.
+Check disabled if empty.

+ 12 - 4
docs/configuration/shared/tls.zh.md

@@ -176,12 +176,20 @@ TLS 版本值:
 
 #### certificate_path
 
+!!! note ""
+
+    文件更改时将自动重新加载。
+
 服务器 PEM 证书路径。
 
 #### key
 
 ==仅服务器==
 
+!!! note ""
+
+    文件更改时将自动重新加载。
+
 服务器 PEM 私钥行数组。
 
 #### key_path
@@ -258,6 +266,10 @@ ECH PEM 密钥行数组
 
 ==仅服务器==
 
+!!! note ""
+
+    文件更改时将自动重新加载。
+
 ECH PEM 密钥路径
 
 #### config
@@ -384,7 +396,3 @@ ACME DNS01 验证字段。如果配置,将禁用其他验证方法。
 服务器与和客户端之间允许的最大时间差。
 
 默认禁用检查。
-
-### 重载
-
-对于服务器配置,如果修改,证书和密钥将自动重新加载。

+ 2 - 2
go.mod

@@ -7,7 +7,6 @@ require (
 	github.com/caddyserver/certmagic v0.20.0
 	github.com/cloudflare/circl v1.3.7
 	github.com/cretz/bine v0.2.0
-	github.com/fsnotify/fsnotify v1.7.0
 	github.com/go-chi/chi/v5 v5.0.12
 	github.com/go-chi/cors v1.2.1
 	github.com/go-chi/render v1.0.3
@@ -23,6 +22,7 @@ require (
 	github.com/oschwald/maxminddb-golang v1.12.0
 	github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a
 	github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1
+	github.com/sagernet/fswatch v0.1.1
 	github.com/sagernet/gomobile v0.1.4
 	github.com/sagernet/gvisor v0.0.0-20240428053021-e691de28565f
 	github.com/sagernet/quic-go v0.47.0-beta.2
@@ -59,6 +59,7 @@ require (
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/andybalholm/brotli v1.0.6 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/gaukas/godicttls v0.0.4 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
 	github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
@@ -81,7 +82,6 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/quic-go/qpack v0.4.0 // indirect
 	github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
-	github.com/sagernet/fswatch v0.1.1 // indirect
 	github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
 	github.com/sagernet/nftables v0.3.0-beta.4 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect

+ 22 - 15
option/rule_set.go

@@ -14,9 +14,10 @@ import (
 )
 
 type _RuleSet struct {
-	Type          string        `json:"type"`
+	Type          string        `json:"type,omitempty"`
 	Tag           string        `json:"tag"`
-	Format        string        `json:"format"`
+	Format        string        `json:"format,omitempty"`
+	InlineOptions PlainRuleSet  `json:"-"`
 	LocalOptions  LocalRuleSet  `json:"-"`
 	RemoteOptions RemoteRuleSet `json:"-"`
 }
@@ -26,6 +27,9 @@ type RuleSet _RuleSet
 func (r RuleSet) MarshalJSON() ([]byte, error) {
 	var v any
 	switch r.Type {
+	case "", C.RuleSetTypeInline:
+		r.Type = ""
+		v = r.InlineOptions
 	case C.RuleSetTypeLocal:
 		v = r.LocalOptions
 	case C.RuleSetTypeRemote:
@@ -44,21 +48,26 @@ func (r *RuleSet) UnmarshalJSON(bytes []byte) error {
 	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)
+	if r.Type != C.RuleSetTypeInline {
+		switch r.Format {
+		case "":
+			return E.New("missing format")
+		case C.RuleSetFormatSource, C.RuleSetFormatBinary:
+		default:
+			return E.New("unknown rule-set format: " + r.Format)
+		}
+	} else {
+		r.Format = ""
 	}
 	var v any
 	switch r.Type {
+	case "", C.RuleSetTypeInline:
+		r.Type = C.RuleSetTypeInline
+		v = &r.InlineOptions
 	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)
 	}
@@ -214,15 +223,13 @@ func (r *PlainRuleSetCompat) UnmarshalJSON(bytes []byte) error {
 	return nil
 }
 
-func (r PlainRuleSetCompat) Upgrade() PlainRuleSet {
-	var result PlainRuleSet
+func (r PlainRuleSetCompat) Upgrade() (PlainRuleSet, error) {
 	switch r.Version {
 	case C.RuleSetVersion1, C.RuleSetVersion2:
-		result = r.Options
 	default:
-		panic("unknown rule-set version: " + F.ToString(r.Version))
+		return PlainRuleSet{}, E.New("unknown rule-set version: " + F.ToString(r.Version))
 	}
-	return result
+	return r.Options, nil
 }
 
 type PlainRuleSet struct {

+ 2 - 2
route/rule_set.go

@@ -20,8 +20,8 @@ import (
 
 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(ctx, router, options)
+	case C.RuleSetTypeInline, C.RuleSetTypeLocal, "":
+		return NewLocalRuleSet(ctx, router, logger, options)
 	case C.RuleSetTypeRemote:
 		return NewRemoteRuleSet(ctx, router, logger, options), nil
 	default:

+ 95 - 32
route/rule_set_local.go

@@ -3,8 +3,10 @@ package route
 import (
 	"context"
 	"os"
+	"path/filepath"
 	"strings"
 
+	"github.com/sagernet/fswatch"
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/srs"
 	C "github.com/sagernet/sing-box/constant"
@@ -14,6 +16,7 @@ import (
 	E "github.com/sagernet/sing/common/exceptions"
 	F "github.com/sagernet/sing/common/format"
 	"github.com/sagernet/sing/common/json"
+	"github.com/sagernet/sing/common/logger"
 	"github.com/sagernet/sing/common/x/list"
 	"github.com/sagernet/sing/service/filemanager"
 
@@ -23,50 +26,55 @@ import (
 var _ adapter.RuleSet = (*LocalRuleSet)(nil)
 
 type LocalRuleSet struct {
-	tag      string
-	rules    []adapter.HeadlessRule
-	metadata adapter.RuleSetMetadata
-	refs     atomic.Int32
+	router     adapter.Router
+	logger     logger.Logger
+	tag        string
+	rules      []adapter.HeadlessRule
+	metadata   adapter.RuleSetMetadata
+	fileFormat string
+	watcher    *fswatch.Watcher
+	refs       atomic.Int32
 }
 
-func NewLocalRuleSet(ctx context.Context, router adapter.Router, options option.RuleSet) (*LocalRuleSet, error) {
-	var plainRuleSet option.PlainRuleSet
-	switch options.Format {
-	case C.RuleSetFormatSource, "":
-		content, err := os.ReadFile(filemanager.BasePath(ctx, options.LocalOptions.Path))
-		if err != nil {
-			return nil, err
-		}
-		compat, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content)
-		if err != nil {
-			return nil, err
+func NewLocalRuleSet(ctx context.Context, router adapter.Router, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) {
+	ruleSet := &LocalRuleSet{
+		router:     router,
+		logger:     logger,
+		tag:        options.Tag,
+		fileFormat: options.Format,
+	}
+	if options.Type == C.RuleSetTypeInline {
+		if len(options.InlineOptions.Rules) == 0 {
+			return nil, E.New("empty inline rule-set")
 		}
-		plainRuleSet = compat.Upgrade()
-	case C.RuleSetFormatBinary:
-		setFile, err := os.Open(filemanager.BasePath(ctx, options.LocalOptions.Path))
+		err := ruleSet.reloadRules(options.InlineOptions.Rules)
 		if err != nil {
 			return nil, err
 		}
-		plainRuleSet, err = srs.Read(setFile, false)
+	} else {
+		err := ruleSet.reloadFile(filemanager.BasePath(ctx, options.LocalOptions.Path))
 		if err != nil {
 			return nil, err
 		}
-	default:
-		return nil, E.New("unknown rule-set format: ", options.Format)
 	}
-	rules := make([]adapter.HeadlessRule, len(plainRuleSet.Rules))
-	var err error
-	for i, ruleOptions := range plainRuleSet.Rules {
-		rules[i], err = NewHeadlessRule(router, ruleOptions)
+	if options.Type == C.RuleSetTypeLocal {
+		var watcher *fswatch.Watcher
+		filePath, _ := filepath.Abs(options.LocalOptions.Path)
+		watcher, err := fswatch.NewWatcher(fswatch.Options{
+			Path: []string{filePath},
+			Callback: func(path string) {
+				uErr := ruleSet.reloadFile(path)
+				if uErr != nil {
+					logger.Error(E.Cause(uErr, "reload rule-set ", options.Tag))
+				}
+			},
+		})
 		if err != nil {
-			return nil, E.Cause(err, "parse rule_set.rules.[", i, "]")
+			return nil, err
 		}
+		ruleSet.watcher = watcher
 	}
-	var metadata adapter.RuleSetMetadata
-	metadata.ContainsProcessRule = hasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule)
-	metadata.ContainsWIFIRule = hasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule)
-	metadata.ContainsIPCIDRRule = hasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule)
-	return &LocalRuleSet{tag: options.Tag, rules: rules, metadata: metadata}, nil
+	return ruleSet, nil
 }
 
 func (s *LocalRuleSet) Name() string {
@@ -78,6 +86,61 @@ func (s *LocalRuleSet) String() string {
 }
 
 func (s *LocalRuleSet) StartContext(ctx context.Context, startContext adapter.RuleSetStartContext) error {
+	if s.watcher != nil {
+		err := s.watcher.Start()
+		if err != nil {
+			s.logger.Error(E.Cause(err, "watch rule-set file"))
+		}
+	}
+	return nil
+}
+
+func (s *LocalRuleSet) reloadFile(path string) error {
+	var plainRuleSet option.PlainRuleSet
+	switch s.fileFormat {
+	case C.RuleSetFormatSource, "":
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return err
+		}
+		compat, err := json.UnmarshalExtended[option.PlainRuleSetCompat](content)
+		if err != nil {
+			return err
+		}
+		plainRuleSet, err = compat.Upgrade()
+		if err != nil {
+			return err
+		}
+	case C.RuleSetFormatBinary:
+		setFile, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		plainRuleSet, err = srs.Read(setFile, false)
+		if err != nil {
+			return err
+		}
+	default:
+		return E.New("unknown rule-set format: ", s.fileFormat)
+	}
+	return s.reloadRules(plainRuleSet.Rules)
+}
+
+func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error {
+	rules := make([]adapter.HeadlessRule, len(headlessRules))
+	var err error
+	for i, ruleOptions := range headlessRules {
+		rules[i], err = NewHeadlessRule(s.router, ruleOptions)
+		if err != nil {
+			return E.Cause(err, "parse rule_set.rules.[", i, "]")
+		}
+	}
+	var metadata adapter.RuleSetMetadata
+	metadata.ContainsProcessRule = hasHeadlessRule(headlessRules, isProcessHeadlessRule)
+	metadata.ContainsWIFIRule = hasHeadlessRule(headlessRules, isWIFIHeadlessRule)
+	metadata.ContainsIPCIDRRule = hasHeadlessRule(headlessRules, isIPCIDRHeadlessRule)
+	s.rules = rules
+	s.metadata = metadata
 	return nil
 }
 
@@ -118,7 +181,7 @@ func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetU
 
 func (s *LocalRuleSet) Close() error {
 	s.rules = nil
-	return nil
+	return common.Close(common.PtrOrNil(s.watcher))
 }
 
 func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool {

+ 4 - 1
route/rule_set_remote.go

@@ -168,7 +168,10 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error {
 		if err != nil {
 			return err
 		}
-		plainRuleSet = compat.Upgrade()
+		plainRuleSet, err = compat.Upgrade()
+		if err != nil {
+			return err
+		}
 	case C.RuleSetFormatBinary:
 		plainRuleSet, err = srs.Read(bytes.NewReader(content), false)
 		if err != nil {