From 4f7770e254d0f2f3e63bd2c8984b69a4c87a48c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 29 Nov 2023 21:01:19 +0800 Subject: [PATCH 1/5] Update dependencies --- go.mod | 8 ++++---- go.sum | 18 +++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index f11ae050..7efe241e 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-chi/render v1.0.3 github.com/gofrs/uuid/v5 v5.0.0 - github.com/insomniacslk/dhcp v0.0.0-20231016090811-6a2c8fbdcc1c + github.com/insomniacslk/dhcp v0.0.0-20231126010706-b0416c0f187a github.com/libdns/alidns v1.0.3 github.com/libdns/cloudflare v0.1.0 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -44,9 +44,9 @@ require ( github.com/stretchr/testify v1.8.4 go.uber.org/zap v1.26.0 go4.org/netipx v0.0.0-20230824141953-6213f710f925 - golang.org/x/crypto v0.15.0 - golang.org/x/net v0.18.0 - golang.org/x/sys v0.14.0 + golang.org/x/crypto v0.16.0 + golang.org/x/net v0.19.0 + golang.org/x/sys v0.15.0 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 diff --git a/go.sum b/go.sum index 2b9a369e..4db37c0b 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,8 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/insomniacslk/dhcp v0.0.0-20231016090811-6a2c8fbdcc1c h1:PgxFEySCI41sH0mB7/2XswdXbUykQsRUGod8Rn+NubM= -github.com/insomniacslk/dhcp v0.0.0-20231016090811-6a2c8fbdcc1c/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/insomniacslk/dhcp v0.0.0-20231126010706-b0416c0f187a h1:biHpNhTkyeXNEzLqTOM6DkIkMedNh/j+ft+POj0Xcko= +github.com/insomniacslk/dhcp v0.0.0-20231126010706-b0416c0f187a/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= @@ -169,16 +169,16 @@ go4.org/netipx v0.0.0-20230824141953-6213f710f925 h1:eeQDDVKFkx0g4Hyy8pHgmZaK0Eq go4.org/netipx v0.0.0-20230824141953-6213f710f925/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -189,10 +189,10 @@ golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= From 5fd9422a47e3cf992fbbcbcabf701c43ef51f468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 28 Nov 2023 12:00:28 +0800 Subject: [PATCH 2/5] Migrate to independent cache file --- adapter/experimental.go | 10 ++- box.go | 12 ++- .../{clashapi => }/cachefile/cache.go | 85 +++++++++++++------ .../{clashapi => }/cachefile/fakeip.go | 0 experimental/clashapi/cache.go | 11 ++- experimental/clashapi/server.go | 66 ++++---------- experimental/libbox/command_group.go | 21 ++--- experimental/libbox/command_urltest.go | 21 ++--- option/clash.go | 31 ------- option/experimental.go | 47 +++++++++- option/group.go | 15 ++++ option/v2ray.go | 12 --- outbound/builder.go | 2 +- outbound/selector.go | 15 ++-- route/router.go | 2 +- transport/fakeip/store.go | 15 ++-- 16 files changed, 194 insertions(+), 171 deletions(-) rename experimental/{clashapi => }/cachefile/cache.go (81%) rename experimental/{clashapi => }/cachefile/fakeip.go (100%) delete mode 100644 option/clash.go create mode 100644 option/group.go diff --git a/adapter/experimental.go b/adapter/experimental.go index 697fa9f1..ac523e4e 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -13,15 +13,17 @@ type ClashServer interface { PreStarter Mode() string ModeList() []string - StoreSelected() bool - StoreFakeIP() bool - CacheFile() ClashCacheFile HistoryStorage() *urltest.HistoryStorage RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule) (net.Conn, Tracker) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule) (N.PacketConn, Tracker) } -type ClashCacheFile interface { +type CacheFile interface { + Service + PreStarter + + StoreFakeIP() bool + LoadMode() string StoreMode(mode string) error LoadSelected(group string) string diff --git a/box.go b/box.go index c10222dd..ae61bffe 100644 --- a/box.go +++ b/box.go @@ -10,6 +10,7 @@ import ( "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/experimental/cachefile" "github.com/sagernet/sing-box/experimental/libbox/platform" "github.com/sagernet/sing-box/inbound" "github.com/sagernet/sing-box/log" @@ -45,17 +46,21 @@ type Options struct { } func New(options Options) (*Box, error) { + createdAt := time.Now() ctx := options.Context if ctx == nil { ctx = context.Background() } ctx = service.ContextWithDefaultRegistry(ctx) ctx = pause.ContextWithDefaultManager(ctx) - createdAt := time.Now() experimentalOptions := common.PtrValueOrDefault(options.Experimental) applyDebugOptions(common.PtrValueOrDefault(experimentalOptions.Debug)) + var needCacheFile bool var needClashAPI bool var needV2RayAPI bool + if experimentalOptions.CacheFile != nil && experimentalOptions.CacheFile.Enabled || options.PlatformLogWriter != nil { + needCacheFile = true + } if experimentalOptions.ClashAPI != nil || options.PlatformLogWriter != nil { needClashAPI = true } @@ -147,6 +152,11 @@ func New(options Options) (*Box, error) { } preServices := make(map[string]adapter.Service) postServices := make(map[string]adapter.Service) + if needCacheFile { + cacheFile := cachefile.NewCacheFile(ctx, common.PtrValueOrDefault(experimentalOptions.CacheFile)) + preServices["cache file"] = cacheFile + service.MustRegister[adapter.CacheFile](ctx, cacheFile) + } if needClashAPI { clashAPIOptions := common.PtrValueOrDefault(experimentalOptions.ClashAPI) clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options) diff --git a/experimental/clashapi/cachefile/cache.go b/experimental/cachefile/cache.go similarity index 81% rename from experimental/clashapi/cachefile/cache.go rename to experimental/cachefile/cache.go index 09118297..89d23882 100644 --- a/experimental/clashapi/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/bbolt" bboltErrors "github.com/sagernet/bbolt/errors" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service/filemanager" @@ -31,11 +32,15 @@ var ( cacheIDDefault = []byte("default") ) -var _ adapter.ClashCacheFile = (*CacheFile)(nil) +var _ adapter.CacheFile = (*CacheFile)(nil) type CacheFile struct { + ctx context.Context + path string + cacheID []byte + storeFakeIP bool + DB *bbolt.DB - cacheID []byte saveAccess sync.RWMutex saveDomain map[netip.Addr]string saveAddress4 map[string]netip.Addr @@ -43,7 +48,29 @@ type CacheFile struct { saveMetadataTimer *time.Timer } -func Open(ctx context.Context, path string, cacheID string) (*CacheFile, error) { +func NewCacheFile(ctx context.Context, options option.CacheFileOptions) *CacheFile { + var path string + if options.Path != "" { + path = filemanager.BasePath(ctx, options.Path) + } else { + path = "cache.db" + } + var cacheIDBytes []byte + if options.CacheID != "" { + cacheIDBytes = append([]byte{0}, []byte(options.CacheID)...) + } + return &CacheFile{ + ctx: ctx, + path: path, + cacheID: cacheIDBytes, + storeFakeIP: options.StoreFakeIP, + saveDomain: make(map[netip.Addr]string), + saveAddress4: make(map[string]netip.Addr), + saveAddress6: make(map[string]netip.Addr), + } +} + +func (c *CacheFile) start() error { const fileMode = 0o666 options := bbolt.Options{Timeout: time.Second} var ( @@ -51,7 +78,7 @@ func Open(ctx context.Context, path string, cacheID string) (*CacheFile, error) err error ) for i := 0; i < 10; i++ { - db, err = bbolt.Open(path, fileMode, &options) + db, err = bbolt.Open(c.path, fileMode, &options) if err == nil { break } @@ -59,23 +86,20 @@ func Open(ctx context.Context, path string, cacheID string) (*CacheFile, error) continue } if E.IsMulti(err, bboltErrors.ErrInvalid, bboltErrors.ErrChecksum, bboltErrors.ErrVersionMismatch) { - rmErr := os.Remove(path) + rmErr := os.Remove(c.path) if rmErr != nil { - return nil, err + return err } } time.Sleep(100 * time.Millisecond) } if err != nil { - return nil, err + return err } - err = filemanager.Chown(ctx, path) + err = filemanager.Chown(c.ctx, c.path) if err != nil { - return nil, E.Cause(err, "platform chown") - } - var cacheIDBytes []byte - if cacheID != "" { - cacheIDBytes = append([]byte{0}, []byte(cacheID)...) + db.Close() + return E.Cause(err, "platform chown") } err = db.Batch(func(tx *bbolt.Tx) error { return tx.ForEach(func(name []byte, b *bbolt.Bucket) error { @@ -97,15 +121,30 @@ func Open(ctx context.Context, path string, cacheID string) (*CacheFile, error) }) }) if err != nil { - return nil, err + db.Close() + return err } - return &CacheFile{ - DB: db, - cacheID: cacheIDBytes, - saveDomain: make(map[netip.Addr]string), - saveAddress4: make(map[string]netip.Addr), - saveAddress6: make(map[string]netip.Addr), - }, nil + c.DB = db + return nil +} + +func (c *CacheFile) PreStart() error { + return c.start() +} + +func (c *CacheFile) Start() error { + return nil +} + +func (c *CacheFile) Close() error { + if c.DB == nil { + return nil + } + return c.DB.Close() +} + +func (c *CacheFile) StoreFakeIP() bool { + return c.storeFakeIP } func (c *CacheFile) LoadMode() string { @@ -218,7 +257,3 @@ func (c *CacheFile) StoreGroupExpand(group string, isExpand bool) error { } }) } - -func (c *CacheFile) Close() error { - return c.DB.Close() -} diff --git a/experimental/clashapi/cachefile/fakeip.go b/experimental/cachefile/fakeip.go similarity index 100% rename from experimental/clashapi/cachefile/fakeip.go rename to experimental/cachefile/fakeip.go diff --git a/experimental/clashapi/cache.go b/experimental/clashapi/cache.go index 7582fde5..9c088a82 100644 --- a/experimental/clashapi/cache.go +++ b/experimental/clashapi/cache.go @@ -1,23 +1,26 @@ package clashapi import ( + "context" "net/http" "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/service" "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) -func cacheRouter(router adapter.Router) http.Handler { +func cacheRouter(ctx context.Context) http.Handler { r := chi.NewRouter() - r.Post("/fakeip/flush", flushFakeip(router)) + r.Post("/fakeip/flush", flushFakeip(ctx)) return r } -func flushFakeip(router adapter.Router) func(w http.ResponseWriter, r *http.Request) { +func flushFakeip(ctx context.Context) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { - if cacheFile := router.ClashServer().CacheFile(); cacheFile != nil { + cacheFile := service.FromContext[adapter.CacheFile](ctx) + if cacheFile != nil { err := cacheFile.FakeIPReset() if err != nil { render.Status(r, http.StatusInternalServerError) diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 6a3d6f66..c40ff938 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -15,7 +15,6 @@ import ( "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/experimental" - "github.com/sagernet/sing-box/experimental/clashapi/cachefile" "github.com/sagernet/sing-box/experimental/clashapi/trafficontrol" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" @@ -49,12 +48,6 @@ type Server struct { mode string modeList []string modeUpdateHook chan<- struct{} - storeMode bool - storeSelected bool - storeFakeIP bool - cacheFilePath string - cacheID string - cacheFile adapter.ClashCacheFile externalController bool externalUI string @@ -76,9 +69,6 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ trafficManager: trafficManager, modeList: options.ModeList, externalController: options.ExternalController != "", - storeMode: options.StoreMode, - storeSelected: options.StoreSelected, - storeFakeIP: options.StoreFakeIP, externalUIDownloadURL: options.ExternalUIDownloadURL, externalUIDownloadDetour: options.ExternalUIDownloadDetour, } @@ -94,18 +84,10 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ server.modeList = append([]string{defaultMode}, server.modeList...) } server.mode = defaultMode - if options.StoreMode || options.StoreSelected || options.StoreFakeIP || options.ExternalController == "" { - cachePath := os.ExpandEnv(options.CacheFile) - if cachePath == "" { - cachePath = "cache.db" - } - if foundPath, loaded := C.FindPath(cachePath); loaded { - cachePath = foundPath - } else { - cachePath = filemanager.BasePath(ctx, cachePath) - } - server.cacheFilePath = cachePath - server.cacheID = options.CacheID + //goland:noinspection GoDeprecation + //nolint:staticcheck + if options.StoreMode || options.StoreSelected || options.StoreFakeIP || options.CacheFile != "" || options.CacheID != "" { + return nil, E.New("cache_file and related fields in Clash API is deprecated in sing-box 1.8.0, use experimental.cache_file instead.") } cors := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, @@ -128,7 +110,7 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ r.Mount("/providers/rules", ruleProviderRouter()) r.Mount("/script", scriptRouter()) r.Mount("/profile", profileRouter()) - r.Mount("/cache", cacheRouter(router)) + r.Mount("/cache", cacheRouter(ctx)) r.Mount("/dns", dnsRouter(router)) server.setupMetaAPI(r) @@ -147,19 +129,13 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ } func (s *Server) PreStart() error { - if s.cacheFilePath != "" { - cacheFile, err := cachefile.Open(s.ctx, s.cacheFilePath, s.cacheID) - if err != nil { - return E.Cause(err, "open cache file") - } - s.cacheFile = cacheFile - if s.storeMode { - mode := s.cacheFile.LoadMode() - if common.Any(s.modeList, func(it string) bool { - return strings.EqualFold(it, mode) - }) { - s.mode = mode - } + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + mode := cacheFile.LoadMode() + if common.Any(s.modeList, func(it string) bool { + return strings.EqualFold(it, mode) + }) { + s.mode = mode } } return nil @@ -187,7 +163,6 @@ func (s *Server) Close() error { return common.Close( common.PtrOrNil(s.httpServer), s.trafficManager, - s.cacheFile, s.urlTestHistory, ) } @@ -224,8 +199,9 @@ func (s *Server) SetMode(newMode string) { } } s.router.ClearDNSCache() - if s.storeMode { - err := s.cacheFile.StoreMode(newMode) + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + err := cacheFile.StoreMode(newMode) if err != nil { s.logger.Error(E.Cause(err, "save mode")) } @@ -233,18 +209,6 @@ func (s *Server) SetMode(newMode string) { s.logger.Info("updated mode: ", newMode) } -func (s *Server) StoreSelected() bool { - return s.storeSelected -} - -func (s *Server) StoreFakeIP() bool { - return s.storeFakeIP -} - -func (s *Server) CacheFile() adapter.ClashCacheFile { - return s.cacheFile -} - func (s *Server) HistoryStorage() *urltest.HistoryStorage { return s.urlTestHistory } diff --git a/experimental/libbox/command_group.go b/experimental/libbox/command_group.go index 93482088..2fc69b98 100644 --- a/experimental/libbox/command_group.go +++ b/experimental/libbox/command_group.go @@ -159,11 +159,7 @@ func readGroups(reader io.Reader) (OutboundGroupIterator, error) { func writeGroups(writer io.Writer, boxService *BoxService) error { historyStorage := service.PtrFromContext[urltest.HistoryStorage](boxService.ctx) - var cacheFile adapter.ClashCacheFile - if clashServer := boxService.instance.Router().ClashServer(); clashServer != nil { - cacheFile = clashServer.CacheFile() - } - + cacheFile := service.FromContext[adapter.CacheFile](boxService.ctx) outbounds := boxService.instance.Router().Outbounds() var iGroups []adapter.OutboundGroup for _, it := range outbounds { @@ -288,16 +284,15 @@ func (s *CommandServer) handleSetGroupExpand(conn net.Conn) error { if err != nil { return err } - service := s.service - if service == nil { + serviceNow := s.service + if serviceNow == nil { return writeError(conn, E.New("service not ready")) } - if clashServer := service.instance.Router().ClashServer(); clashServer != nil { - if cacheFile := clashServer.CacheFile(); cacheFile != nil { - err = cacheFile.StoreGroupExpand(groupTag, isExpand) - if err != nil { - return writeError(conn, err) - } + cacheFile := service.FromContext[adapter.CacheFile](serviceNow.ctx) + if cacheFile != nil { + err = cacheFile.StoreGroupExpand(groupTag, isExpand) + if err != nil { + return writeError(conn, err) } } return writeError(conn, nil) diff --git a/experimental/libbox/command_urltest.go b/experimental/libbox/command_urltest.go index 88e86a8f..3563d8c6 100644 --- a/experimental/libbox/command_urltest.go +++ b/experimental/libbox/command_urltest.go @@ -12,6 +12,7 @@ import ( "github.com/sagernet/sing/common/batch" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/rw" + "github.com/sagernet/sing/service" ) func (c *CommandClient) URLTest(groupTag string) error { @@ -37,11 +38,11 @@ func (s *CommandServer) handleURLTest(conn net.Conn) error { if err != nil { return err } - service := s.service - if service == nil { + serviceNow := s.service + if serviceNow == nil { return nil } - abstractOutboundGroup, isLoaded := service.instance.Router().Outbound(groupTag) + abstractOutboundGroup, isLoaded := serviceNow.instance.Router().Outbound(groupTag) if !isLoaded { return writeError(conn, E.New("outbound group not found: ", groupTag)) } @@ -53,15 +54,9 @@ func (s *CommandServer) handleURLTest(conn net.Conn) error { if isURLTest { go urlTest.CheckOutbounds() } else { - var historyStorage *urltest.HistoryStorage - if clashServer := service.instance.Router().ClashServer(); clashServer != nil { - historyStorage = clashServer.HistoryStorage() - } else { - return writeError(conn, E.New("Clash API is required for URLTest on non-URLTest group")) - } - + historyStorage := service.PtrFromContext[urltest.HistoryStorage](serviceNow.ctx) outbounds := common.Filter(common.Map(outboundGroup.All(), func(it string) adapter.Outbound { - itOutbound, _ := service.instance.Router().Outbound(it) + itOutbound, _ := serviceNow.instance.Router().Outbound(it) return itOutbound }), func(it adapter.Outbound) bool { if it == nil { @@ -73,12 +68,12 @@ func (s *CommandServer) handleURLTest(conn net.Conn) error { } return true }) - b, _ := batch.New(service.ctx, batch.WithConcurrencyNum[any](10)) + b, _ := batch.New(serviceNow.ctx, batch.WithConcurrencyNum[any](10)) for _, detour := range outbounds { outboundToTest := detour outboundTag := outboundToTest.Tag() b.Go(outboundTag, func() (any, error) { - t, err := urltest.URLTest(service.ctx, "", outboundToTest) + t, err := urltest.URLTest(serviceNow.ctx, "", outboundToTest) if err != nil { historyStorage.DeleteURLTestHistory(outboundTag) } else { diff --git a/option/clash.go b/option/clash.go deleted file mode 100644 index 63ee2aeb..00000000 --- a/option/clash.go +++ /dev/null @@ -1,31 +0,0 @@ -package option - -type ClashAPIOptions struct { - ExternalController string `json:"external_controller,omitempty"` - ExternalUI string `json:"external_ui,omitempty"` - ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` - ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` - Secret string `json:"secret,omitempty"` - DefaultMode string `json:"default_mode,omitempty"` - StoreMode bool `json:"store_mode,omitempty"` - StoreSelected bool `json:"store_selected,omitempty"` - StoreFakeIP bool `json:"store_fakeip,omitempty"` - CacheFile string `json:"cache_file,omitempty"` - CacheID string `json:"cache_id,omitempty"` - - ModeList []string `json:"-"` -} - -type SelectorOutboundOptions struct { - Outbounds []string `json:"outbounds"` - Default string `json:"default,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` -} - -type URLTestOutboundOptions struct { - Outbounds []string `json:"outbounds"` - URL string `json:"url,omitempty"` - Interval Duration `json:"interval,omitempty"` - Tolerance uint16 `json:"tolerance,omitempty"` - InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` -} diff --git a/option/experimental.go b/option/experimental.go index a5b6acbd..72751a59 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -1,7 +1,48 @@ package option type ExperimentalOptions struct { - ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` - V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` - Debug *DebugOptions `json:"debug,omitempty"` + CacheFile *CacheFileOptions `json:"cache_file,omitempty"` + ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` + V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + Debug *DebugOptions `json:"debug,omitempty"` +} + +type CacheFileOptions struct { + Enabled bool `json:"enabled,omitempty"` + Path string `json:"path,omitempty"` + CacheID string `json:"cache_id,omitempty"` + StoreFakeIP bool `json:"store_fakeip,omitempty"` +} + +type ClashAPIOptions struct { + ExternalController string `json:"external_controller,omitempty"` + ExternalUI string `json:"external_ui,omitempty"` + ExternalUIDownloadURL string `json:"external_ui_download_url,omitempty"` + ExternalUIDownloadDetour string `json:"external_ui_download_detour,omitempty"` + Secret string `json:"secret,omitempty"` + DefaultMode string `json:"default_mode,omitempty"` + ModeList []string `json:"-"` + + // 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 { + Listen string `json:"listen,omitempty"` + Stats *V2RayStatsServiceOptions `json:"stats,omitempty"` +} + +type V2RayStatsServiceOptions struct { + Enabled bool `json:"enabled,omitempty"` + Inbounds []string `json:"inbounds,omitempty"` + Outbounds []string `json:"outbounds,omitempty"` + Users []string `json:"users,omitempty"` } diff --git a/option/group.go b/option/group.go new file mode 100644 index 00000000..58824e80 --- /dev/null +++ b/option/group.go @@ -0,0 +1,15 @@ +package option + +type SelectorOutboundOptions struct { + Outbounds []string `json:"outbounds"` + Default string `json:"default,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` +} + +type URLTestOutboundOptions struct { + Outbounds []string `json:"outbounds"` + URL string `json:"url,omitempty"` + Interval Duration `json:"interval,omitempty"` + Tolerance uint16 `json:"tolerance,omitempty"` + InterruptExistConnections bool `json:"interrupt_exist_connections,omitempty"` +} diff --git a/option/v2ray.go b/option/v2ray.go index 37f7b8c4..774a651d 100644 --- a/option/v2ray.go +++ b/option/v2ray.go @@ -1,13 +1 @@ package option - -type V2RayAPIOptions struct { - Listen string `json:"listen,omitempty"` - Stats *V2RayStatsServiceOptions `json:"stats,omitempty"` -} - -type V2RayStatsServiceOptions struct { - Enabled bool `json:"enabled,omitempty"` - Inbounds []string `json:"inbounds,omitempty"` - Outbounds []string `json:"outbounds,omitempty"` - Users []string `json:"users,omitempty"` -} diff --git a/outbound/builder.go b/outbound/builder.go index 141758d8..e4d6a80e 100644 --- a/outbound/builder.go +++ b/outbound/builder.go @@ -56,7 +56,7 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, t case C.TypeHysteria2: return NewHysteria2(ctx, router, logger, tag, options.Hysteria2Options) case C.TypeSelector: - return NewSelector(router, logger, tag, options.SelectorOptions) + return NewSelector(ctx, router, logger, tag, options.SelectorOptions) case C.TypeURLTest: return NewURLTest(ctx, router, logger, tag, options.URLTestOptions) default: diff --git a/outbound/selector.go b/outbound/selector.go index c66591cd..e801daea 100644 --- a/outbound/selector.go +++ b/outbound/selector.go @@ -12,6 +12,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" ) var ( @@ -21,6 +22,7 @@ var ( type Selector struct { myOutboundAdapter + ctx context.Context tags []string defaultTag string outbounds map[string]adapter.Outbound @@ -29,7 +31,7 @@ type Selector struct { interruptExternalConnections bool } -func NewSelector(router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (*Selector, error) { +func NewSelector(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SelectorOutboundOptions) (*Selector, error) { outbound := &Selector{ myOutboundAdapter: myOutboundAdapter{ protocol: C.TypeSelector, @@ -38,6 +40,7 @@ func NewSelector(router adapter.Router, logger log.ContextLogger, tag string, op tag: tag, dependencies: options.Outbounds, }, + ctx: ctx, tags: options.Outbounds, defaultTag: options.Default, outbounds: make(map[string]adapter.Outbound), @@ -67,8 +70,9 @@ func (s *Selector) Start() error { } if s.tag != "" { - if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreSelected() { - selected := clashServer.CacheFile().LoadSelected(s.tag) + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + selected := cacheFile.LoadSelected(s.tag) if selected != "" { detour, loaded := s.outbounds[selected] if loaded { @@ -110,8 +114,9 @@ func (s *Selector) SelectOutbound(tag string) bool { } s.selected = detour if s.tag != "" { - if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreSelected() { - err := clashServer.CacheFile().StoreSelected(s.tag, tag) + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil { + err := cacheFile.StoreSelected(s.tag, tag) if err != nil { s.logger.Error("store selected: ", err) } diff --git a/route/router.go b/route/router.go index 972c4ec0..e50a51a9 100644 --- a/route/router.go +++ b/route/router.go @@ -261,7 +261,7 @@ func NewRouter( if fakeIPOptions.Inet6Range != nil { inet6Range = *fakeIPOptions.Inet6Range } - router.fakeIPStore = fakeip.NewStore(router, router.logger, inet4Range, inet6Range) + router.fakeIPStore = fakeip.NewStore(ctx, router.logger, inet4Range, inet6Range) } usePlatformDefaultInterfaceMonitor := platformInterface != nil && platformInterface.UsePlatformDefaultInterfaceMonitor() diff --git a/transport/fakeip/store.go b/transport/fakeip/store.go index 96f6bf03..83677b0d 100644 --- a/transport/fakeip/store.go +++ b/transport/fakeip/store.go @@ -1,17 +1,19 @@ package fakeip import ( + "context" "net/netip" "github.com/sagernet/sing-box/adapter" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" ) var _ adapter.FakeIPStore = (*Store)(nil) type Store struct { - router adapter.Router + ctx context.Context logger logger.Logger inet4Range netip.Prefix inet6Range netip.Prefix @@ -20,9 +22,9 @@ type Store struct { inet6Current netip.Addr } -func NewStore(router adapter.Router, logger logger.Logger, inet4Range netip.Prefix, inet6Range netip.Prefix) *Store { +func NewStore(ctx context.Context, logger logger.Logger, inet4Range netip.Prefix, inet6Range netip.Prefix) *Store { return &Store{ - router: router, + ctx: ctx, logger: logger, inet4Range: inet4Range, inet6Range: inet6Range, @@ -31,10 +33,9 @@ func NewStore(router adapter.Router, logger logger.Logger, inet4Range netip.Pref func (s *Store) Start() error { var storage adapter.FakeIPStorage - if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreFakeIP() { - if cacheFile := clashServer.CacheFile(); cacheFile != nil { - storage = cacheFile - } + cacheFile := service.FromContext[adapter.CacheFile](s.ctx) + if cacheFile != nil && cacheFile.StoreFakeIP() { + storage = cacheFile } if storage == nil { storage = NewMemoryStorage() From d26cbfb570a65b464b37ba86463c606e8b270e4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 28 Nov 2023 23:47:32 +0800 Subject: [PATCH 3/5] Allow nested logical rules --- experimental/clashapi.go | 46 ++++++++++++++++++++++------------- option/rule.go | 21 ++++++++++++---- option/rule_dns.go | 25 +++++++++++++------ route/router.go | 4 +-- route/router_geo_resources.go | 12 +++------ route/rule_default.go | 8 +++--- route/rule_dns.go | 8 +++--- 7 files changed, 77 insertions(+), 47 deletions(-) diff --git a/experimental/clashapi.go b/experimental/clashapi.go index 894d40a7..8f2e18fe 100644 --- a/experimental/clashapi.go +++ b/experimental/clashapi.go @@ -5,6 +5,7 @@ import ( "os" "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -27,24 +28,35 @@ func NewClashServer(ctx context.Context, router adapter.Router, logFactory log.O func CalculateClashModeList(options option.Options) []string { var clashMode []string - for _, dnsRule := range common.PtrValueOrDefault(options.DNS).Rules { - if dnsRule.DefaultOptions.ClashMode != "" && !common.Contains(clashMode, dnsRule.DefaultOptions.ClashMode) { - clashMode = append(clashMode, dnsRule.DefaultOptions.ClashMode) - } - for _, defaultRule := range dnsRule.LogicalOptions.Rules { - if defaultRule.ClashMode != "" && !common.Contains(clashMode, defaultRule.ClashMode) { - clashMode = append(clashMode, defaultRule.ClashMode) - } - } - } - for _, rule := range common.PtrValueOrDefault(options.Route).Rules { - if rule.DefaultOptions.ClashMode != "" && !common.Contains(clashMode, rule.DefaultOptions.ClashMode) { + clashMode = append(clashMode, extraClashModeFromRule(common.PtrValueOrDefault(options.Route).Rules)...) + clashMode = append(clashMode, extraClashModeFromDNSRule(common.PtrValueOrDefault(options.DNS).Rules)...) + clashMode = common.Uniq(clashMode) + return clashMode +} + +func extraClashModeFromRule(rules []option.Rule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: clashMode = append(clashMode, rule.DefaultOptions.ClashMode) - } - for _, defaultRule := range rule.LogicalOptions.Rules { - if defaultRule.ClashMode != "" && !common.Contains(clashMode, defaultRule.ClashMode) { - clashMode = append(clashMode, defaultRule.ClashMode) - } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromRule(rule.LogicalOptions.Rules)...) + } + } + return clashMode +} + +func extraClashModeFromDNSRule(rules []option.DNSRule) []string { + var clashMode []string + for _, rule := range rules { + switch rule.Type { + case C.RuleTypeDefault: + if rule.DefaultOptions.ClashMode != "" { + clashMode = append(clashMode, rule.DefaultOptions.ClashMode) + } + case C.RuleTypeLogical: + clashMode = append(clashMode, extraClashModeFromDNSRule(rule.LogicalOptions.Rules)...) } } return clashMode diff --git a/option/rule.go b/option/rule.go index 8caba96e..4f404202 100644 --- a/option/rule.go +++ b/option/rule.go @@ -53,6 +53,17 @@ func (r *Rule) UnmarshalJSON(bytes []byte) error { return nil } +func (r Rule) 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 DefaultRule struct { Inbound Listable[string] `json:"inbound,omitempty"` IPVersion int `json:"ip_version,omitempty"` @@ -92,12 +103,12 @@ func (r DefaultRule) IsValid() bool { } type LogicalRule struct { - Mode string `json:"mode"` - Rules []DefaultRule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` - Outbound string `json:"outbound,omitempty"` + Mode string `json:"mode"` + Rules []Rule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` + Outbound string `json:"outbound,omitempty"` } func (r LogicalRule) IsValid() bool { - return len(r.Rules) > 0 && common.All(r.Rules, DefaultRule.IsValid) + return len(r.Rules) > 0 && common.All(r.Rules, Rule.IsValid) } diff --git a/option/rule_dns.go b/option/rule_dns.go index ba572b9a..fca34322 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -53,6 +53,17 @@ func (r *DNSRule) UnmarshalJSON(bytes []byte) error { return nil } +func (r DNSRule) IsValid() bool { + switch r.Type { + case C.RuleTypeDefault: + return r.DefaultOptions.IsValid() + case C.RuleTypeLogical: + return r.LogicalOptions.IsValid() + default: + panic("unknown DNS rule type: " + r.Type) + } +} + type DefaultDNSRule struct { Inbound Listable[string] `json:"inbound,omitempty"` IPVersion int `json:"ip_version,omitempty"` @@ -96,14 +107,14 @@ func (r DefaultDNSRule) IsValid() bool { } type LogicalDNSRule struct { - Mode string `json:"mode"` - Rules []DefaultDNSRule `json:"rules,omitempty"` - Invert bool `json:"invert,omitempty"` - Server string `json:"server,omitempty"` - DisableCache bool `json:"disable_cache,omitempty"` - RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` + Mode string `json:"mode"` + Rules []DNSRule `json:"rules,omitempty"` + Invert bool `json:"invert,omitempty"` + Server string `json:"server,omitempty"` + DisableCache bool `json:"disable_cache,omitempty"` + RewriteTTL *uint32 `json:"rewrite_ttl,omitempty"` } func (r LogicalDNSRule) IsValid() bool { - return len(r.Rules) > 0 && common.All(r.Rules, DefaultDNSRule.IsValid) + return len(r.Rules) > 0 && common.All(r.Rules, DNSRule.IsValid) } diff --git a/route/router.go b/route/router.go index e50a51a9..b48d2346 100644 --- a/route/router.go +++ b/route/router.go @@ -127,14 +127,14 @@ func NewRouter( Logger: router.dnsLogger, }) for i, ruleOptions := range options.Rules { - routeRule, err := NewRule(router, router.logger, ruleOptions) + routeRule, err := NewRule(router, router.logger, ruleOptions, true) if err != nil { return nil, E.Cause(err, "parse rule[", i, "]") } router.rules = append(router.rules, routeRule) } for i, dnsRuleOptions := range dnsOptions.Rules { - dnsRule, err := NewDNSRule(router, router.logger, dnsRuleOptions) + dnsRule, err := NewDNSRule(router, router.logger, dnsRuleOptions, true) if err != nil { return nil, E.Cause(err, "parse dns rule[", i, "]") } diff --git a/route/router_geo_resources.go b/route/router_geo_resources.go index 8715cf92..638d00df 100644 --- a/route/router_geo_resources.go +++ b/route/router_geo_resources.go @@ -252,10 +252,8 @@ func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool return true } case C.RuleTypeLogical: - for _, subRule := range rule.LogicalOptions.Rules { - if cond(subRule) { - return true - } + if hasRule(rule.LogicalOptions.Rules, cond) { + return true } } } @@ -270,10 +268,8 @@ func hasDNSRule(rules []option.DNSRule, cond func(rule option.DefaultDNSRule) bo return true } case C.RuleTypeLogical: - for _, subRule := range rule.LogicalOptions.Rules { - if cond(subRule) { - return true - } + if hasDNSRule(rule.LogicalOptions.Rules, cond) { + return true } } } diff --git a/route/rule_default.go b/route/rule_default.go index 2d62f97a..8c8473ab 100644 --- a/route/rule_default.go +++ b/route/rule_default.go @@ -8,13 +8,13 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -func NewRule(router adapter.Router, logger log.ContextLogger, options option.Rule) (adapter.Rule, error) { +func NewRule(router adapter.Router, logger log.ContextLogger, options option.Rule, checkOutbound bool) (adapter.Rule, error) { switch options.Type { case "", C.RuleTypeDefault: if !options.DefaultOptions.IsValid() { return nil, E.New("missing conditions") } - if options.DefaultOptions.Outbound == "" { + if options.DefaultOptions.Outbound == "" && checkOutbound { return nil, E.New("missing outbound field") } return NewDefaultRule(router, logger, options.DefaultOptions) @@ -22,7 +22,7 @@ func NewRule(router adapter.Router, logger log.ContextLogger, options option.Rul if !options.LogicalOptions.IsValid() { return nil, E.New("missing conditions") } - if options.LogicalOptions.Outbound == "" { + if options.LogicalOptions.Outbound == "" && checkOutbound { return nil, E.New("missing outbound field") } return NewLogicalRule(router, logger, options.LogicalOptions) @@ -220,7 +220,7 @@ func NewLogicalRule(router adapter.Router, logger log.ContextLogger, options opt return nil, E.New("unknown logical mode: ", options.Mode) } for i, subRule := range options.Rules { - rule, err := NewDefaultRule(router, logger, subRule) + rule, err := NewRule(router, logger, subRule, false) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") } diff --git a/route/rule_dns.go b/route/rule_dns.go index 5132f024..b4449325 100644 --- a/route/rule_dns.go +++ b/route/rule_dns.go @@ -8,13 +8,13 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -func NewDNSRule(router adapter.Router, logger log.ContextLogger, options option.DNSRule) (adapter.DNSRule, error) { +func NewDNSRule(router adapter.Router, logger log.ContextLogger, options option.DNSRule, checkServer bool) (adapter.DNSRule, error) { switch options.Type { case "", C.RuleTypeDefault: if !options.DefaultOptions.IsValid() { return nil, E.New("missing conditions") } - if options.DefaultOptions.Server == "" { + if options.DefaultOptions.Server == "" && checkServer { return nil, E.New("missing server field") } return NewDefaultDNSRule(router, logger, options.DefaultOptions) @@ -22,7 +22,7 @@ func NewDNSRule(router adapter.Router, logger log.ContextLogger, options option. if !options.LogicalOptions.IsValid() { return nil, E.New("missing conditions") } - if options.LogicalOptions.Server == "" { + if options.LogicalOptions.Server == "" && checkServer { return nil, E.New("missing server field") } return NewLogicalDNSRule(router, logger, options.LogicalOptions) @@ -228,7 +228,7 @@ func NewLogicalDNSRule(router adapter.Router, logger log.ContextLogger, options return nil, E.New("unknown logical mode: ", options.Mode) } for i, subRule := range options.Rules { - rule, err := NewDefaultDNSRule(router, logger, subRule) + rule, err := NewDNSRule(router, logger, subRule, false) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") } From 1bb40934f73c6e788e6f1717c9f868e25c676ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 29 Nov 2023 17:35:40 +0800 Subject: [PATCH 4/5] Add rule set --- .gitignore | 1 + adapter/experimental.go | 66 ++- adapter/inbound.go | 1 + adapter/router.go | 15 +- cmd/sing-box/cmd_format.go | 39 -- cmd/sing-box/cmd_geoip.go | 43 ++ cmd/sing-box/cmd_geoip_export.go | 98 ++++ cmd/sing-box/cmd_geoip_list.go | 31 ++ cmd/sing-box/cmd_geoip_lookup.go | 47 ++ cmd/sing-box/cmd_geosite.go | 41 ++ cmd/sing-box/cmd_geosite_export.go | 81 +++ cmd/sing-box/cmd_geosite_list.go | 50 ++ cmd/sing-box/cmd_geosite_lookup.go | 97 ++++ cmd/sing-box/cmd_geosite_matcher.go | 56 ++ cmd/sing-box/cmd_merge.go | 2 +- cmd/sing-box/cmd_rule_set.go | 14 + cmd/sing-box/cmd_rule_set_compile.go | 80 +++ cmd/sing-box/cmd_rule_set_format.go | 87 ++++ cmd/sing-box/cmd_tools.go | 6 +- cmd/sing-box/cmd_tools_connect.go | 2 +- common/dialer/router.go | 12 +- common/srs/binary.go | 485 ++++++++++++++++++ common/srs/ip_set.go | 116 +++++ constant/rule.go | 8 + experimental/cachefile/cache.go | 35 ++ experimental/cachefile/fakeip.go | 2 +- experimental/clashapi/proxies.go | 6 +- experimental/clashapi/server_resources.go | 6 +- .../clashapi/trafficontrol/tracker.go | 8 +- go.mod | 2 +- go.sum | 4 +- option/experimental.go | 8 +- option/route.go | 1 + option/rule.go | 58 ++- option/rule_dns.go | 1 + option/rule_set.go | 230 +++++++++ option/types.go | 8 + route/router.go | 34 +- route/rule_abstract.go | 22 +- route/rule_default.go | 7 +- route/rule_dns.go | 7 +- route/rule_headless.go | 173 +++++++ route/rule_item_cidr.go | 19 +- route/rule_item_domain.go | 7 + route/rule_item_rule_set.go | 55 ++ route/rule_set.go | 22 + route/rule_set_local.go | 69 +++ route/rule_set_remote.go | 218 ++++++++ 48 files changed, 2375 insertions(+), 105 deletions(-) create mode 100644 cmd/sing-box/cmd_geoip.go create mode 100644 cmd/sing-box/cmd_geoip_export.go create mode 100644 cmd/sing-box/cmd_geoip_list.go create mode 100644 cmd/sing-box/cmd_geoip_lookup.go create mode 100644 cmd/sing-box/cmd_geosite.go create mode 100644 cmd/sing-box/cmd_geosite_export.go create mode 100644 cmd/sing-box/cmd_geosite_list.go create mode 100644 cmd/sing-box/cmd_geosite_lookup.go create mode 100644 cmd/sing-box/cmd_geosite_matcher.go create mode 100644 cmd/sing-box/cmd_rule_set.go create mode 100644 cmd/sing-box/cmd_rule_set_compile.go create mode 100644 cmd/sing-box/cmd_rule_set_format.go create mode 100644 common/srs/binary.go create mode 100644 common/srs/ip_set.go create mode 100644 option/rule_set.go create mode 100644 route/rule_headless.go create mode 100644 route/rule_item_rule_set.go create mode 100644 route/rule_set.go create mode 100644 route/rule_set_local.go create mode 100644 route/rule_set_remote.go 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 ac523e4e..c26ee8c1 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..30dec9d1 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -47,6 +47,7 @@ type InboundContext struct { GeoIPCode string ProcessInfo *process.Info FakeIP bool + IPCIDRMatchSource bool // dns cache diff --git a/adapter/router.go b/adapter/router.go index e4c3904d..ca4d6547 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -18,7 +18,7 @@ type Router interface { Outbounds() []Outbound Outbound(tag string) (Outbound, bool) - DefaultOutbound(network string) Outbound + DefaultOutbound(network string) (Outbound, error) FakeIPStore() FakeIPStore @@ -27,6 +27,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) @@ -61,11 +63,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 } @@ -76,6 +82,11 @@ type DNSRule interface { RewriteTTL() *uint32 } +type RuleSet interface { + HeadlessRule + Service +} + type InterfaceUpdateListener interface { InterfaceUpdated() } 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..dd994c2c --- /dev/null +++ b/common/srs/binary.go @@ -0,0 +1,485 @@ +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.DefaultOptions, err = readDefaultRule(reader, recovery) + case 1: + 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 89d23882..daf48d72 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/go.mod b/go.mod index 7efe241e..095326d7 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sagernet/gvisor v0.0.0-20231119034329-07cfb6aaf930 github.com/sagernet/quic-go v0.40.0 github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 - github.com/sagernet/sing v0.2.18-0.20231124125253-2dcabf4bfcbc + github.com/sagernet/sing v0.2.18-0.20231129075305-eb56a60214be github.com/sagernet/sing-dns v0.1.11 github.com/sagernet/sing-mux v0.1.5-0.20231109075101-6b086ed6bb07 github.com/sagernet/sing-quic v0.1.5-0.20231123150216-00957d136203 diff --git a/go.sum b/go.sum index 4db37c0b..6d8d994a 100644 --- a/go.sum +++ b/go.sum @@ -110,8 +110,8 @@ github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 h1:5Th31OC6yj8byL github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691/go.mod h1:B8lp4WkQ1PwNnrVMM6KyuFR20pU8jYBD+A4EhJovEXU= github.com/sagernet/sing v0.0.0-20220817130738-ce854cda8522/go.mod h1:QVsS5L/ZA2Q5UhQwLrn0Trw+msNd/NPGEhBKR/ioWiY= github.com/sagernet/sing v0.1.8/go.mod h1:jt1w2u7lJQFFSGLiRrRIs5YWmx4kAPfWuOejuDW9qMk= -github.com/sagernet/sing v0.2.18-0.20231124125253-2dcabf4bfcbc h1:vESVuxHgbd2EzHxd+TYTpNACIEGBOhp5n3KG7bgbcws= -github.com/sagernet/sing v0.2.18-0.20231124125253-2dcabf4bfcbc/go.mod h1:OL6k2F0vHmEzXz2KW19qQzu172FDgSbUSODylighuVo= +github.com/sagernet/sing v0.2.18-0.20231129075305-eb56a60214be h1:FigAM9kq7RRXmHvgn8w2a8tqCY5CMV5GIk0id84dI0o= +github.com/sagernet/sing v0.2.18-0.20231129075305-eb56a60214be/go.mod h1:OL6k2F0vHmEzXz2KW19qQzu172FDgSbUSODylighuVo= github.com/sagernet/sing-dns v0.1.11 h1:PPrMCVVrAeR3f5X23I+cmvacXJ+kzuyAsBiWyUKhGSE= github.com/sagernet/sing-dns v0.1.11/go.mod h1:zJ/YjnYB61SYE+ubMcMqVdpaSvsyQ2iShQGO3vuLvvE= github.com/sagernet/sing-mux v0.1.5-0.20231109075101-6b086ed6bb07 h1:ncKb5tVOsCQgCsv6UpsA0jinbNb5OQ5GMPJlyQP3EHM= 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..7a6f7e92 --- /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 rule_set.[].tag") + } + switch r.Format { + case "": + return E.New("missing rule_set.[].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 rule_set.[].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/types.go b/option/types.go index f2fed663..520c3503 100644 --- a/option/types.go +++ b/option/types.go @@ -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 b48d2346..96706195 100644 --- a/route/router.go +++ b/route/router.go @@ -67,6 +67,8 @@ type Router struct { 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 @@ -106,6 +108,7 @@ 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), @@ -140,6 +143,14 @@ func NewRouter( } router.dnsRules = append(router.dnsRules, dnsRule) } + for i, ruleSetOptions := range options.RuleSet { + 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) @@ -479,6 +490,12 @@ func (r *Router) Start() error { if r.needWIFIState { r.updateWIFIState() } + for i, ruleSet := range r.ruleSets { + err := ruleSet.Start() + if err != nil { + return E.Cause(err, "initialize rule-set[", i, "]") + } + } for i, rule := range r.rules { err := rule.Start() if err != nil { @@ -576,11 +593,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 } } @@ -588,6 +611,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 { diff --git a/route/rule_abstract.go b/route/rule_abstract.go index 38d4d57d..312caaee 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" @@ -135,7 +136,7 @@ func (r *abstractDefaultRule) String() string { } type abstractLogicalRule struct { - rules []adapter.Rule + rules []adapter.HeadlessRule mode string invert bool outbound string @@ -146,7 +147,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 +160,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 +173,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 +187,11 @@ 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 { 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 { 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..76c78c62 --- /dev/null +++ b/route/rule_set.go @@ -0,0 +1,22 @@ +package route + +import ( + "context" + + "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" +) + +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) + } +} diff --git a/route/rule_set_local.go b/route/rule_set_local.go new file mode 100644 index 00000000..ccdb1704 --- /dev/null +++ b/route/rule_set_local.go @@ -0,0 +1,69 @@ +package route + +import ( + "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 +} + +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, "]") + } + } + return &LocalRuleSet{rules}, 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) Start() error { + return nil +} + +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..d9102320 --- /dev/null +++ b/route/rule_set_remote.go @@ -0,0 +1,218 @@ +package route + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "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" +) + +var _ adapter.RuleSet = (*RemoteRuleSet)(nil) + +type RemoteRuleSet struct { + ctx context.Context + cancel context.CancelFunc + router adapter.Router + logger logger.ContextLogger + options option.RuleSet + updateInterval time.Duration + dialer N.Dialer + rules []adapter.HeadlessRule + lastUpdated time.Time + lastEtag string + updateTicker *time.Ticker +} + +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 = 12 * time.Hour + } + return &RemoteRuleSet{ + ctx: ctx, + cancel: cancel, + router: router, + logger: logger, + options: options, + updateInterval: updateInterval, + } +} + +func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool { + for _, rule := range s.rules { + if rule.Match(metadata) { + return true + } + } + return false +} + +func (s *RemoteRuleSet) Start() 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() || time.Since(s.lastUpdated) > s.updateInterval { + err := s.fetchOnce() + if err != nil { + return E.Cause(err, "fetch rule-set ", s.options.Tag) + } + } + s.updateTicker = time.NewTicker(s.updateInterval) + go s.loopUpdate() + return nil +} + +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.rules = rules + return nil +} + +func (s *RemoteRuleSet) loopUpdate() { + for { + select { + case <-s.ctx.Done(): + return + case <-s.updateTicker.C: + err := s.fetchOnce() + if err != nil { + s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err) + } + } + } +} + +func (s *RemoteRuleSet) fetchOnce() error { + s.logger.Debug("updating rule-set ", s.options.Tag, " from URL: ", s.options.RemoteOptions.URL) + 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)) + }, + }, + } + defer httpClient.CloseIdleConnections() + 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(s.ctx)) + if err != nil { + return err + } + switch response.StatusCode { + case http.StatusOK: + case http.StatusNotModified: + 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 +} From 65c9254c6b465fa10dc1f7002bdd133a918346f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 29 Nov 2023 17:35:56 +0800 Subject: [PATCH 5/5] documentation: Add rule set --- docs/configuration/dns/rule.md | 32 ++- docs/configuration/dns/rule.zh.md | 32 ++- docs/configuration/experimental/cache-file.md | 34 +++ docs/configuration/experimental/clash-api.md | 121 ++++++++++ docs/configuration/experimental/index.md | 145 ++---------- docs/configuration/experimental/index.zh.md | 137 ------------ docs/configuration/experimental/v2ray-api.md | 50 +++++ docs/configuration/route/geoip.md | 8 + docs/configuration/route/geoip.zh.md | 33 --- docs/configuration/route/geosite.md | 8 + docs/configuration/route/geosite.zh.md | 33 --- docs/configuration/route/index.md | 30 ++- docs/configuration/route/index.zh.md | 32 ++- docs/configuration/route/rule.md | 46 +++- docs/configuration/route/rule.zh.md | 44 +++- docs/configuration/rule-set/headless-rule.md | 207 ++++++++++++++++++ docs/configuration/rule-set/index.md | 97 ++++++++ docs/configuration/rule-set/source-format.md | 34 +++ docs/migration.md | 187 ++++++++++++++++ mkdocs.yml | 28 ++- 20 files changed, 980 insertions(+), 358 deletions(-) create mode 100644 docs/configuration/experimental/cache-file.md create mode 100644 docs/configuration/experimental/clash-api.md delete mode 100644 docs/configuration/experimental/index.zh.md create mode 100644 docs/configuration/experimental/v2ray-api.md delete mode 100644 docs/configuration/route/geoip.zh.md delete mode 100644 docs/configuration/route/geosite.zh.md create mode 100644 docs/configuration/rule-set/headless-rule.md create mode 100644 docs/configuration/rule-set/index.md create mode 100644 docs/configuration/rule-set/source-format.md create mode 100644 docs/migration.md diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 18e352b4..896a3b44 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -1,3 +1,13 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### Structure ```json @@ -85,6 +95,10 @@ "wifi_bssid": [ "00:00:00:00:00:00" ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], "invert": false, "outbound": [ "direct" @@ -166,15 +180,23 @@ Match domain using regular expression. #### geosite +!!! failure "Deprecated in sing-box 1.8.0" + + Geosite is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geosite-to-rule-set). + Match geosite. #### source_geoip +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geoip-to-rule-set). + Match source geoip. #### source_ip_cidr -Match source ip cidr. +Match source IP CIDR. #### source_port @@ -250,6 +272,12 @@ Match WiFi SSID. Match WiFi BSSID. +#### rule_set + +!!! question "Since sing-box 1.8.0" + +Match [Rule Set](/configuration/route/#rule_set). + #### invert Invert match result. @@ -286,4 +314,4 @@ Rewrite TTL in DNS responses. #### rules -Included default rules. \ No newline at end of file +Included rules. \ No newline at end of file diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 98bfa8ab..f990ed3e 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -1,3 +1,13 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### 结构 ```json @@ -84,6 +94,10 @@ "wifi_bssid": [ "00:00:00:00:00:00" ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], "invert": false, "outbound": [ "direct" @@ -163,10 +177,18 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 #### geosite -匹配 GeoSite。 +!!! failure "已在 sing-box 1.8.0 废弃" + + Geosite 已废弃且可能在不久的将来移除,参阅 [迁移指南](/migration/#migrate-geosite-to-rule-set)。 + +匹配 Geosite。 #### source_geoip +!!! failure "已在 sing-box 1.8.0 废弃" + + GeoIp 已废弃且可能在不久的将来移除,参阅 [迁移指南](/migration/#migrate-geoip-to-rule-set)。 + 匹配源 GeoIP。 #### source_ip_cidr @@ -245,6 +267,12 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 匹配 WiFi BSSID。 +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +匹配[规则集](/zh/configuration/route/#rule_set)。 + #### invert 反选匹配结果。 @@ -281,4 +309,4 @@ DNS 查询类型。值可以为整数或者类型名称字符串。 #### rules -包括的默认规则。 \ No newline at end of file +包括的规则。 \ No newline at end of file diff --git a/docs/configuration/experimental/cache-file.md b/docs/configuration/experimental/cache-file.md new file mode 100644 index 00000000..66e30ef9 --- /dev/null +++ b/docs/configuration/experimental/cache-file.md @@ -0,0 +1,34 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.8.0" + +### Structure + +```json +{ + "enabled": true, + "path": "", + "cache_id": "", + "store_fakeip": false +} +``` + +### Fields + +#### enabled + +Enable cache file. + +#### path + +Path to the cache file. + +`cache.db` will be used if empty. + +#### cache_id + +Identifier in cache file. + +If not empty, configuration specified data will use a separate store keyed by it. diff --git a/docs/configuration/experimental/clash-api.md b/docs/configuration/experimental/clash-api.md new file mode 100644 index 00000000..a06fe154 --- /dev/null +++ b/docs/configuration/experimental/clash-api.md @@ -0,0 +1,121 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.8.0" + + :material-delete-alert: [store_mode](#store_mode) + :material-delete-alert: [store_selected](#store_selected) + :material-delete-alert: [store_fakeip](#store_fakeip) + :material-delete-alert: [cache_file](#cache_file) + :material-delete-alert: [cache_id](#cache_id) + + +!!! quote "" + + Clash API is not included by default, see [Installation](./#installation). + +### Structure + +```json +{ + "external_controller": "127.0.0.1:9090", + "external_ui": "", + "external_ui_download_url": "", + "external_ui_download_detour": "", + "secret": "", + "default_mode": "", + + // Deprecated + + "store_mode": false, + "store_selected": false, + "store_fakeip": false, + "cache_file": "", + "cache_id": "" +} +``` + +### Fields + +#### external_controller + +RESTful web API listening address. Clash API will be disabled if empty. + +#### external_ui + +A relative path to the configuration directory or an absolute path to a +directory in which you put some static web resource. sing-box will then +serve it at `http://{{external-controller}}/ui`. + + + +#### external_ui_download_url + +ZIP download URL for the external UI, will be used if the specified `external_ui` directory is empty. + +`https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` will be used if empty. + +#### external_ui_download_detour + +The tag of the outbound to download the external UI. + +Default outbound will be used if empty. + +#### secret + +Secret for the RESTful API (optional) +Authenticate by spedifying HTTP header `Authorization: Bearer ${secret}` +ALWAYS set a secret if RESTful API is listening on 0.0.0.0 + +#### default_mode + +Default mode in clash, `Rule` will be used if empty. + +This setting has no direct effect, but can be used in routing and DNS rules via the `clash_mode` rule item. + +#### store_mode + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_mode` is deprecated in Clash API and enabled by default if `cache_file.enabled`. + +Store Clash mode in cache file. + +#### store_selected + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_selected` is deprecated in Clash API and enabled by default if `cache_file.enabled`. + +!!! note "" + + The tag must be set for target outbounds. + +Store selected outbound for the `Selector` outbound in cache file. + +#### store_fakeip + +!!! failure "Deprecated in sing-box 1.8.0" + + `store_selected` is deprecated in Clash API and migrated to `cache_file.store_fakeip`. + +Store fakeip in cache file. + +#### cache_file + +!!! failure "Deprecated in sing-box 1.8.0" + + `cache_file` is deprecated in Clash API and migrated to `cache_file.enabled` and `cache_file.path`. + +Cache file path, `cache.db` will be used if empty. + +#### cache_id + +!!! failure "Deprecated in sing-box 1.8.0" + + `cache_id` is deprecated in Clash API and migrated to `cache_file.cache_id`. + +Identifier in cache file. + +If not empty, configuration specified data will use a separate store keyed by it. \ No newline at end of file diff --git a/docs/configuration/experimental/index.md b/docs/configuration/experimental/index.md index 308e851c..1057e59b 100644 --- a/docs/configuration/experimental/index.md +++ b/docs/configuration/experimental/index.md @@ -1,139 +1,30 @@ +--- +icon: material/alert-decagram +--- + # Experimental +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [cache_file](#cache_file) + :material-alert-decagram: [clash_api](#clash_api) + ### Structure ```json { "experimental": { - "clash_api": { - "external_controller": "127.0.0.1:9090", - "external_ui": "", - "external_ui_download_url": "", - "external_ui_download_detour": "", - "secret": "", - "default_mode": "", - "store_mode": false, - "store_selected": false, - "store_fakeip": false, - "cache_file": "", - "cache_id": "" - }, - "v2ray_api": { - "listen": "127.0.0.1:8080", - "stats": { - "enabled": true, - "inbounds": [ - "socks-in" - ], - "outbounds": [ - "proxy", - "direct" - ], - "users": [ - "sekai" - ] - } - } + "cache_file": {}, + "clash_api": {}, + "v2ray_api": {} } } ``` -!!! note "" +### Fields - Traffic statistics and connection management can degrade performance. - -### Clash API Fields - -!!! quote "" - - Clash API is not included by default, see [Installation](./#installation). - -#### external_controller - -RESTful web API listening address. Clash API will be disabled if empty. - -#### external_ui - -A relative path to the configuration directory or an absolute path to a -directory in which you put some static web resource. sing-box will then -serve it at `http://{{external-controller}}/ui`. - -#### external_ui_download_url - -ZIP download URL for the external UI, will be used if the specified `external_ui` directory is empty. - -`https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip` will be used if empty. - -#### external_ui_download_detour - -The tag of the outbound to download the external UI. - -Default outbound will be used if empty. - -#### secret - -Secret for the RESTful API (optional) -Authenticate by spedifying HTTP header `Authorization: Bearer ${secret}` -ALWAYS set a secret if RESTful API is listening on 0.0.0.0 - -#### default_mode - -Default mode in clash, `Rule` will be used if empty. - -This setting has no direct effect, but can be used in routing and DNS rules via the `clash_mode` rule item. - -#### store_mode - -Store Clash mode in cache file. - -#### store_selected - -!!! note "" - - The tag must be set for target outbounds. - -Store selected outbound for the `Selector` outbound in cache file. - -#### store_fakeip - -Store fakeip in cache file. - -#### cache_file - -Cache file path, `cache.db` will be used if empty. - -#### cache_id - -Cache ID. - -If not empty, `store_selected` will use a separate store keyed by it. - -### V2Ray API Fields - -!!! quote "" - - V2Ray API is not included by default, see [Installation](./#installation). - -#### listen - -gRPC API listening address. V2Ray API will be disabled if empty. - -#### stats - -Traffic statistics service settings. - -#### stats.enabled - -Enable statistics service. - -#### stats.inbounds - -Inbound list to count traffic. - -#### stats.outbounds - -Outbound list to count traffic. - -#### stats.users - -User list to count traffic. \ No newline at end of file +| Key | Format | +|--------------|----------------------------| +| `cache_file` | [Cache File](./cache-file) | +| `clash_api` | [Clash API](./clash-api) | +| `v2ray_api` | [V2Ray API](./v2ray-api) | \ No newline at end of file diff --git a/docs/configuration/experimental/index.zh.md b/docs/configuration/experimental/index.zh.md deleted file mode 100644 index 88a95852..00000000 --- a/docs/configuration/experimental/index.zh.md +++ /dev/null @@ -1,137 +0,0 @@ -# 实验性 - -### 结构 - -```json -{ - "experimental": { - "clash_api": { - "external_controller": "127.0.0.1:9090", - "external_ui": "", - "external_ui_download_url": "", - "external_ui_download_detour": "", - "secret": "", - "default_mode": "", - "store_mode": false, - "store_selected": false, - "store_fakeip": false, - "cache_file": "", - "cache_id": "" - }, - "v2ray_api": { - "listen": "127.0.0.1:8080", - "stats": { - "enabled": true, - "inbounds": [ - "socks-in" - ], - "outbounds": [ - "proxy", - "direct" - ], - "users": [ - "sekai" - ] - } - } - } -} -``` - -!!! note "" - - 流量统计和连接管理会降低性能。 - -### Clash API 字段 - -!!! quote "" - - 默认安装不包含 Clash API,参阅 [安装](/zh/#_2)。 - -#### external_controller - -RESTful web API 监听地址。如果为空,则禁用 Clash API。 - -#### external_ui - -到静态网页资源目录的相对路径或绝对路径。sing-box 会在 `http://{{external-controller}}/ui` 下提供它。 - -#### external_ui_download_url - -静态网页资源的 ZIP 下载 URL,如果指定的 `external_ui` 目录为空,将使用。 - -默认使用 `https://github.com/MetaCubeX/Yacd-meta/archive/gh-pages.zip`。 - -#### external_ui_download_detour - -用于下载静态网页资源的出站的标签。 - -如果为空,将使用默认出站。 - -#### secret - -RESTful API 的密钥(可选) -通过指定 HTTP 标头 `Authorization: Bearer ${secret}` 进行身份验证 -如果 RESTful API 正在监听 0.0.0.0,请始终设置一个密钥。 - -#### default_mode - -Clash 中的默认模式,默认使用 `Rule`。 - -此设置没有直接影响,但可以通过 `clash_mode` 规则项在路由和 DNS 规则中使用。 - -#### store_mode - -将 Clash 模式存储在缓存文件中。 - -#### store_selected - -!!! note "" - - 必须为目标出站设置标签。 - -将 `Selector` 中出站的选定的目标出站存储在缓存文件中。 - -#### store_fakeip - -将 fakeip 存储在缓存文件中。 - -#### cache_file - -缓存文件路径,默认使用`cache.db`。 - -#### cache_id - -缓存 ID。 - -如果不为空,`store_selected` 将会使用以此为键的独立存储。 - -### V2Ray API 字段 - -!!! quote "" - - 默认安装不包含 V2Ray API,参阅 [安装](/zh/#_2)。 - -#### listen - -gRPC API 监听地址。如果为空,则禁用 V2Ray API。 - -#### stats - -流量统计服务设置。 - -#### stats.enabled - -启用统计服务。 - -#### stats.inbounds - -统计流量的入站列表。 - -#### stats.outbounds - -统计流量的出站列表。 - -#### stats.users - -统计流量的用户列表。 \ No newline at end of file diff --git a/docs/configuration/experimental/v2ray-api.md b/docs/configuration/experimental/v2ray-api.md new file mode 100644 index 00000000..39888424 --- /dev/null +++ b/docs/configuration/experimental/v2ray-api.md @@ -0,0 +1,50 @@ +### Structure + +!!! quote "" + + V2Ray API is not included by default, see [Installation](./#installation). + +```json +{ + "listen": "127.0.0.1:8080", + "stats": { + "enabled": true, + "inbounds": [ + "socks-in" + ], + "outbounds": [ + "proxy", + "direct" + ], + "users": [ + "sekai" + ] + } +} +``` + +### Fields + +#### listen + +gRPC API listening address. V2Ray API will be disabled if empty. + +#### stats + +Traffic statistics service settings. + +#### stats.enabled + +Enable statistics service. + +#### stats.inbounds + +Inbound list to count traffic. + +#### stats.outbounds + +Outbound list to count traffic. + +#### stats.users + +User list to count traffic. \ No newline at end of file diff --git a/docs/configuration/route/geoip.md b/docs/configuration/route/geoip.md index b966a292..d6dfd232 100644 --- a/docs/configuration/route/geoip.md +++ b/docs/configuration/route/geoip.md @@ -1,3 +1,11 @@ +--- +icon: material/delete-clock +--- + +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geoip-to-rule-set). + ### Structure ```json diff --git a/docs/configuration/route/geoip.zh.md b/docs/configuration/route/geoip.zh.md deleted file mode 100644 index 3ee70427..00000000 --- a/docs/configuration/route/geoip.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -### 结构 - -```json -{ - "route": { - "geoip": { - "path": "", - "download_url": "", - "download_detour": "" - } - } -} -``` - -### 字段 - -#### path - -指定 GeoIP 资源的路径。 - -默认 `geoip.db`。 - -#### download_url - -指定 GeoIP 资源的下载链接。 - -默认为 `https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db`。 - -#### download_detour - -用于下载 GeoIP 资源的出站的标签。 - -如果为空,将使用默认出站。 \ No newline at end of file diff --git a/docs/configuration/route/geosite.md b/docs/configuration/route/geosite.md index db700c6a..515e86b1 100644 --- a/docs/configuration/route/geosite.md +++ b/docs/configuration/route/geosite.md @@ -1,3 +1,11 @@ +--- +icon: material/delete-clock +--- + +!!! failure "Deprecated in sing-box 1.8.0" + + Geosite is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geosite-to-rule-set). + ### Structure ```json diff --git a/docs/configuration/route/geosite.zh.md b/docs/configuration/route/geosite.zh.md deleted file mode 100644 index bee81fbf..00000000 --- a/docs/configuration/route/geosite.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -### 结构 - -```json -{ - "route": { - "geosite": { - "path": "", - "download_url": "", - "download_detour": "" - } - } -} -``` - -### 字段 - -#### path - -指定 GeoSite 资源的路径。 - -默认 `geosite.db`。 - -#### download_url - -指定 GeoSite 资源的下载链接。 - -默认为 `https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db`。 - -#### download_detour - -用于下载 GeoSite 资源的出站的标签。 - -如果为空,将使用默认出站。 \ No newline at end of file diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md index 846d4973..7c1787ea 100644 --- a/docs/configuration/route/index.md +++ b/docs/configuration/route/index.md @@ -1,5 +1,15 @@ +--- +icon: material/alert-decagram +--- + # Route +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### Structure ```json @@ -8,6 +18,7 @@ "geoip": {}, "geosite": {}, "rules": [], + "rule_set": [], "final": "", "auto_detect_interface": false, "override_android_vpn": false, @@ -19,11 +30,20 @@ ### Fields -| Key | Format | -|------------|------------------------------------| -| `geoip` | [GeoIP](./geoip) | -| `geosite` | [Geosite](./geosite) | -| `rules` | List of [Route Rule](./rule) | +| Key | Format | +|-----------|----------------------| +| `geoip` | [GeoIP](./geoip) | +| `geosite` | [Geosite](./geosite) | + +#### rules + +List of [Route Rule](./rule) + +#### rule_set + +!!! question "Since sing-box 1.8.0" + +List of [Rule Set](/configuration/rule-set) #### final diff --git a/docs/configuration/route/index.zh.md b/docs/configuration/route/index.zh.md index 8bef5bea..b5302727 100644 --- a/docs/configuration/route/index.zh.md +++ b/docs/configuration/route/index.zh.md @@ -1,5 +1,15 @@ +--- +icon: material/alert-decagram +--- + # 路由 +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### 结构 ```json @@ -7,8 +17,8 @@ "route": { "geoip": {}, "geosite": {}, - "ip_rules": [], "rules": [], + "rule_set": [], "final": "", "auto_detect_interface": false, "override_android_vpn": false, @@ -20,11 +30,21 @@ ### 字段 -| 键 | 格式 | -|------------|-------------------------| -| `geoip` | [GeoIP](./geoip) | -| `geosite` | [GeoSite](./geosite) | -| `rules` | 一组 [路由规则](./rule) | +| 键 | 格式 | +|------------|-----------------------------------| +| `geoip` | [GeoIP](./geoip) | +| `geosite` | [Geosite](./geosite) | + + +#### rule + +一组 [路由规则](./rule)。 + +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +一组 [规则集](/configuration/rule-set)。 #### final diff --git a/docs/configuration/route/rule.md b/docs/configuration/route/rule.md index abbfde6f..35c1d8fc 100644 --- a/docs/configuration/route/rule.md +++ b/docs/configuration/route/rule.md @@ -1,3 +1,15 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "Changes in sing-box 1.8.0" + + :material-plus: [rule_set](#rule_set) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-delete-clock: [source_geoip](#source_geoip) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### Structure ```json @@ -89,6 +101,10 @@ "wifi_bssid": [ "00:00:00:00:00:00" ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], "invert": false, "outbound": "direct" }, @@ -160,23 +176,35 @@ Match domain using regular expression. #### geosite +!!! failure "Deprecated in sing-box 1.8.0" + + Geosite is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geosite-to-rule-set). + Match geosite. #### source_geoip +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geoip-to-rule-set). + Match source geoip. #### geoip +!!! failure "Deprecated in sing-box 1.8.0" + + GeoIP is deprecated and may be removed in the future, check [Migration](/migration/#migrate-geoip-to-rule-set). + Match geoip. #### source_ip_cidr -Match source ip cidr. +Match source IP CIDR. #### ip_cidr -Match ip cidr. +Match IP CIDR. #### source_port @@ -250,6 +278,18 @@ Match WiFi SSID. Match WiFi BSSID. +#### rule_set + +!!! question "Since sing-box 1.8.0" + +Match [Rule Set](/configuration/route/#rule_set). + +#### rule_set_ipcidr_match_source + +!!! question "Since sing-box 1.8.0" + +Make `ipcidr` in rule sets match the source IP. + #### invert Invert match result. @@ -276,4 +316,4 @@ Tag of the target outbound. ==Required== -Included default rules. +Included rules. diff --git a/docs/configuration/route/rule.zh.md b/docs/configuration/route/rule.zh.md index f4ab7890..31ee08d1 100644 --- a/docs/configuration/route/rule.zh.md +++ b/docs/configuration/route/rule.zh.md @@ -1,3 +1,15 @@ +--- +icon: material/alert-decagram +--- + +!!! quote "sing-box 1.8.0 中的更改" + + :material-plus: [rule_set](#rule_set) + :material-plus: [rule_set_ipcidr_match_source](#rule_set_ipcidr_match_source) + :material-delete-clock: [source_geoip](#source_geoip) + :material-delete-clock: [geoip](#geoip) + :material-delete-clock: [geosite](#geosite) + ### 结构 ```json @@ -87,6 +99,10 @@ "wifi_bssid": [ "00:00:00:00:00:00" ], + "rule_set": [ + "geoip-cn", + "geosite-cn" + ], "invert": false, "outbound": "direct" }, @@ -158,14 +174,26 @@ #### geosite -匹配 GeoSite。 +!!! failure "已在 sing-box 1.8.0 废弃" + + Geosite 已废弃且可能在不久的将来移除,参阅 [迁移指南](/migration/#migrate-geosite-to-rule-set)。 + +匹配 Geosite。 #### source_geoip +!!! failure "已在 sing-box 1.8.0 废弃" + + GeoIp 已废弃且可能在不久的将来移除,参阅 [迁移指南](/migration/#migrate-geoip-to-rule-set)。 + 匹配源 GeoIP。 #### geoip +!!! failure "已在 sing-box 1.8.0 废弃" + + GeoIp 已废弃且可能在不久的将来移除,参阅 [迁移指南](/migration/#migrate-geoip-to-rule-set)。 + 匹配 GeoIP。 #### source_ip_cidr @@ -248,6 +276,18 @@ 匹配 WiFi BSSID。 +#### rule_set + +!!! question "自 sing-box 1.8.0 起" + +匹配[规则集](/zh/configuration/route/#rule_set)。 + +#### rule_set_ipcidr_match_source + +!!! question "自 sing-box 1.8.0 起" + +使规则集中的 `ipcidr` 规则匹配源 IP。 + #### invert 反选匹配结果。 @@ -274,4 +314,4 @@ ==必填== -包括的默认规则。 \ No newline at end of file +包括的规则。 \ No newline at end of file diff --git a/docs/configuration/rule-set/headless-rule.md b/docs/configuration/rule-set/headless-rule.md new file mode 100644 index 00000000..6ab62eb2 --- /dev/null +++ b/docs/configuration/rule-set/headless-rule.md @@ -0,0 +1,207 @@ +--- +icon: material/new-box +--- + +### Structure + +!!! question "Since sing-box 1.8.0" + +```json +{ + "rules": [ + { + "query_type": [ + "A", + "HTTPS", + 32768 + ], + "network": [ + "tcp" + ], + "domain": [ + "test.com" + ], + "domain_suffix": [ + ".cn" + ], + "domain_keyword": [ + "test" + ], + "domain_regex": [ + "^stun\\..+" + ], + "source_ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "source_port": [ + 12345 + ], + "source_port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "port": [ + 80, + 443 + ], + "port_range": [ + "1000:2000", + ":3000", + "4000:" + ], + "process_name": [ + "curl" + ], + "process_path": [ + "/usr/bin/curl" + ], + "package_name": [ + "com.termux" + ], + "wifi_ssid": [ + "My WIFI" + ], + "wifi_bssid": [ + "00:00:00:00:00:00" + ], + "invert": false + }, + { + "type": "logical", + "mode": "and", + "rules": [], + "invert": false + } + ] +} +``` + +!!! note "" + + You can ignore the JSON Array [] tag when the content is only one item + +### Default Fields + +!!! note "" + + The default rule uses the following matching logic: + (`domain` || `domain_suffix` || `domain_keyword` || `domain_regex` || `ip_cidr`) && + (`port` || `port_range`) && + (`source_port` || `source_port_range`) && + `other fields` + +#### query_type + +DNS query type. Values can be integers or type name strings. + +#### network + +`tcp` or `udp`. + +#### domain + +Match full domain. + +#### domain_suffix + +Match domain suffix. + +#### domain_keyword + +Match domain using keyword. + +#### domain_regex + +Match domain using regular expression. + +#### source_ip_cidr + +Match source IP CIDR. + +#### ip_cidr + +!!! info "" + + `ip_cidr` is an alias for `source_ip_cidr` when the Rule Set is used in DNS rules or `rule_set_ipcidr_match_source` enabled in route rules. + +Match IP CIDR. + +#### source_port + +Match source port. + +#### source_port_range + +Match source port range. + +#### port + +Match port. + +#### port_range + +Match port range. + +#### process_name + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process name. + +#### process_path + +!!! quote "" + + Only supported on Linux, Windows, and macOS. + +Match process path. + +#### package_name + +Match android package name. + +#### wifi_ssid + +!!! quote "" + + Only supported in graphical clients on Android and iOS. + +Match WiFi SSID. + +#### wifi_bssid + +!!! quote "" + + Only supported in graphical clients on Android and iOS. + +Match WiFi BSSID. + +#### invert + +Invert match result. + +### Logical Fields + +#### type + +`logical` + +#### mode + +==Required== + +`and` or `or` + +#### rules + +==Required== + +Included rules. diff --git a/docs/configuration/rule-set/index.md b/docs/configuration/rule-set/index.md new file mode 100644 index 00000000..5aff55b3 --- /dev/null +++ b/docs/configuration/rule-set/index.md @@ -0,0 +1,97 @@ +--- +icon: material/new-box +--- + +# 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": "" +} +``` + +### Fields + +#### type + +==Required== + +Type of Rule Set, `local` or `remote`. + +#### tag + +==Required== + +Tag of Rule Set. + +#### format + +==Required== + +Format of Rule Set, `source` or `binary`. + +### Local Fields + +#### path + +==Required== + +File path of Rule Set. + +### Remote Fields + +#### url + +==Required== + +Download URL of Rule Set. + +#### download_detour + +Tag of the outbound to download rule-set. + +Default outbound will be used if empty. + +#### update_interval + +Update interval of Rule Set. + +`1d` will be used if empty. diff --git a/docs/configuration/rule-set/source-format.md b/docs/configuration/rule-set/source-format.md new file mode 100644 index 00000000..116c1ee6 --- /dev/null +++ b/docs/configuration/rule-set/source-format.md @@ -0,0 +1,34 @@ +--- +icon: material/new-box +--- + +# Source Format + +!!! question "Since sing-box 1.8.0" + +### Structure + +```json +{ + "version": 1, + "rules": [] +} +``` + +### Compile + +Use `sing-box rule-set compile [--output .srs] .json` to compile source to binary rule-set. + +### Fields + +#### version + +==Required== + +Version of Rule Set, must be `1`. + +#### rules + +==Required== + +List of [Headless Rule](./headless-rule.md). diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 00000000..8eb3daee --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,187 @@ +--- +icon: material/arrange-bring-forward +--- + +# Migration + +## 1.8.0 + +!!! warning "Unstable" + + This version is still under development, and the following migration guide may be changed in the future. + +### :material-close-box: Migrate cache file from Clash API to independent options + +!!! info "Reference" + + [Clash API](/configuration/experimental/clash-api) / + [Cache File](/configuration/experimental/cache-file) + +=== ":material-card-remove: Deprecated" + + ```json + { + "experimental": { + "clash_api": { + "cache_file": "cache.db", // default value + "cahce_id": "my_profile2", + "store_mode": true, + "store_selected": true, + "store_fakeip": true + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "experimental" : { + "cache_file": { + "enabled": true, + "path": "cache.db", // default value + "cache_id": "my_profile2", + "store_fakeip": true + } + } + } + ``` + +### :material-checkbox-intermediate: Migrate GeoIP to rule sets + +!!! info "Reference" + + [GeoIP](/configuration/route/geoip) / + [Route](/configuration/route) / + [Route Rule](/configuration/route/rule) / + [DNS Rule](/configuration/dns/rule) / + [Rule Set](/configuration/rule-set) + +!!! tip + + `sing-box geoip` commands can help you convert custom GeoIP into rule sets. + +=== ":material-card-remove: Deprecated" + + ```json + { + "route": { + "rules": [ + { + "geoip": "cn", + "outbound": "direct" + }, + { + "source_geoip": "cn", + "outbound": "block" + } + ], + "geoip": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "rule_set": "geoip-cn", + "outbound": "direct" + }, + { + "rule_set": "geoip-us", + "rule_set_ipcidr_match_source": true, + "outbound": "block" + } + ], + "rule_set": [ + { + "tag": "geoip-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs", + "download_detour": "proxy" + }, + { + "tag": "geoip-us", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-us.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save Rule Set cache + } + } + } + ``` + +### :material-checkbox-intermediate: Migrate Geosite to rule sets + +!!! info "Reference" + + [Geosite](/configuration/route/geosite) / + [Route](/configuration/route) / + [Route Rule](/configuration/route/rule) / + [DNS Rule](/configuration/dns/rule) / + [Rule Set](/configuration/rule-set) + +!!! tip + + `sing-box geosite` commands can help you convert custom Geosite into rule sets. + +=== ":material-card-remove: Deprecated" + + ```json + { + "route": { + "rules": [ + { + "geosite": "cn", + "outbound": "direct" + } + ], + "geosite": { + "download_detour": "proxy" + } + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "route": { + "rules": [ + { + "rule_set": "geosite-cn", + "outbound": "direct" + } + ], + "rule_set": [ + { + "tag": "geosite-cn", + "type": "remote", + "format": "binary", + "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs", + "download_detour": "proxy" + } + ] + }, + "experimental": { + "cache_file": { + "enabled": true // required to save Rule Set cache + } + } + } + ``` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 1d4b1d8b..c5dd7df3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,12 +32,16 @@ theme: - content.code.copy - content.code.select - content.code.annotate + icon: + admonition: + question: material/new-box nav: - Home: - index.md + - Change Log: changelog.md + - Migration: migration.md - Deprecated: deprecated.md - Support: support.md - - Change Log: changelog.md - Installation: - Package Manager: installation/package-manager.md - Docker: installation/docker.md @@ -56,7 +60,7 @@ nav: - Proxy: - Server: manual/proxy/server.md - Client: manual/proxy/client.md -# - TUN: manual/proxy/tun.md + # - TUN: manual/proxy/tun.md - Proxy Protocol: - Shadowsocks: manual/proxy-protocol/shadowsocks.md - Trojan: manual/proxy-protocol/trojan.md @@ -79,8 +83,15 @@ nav: - Geosite: configuration/route/geosite.md - Route Rule: configuration/route/rule.md - Protocol Sniff: configuration/route/sniff.md + - Rule Set: + - configuration/rule-set/index.md + - Source Format: configuration/rule-set/source-format.md + - Headless Rule: configuration/rule-set/headless-rule.md - Experimental: - configuration/experimental/index.md + - Cache File: configuration/experimental/cache-file.md + - Clash API: configuration/experimental/clash-api.md + - V2Ray API: configuration/experimental/v2ray-api.md - Shared: - Listen Fields: configuration/shared/listen.md - Dial Fields: configuration/shared/dial.md @@ -180,9 +191,10 @@ plugins: name: 简体中文 nav_translations: Home: 开始 + Change Log: 更新日志 + Migration: 迁移指南 Deprecated: 废弃功能列表 Support: 支持 - Change Log: 更新日志 Installation: 安装 Package Manager: 包管理器 @@ -203,6 +215,10 @@ plugins: Route Rule: 路由规则 Protocol Sniff: 协议探测 + Rule Set: 规则集 + Source Format: 源文件格式 + Headless Rule: 无头规则 + Experimental: 实验性 Shared: 通用 @@ -215,10 +231,6 @@ plugins: Inbound: 入站 Outbound: 出站 - FAQ: 常见问题 - Known Issues: 已知问题 - Examples: 示例 - Linux Server Installation: Linux 服务器安装 - DNS Hijack: DNS 劫持 + Manual: 手册 reconfigure_material: true reconfigure_search: true \ No newline at end of file