浏览代码

Migrate to independent cache file

世界 1 年之前
父节点
当前提交
bf4e556f67

+ 6 - 4
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

+ 44 - 9
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"
@@ -32,7 +33,8 @@ type Box struct {
 	outbounds    []adapter.Outbound
 	logFactory   log.Factory
 	logger       log.ContextLogger
-	preServices  map[string]adapter.Service
+	preServices1 map[string]adapter.Service
+	preServices2 map[string]adapter.Service
 	postServices map[string]adapter.Service
 	done         chan struct{}
 }
@@ -45,17 +47,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
 	}
@@ -145,8 +151,14 @@ func New(options Options) (*Box, error) {
 			return nil, E.Cause(err, "initialize platform interface")
 		}
 	}
-	preServices := make(map[string]adapter.Service)
+	preServices1 := make(map[string]adapter.Service)
+	preServices2 := make(map[string]adapter.Service)
 	postServices := make(map[string]adapter.Service)
+	if needCacheFile {
+		cacheFile := cachefile.New(ctx, common.PtrValueOrDefault(experimentalOptions.CacheFile))
+		preServices1["cache file"] = cacheFile
+		service.MustRegister[adapter.CacheFile](ctx, cacheFile)
+	}
 	if needClashAPI {
 		clashAPIOptions := common.PtrValueOrDefault(experimentalOptions.ClashAPI)
 		clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options)
@@ -155,7 +167,7 @@ func New(options Options) (*Box, error) {
 			return nil, E.Cause(err, "create clash api server")
 		}
 		router.SetClashServer(clashServer)
-		preServices["clash api"] = clashServer
+		preServices2["clash api"] = clashServer
 	}
 	if needV2RayAPI {
 		v2rayServer, err := experimental.NewV2RayServer(logFactory.NewLogger("v2ray-api"), common.PtrValueOrDefault(experimentalOptions.V2RayAPI))
@@ -163,7 +175,7 @@ func New(options Options) (*Box, error) {
 			return nil, E.Cause(err, "create v2ray api server")
 		}
 		router.SetV2RayServer(v2rayServer)
-		preServices["v2ray api"] = v2rayServer
+		preServices2["v2ray api"] = v2rayServer
 	}
 	return &Box{
 		router:       router,
@@ -172,7 +184,8 @@ func New(options Options) (*Box, error) {
 		createdAt:    createdAt,
 		logFactory:   logFactory,
 		logger:       logFactory.Logger(),
-		preServices:  preServices,
+		preServices1: preServices1,
+		preServices2: preServices2,
 		postServices: postServices,
 		done:         make(chan struct{}),
 	}, nil
@@ -217,7 +230,16 @@ func (s *Box) Start() error {
 }
 
 func (s *Box) preStart() error {
-	for serviceName, service := range s.preServices {
+	for serviceName, service := range s.preServices1 {
+		if preService, isPreService := service.(adapter.PreStarter); isPreService {
+			s.logger.Trace("pre-start ", serviceName)
+			err := preService.PreStart()
+			if err != nil {
+				return E.Cause(err, "pre-starting ", serviceName)
+			}
+		}
+	}
+	for serviceName, service := range s.preServices2 {
 		if preService, isPreService := service.(adapter.PreStarter); isPreService {
 			s.logger.Trace("pre-start ", serviceName)
 			err := preService.PreStart()
@@ -238,7 +260,14 @@ func (s *Box) start() error {
 	if err != nil {
 		return err
 	}
-	for serviceName, service := range s.preServices {
+	for serviceName, service := range s.preServices1 {
+		s.logger.Trace("starting ", serviceName)
+		err = service.Start()
+		if err != nil {
+			return E.Cause(err, "start ", serviceName)
+		}
+	}
+	for serviceName, service := range s.preServices2 {
 		s.logger.Trace("starting ", serviceName)
 		err = service.Start()
 		if err != nil {
@@ -314,7 +343,13 @@ func (s *Box) Close() error {
 			return E.Cause(err, "close router")
 		})
 	}
-	for serviceName, service := range s.preServices {
+	for serviceName, service := range s.preServices1 {
+		s.logger.Trace("closing ", serviceName)
+		errors = E.Append(errors, service.Close(), func(err error) error {
+			return E.Cause(err, "close ", serviceName)
+		})
+	}
+	for serviceName, service := range s.preServices2 {
 		s.logger.Trace("closing ", serviceName)
 		errors = E.Append(errors, service.Close(), func(err error) error {
 			return E.Cause(err, "close ", serviceName)

+ 60 - 25
experimental/clashapi/cachefile/cache.go → 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 New(ctx context.Context, options option.CacheFileOptions) *CacheFile {
+	var path string
+	if options.Path != "" {
+		path = options.Path
+	} else {
+		path = "cache.db"
+	}
+	var cacheIDBytes []byte
+	if options.CacheID != "" {
+		cacheIDBytes = append([]byte{0}, []byte(options.CacheID)...)
+	}
+	return &CacheFile{
+		ctx:          ctx,
+		path:         filemanager.BasePath(ctx, 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()
-}

+ 0 - 0
experimental/clashapi/cachefile/fakeip.go → experimental/cachefile/fakeip.go


+ 7 - 4
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)

+ 15 - 51
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
 }

+ 8 - 13
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)

+ 8 - 13
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 {

+ 0 - 31
option/clash.go

@@ -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"`
-}

+ 44 - 3
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"`
 }

+ 15 - 0
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"`
+}

+ 0 - 12
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"`
-}

+ 1 - 1
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:

+ 10 - 5
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)
 			}

+ 1 - 1
route/router.go

@@ -262,7 +262,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()

+ 8 - 7
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()