Browse Source

Add `store_mode` and platform Clash mode selector

世界 2 years ago
parent
commit
43f72a6419

+ 3 - 0
adapter/experimental.go

@@ -12,6 +12,7 @@ type ClashServer interface {
 	Service
 	PreStarter
 	Mode() string
+	ModeList() []string
 	StoreSelected() bool
 	StoreFakeIP() bool
 	CacheFile() ClashCacheFile
@@ -21,6 +22,8 @@ type ClashServer interface {
 }
 
 type ClashCacheFile interface {
+	LoadMode() string
+	StoreMode(mode string) error
 	LoadSelected(group string) string
 	StoreSelected(group string, selected string) error
 	LoadGroupExpand(group string) (isExpand bool, loaded bool)

+ 1 - 0
adapter/router.go

@@ -32,6 +32,7 @@ type Router interface {
 	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)
+	ClearDNSCache()
 
 	InterfaceFinder() control.InterfaceFinder
 	UpdateInterfaces() error

+ 3 - 1
box.go

@@ -145,7 +145,9 @@ func New(options Options) (*Box, error) {
 	preServices := make(map[string]adapter.Service)
 	postServices := make(map[string]adapter.Service)
 	if needClashAPI {
-		clashServer, err := experimental.NewClashServer(ctx, router, logFactory.(log.ObservableFactory), common.PtrValueOrDefault(experimentalOptions.ClashAPI))
+		clashAPIOptions := common.PtrValueOrDefault(experimentalOptions.ClashAPI)
+		clashAPIOptions.ModeList = experimental.CalculateClashModeList(options.Options)
+		clashServer, err := experimental.NewClashServer(ctx, router, logFactory.(log.ObservableFactory), clashAPIOptions)
 		if err != nil {
 			return nil, E.Cause(err, "create clash api server")
 		}

+ 14 - 16
common/urltest/urltest.go

@@ -10,7 +10,6 @@ import (
 
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
-	"github.com/sagernet/sing/common/x/list"
 )
 
 type History struct {
@@ -21,7 +20,7 @@ type History struct {
 type HistoryStorage struct {
 	access       sync.RWMutex
 	delayHistory map[string]*History
-	callbacks    list.List[func()]
+	updateHook   chan<- struct{}
 }
 
 func NewHistoryStorage() *HistoryStorage {
@@ -30,16 +29,8 @@ func NewHistoryStorage() *HistoryStorage {
 	}
 }
 
-func (s *HistoryStorage) AddListener(listener func()) *list.Element[func()] {
-	s.access.Lock()
-	defer s.access.Unlock()
-	return s.callbacks.PushBack(listener)
-}
-
-func (s *HistoryStorage) RemoveListener(element *list.Element[func()]) {
-	s.access.Lock()
-	defer s.access.Unlock()
-	s.callbacks.Remove(element)
+func (s *HistoryStorage) SetHook(hook chan<- struct{}) {
+	s.updateHook = hook
 }
 
 func (s *HistoryStorage) LoadURLTestHistory(tag string) *History {
@@ -66,13 +57,20 @@ func (s *HistoryStorage) StoreURLTestHistory(tag string, history *History) {
 }
 
 func (s *HistoryStorage) notifyUpdated() {
-	s.access.RLock()
-	defer s.access.RUnlock()
-	for element := s.callbacks.Front(); element != nil; element = element.Next() {
-		element.Value()
+	updateHook := s.updateHook
+	if updateHook != nil {
+		select {
+		case updateHook <- struct{}{}:
+		default:
+		}
 	}
 }
 
+func (s *HistoryStorage) Close() error {
+	s.updateHook = nil
+	return nil
+}
+
 func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) {
 	if link == "" {
 		link = "https://www.gstatic.com/generate_204"

+ 10 - 0
docs/configuration/experimental/index.md

@@ -12,7 +12,9 @@
       "external_ui_download_detour": "",
       "secret": "",
       "default_mode": "",
+      "store_mode": false,
       "store_selected": false,
+      "store_fakeip": false,
       "cache_file": "",
       "cache_id": ""
     },
@@ -80,6 +82,10 @@ 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 ""
@@ -88,6 +94,10 @@ This setting has no direct effect, but can be used in routing and DNS rules via
 
 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.

+ 10 - 0
docs/configuration/experimental/index.zh.md

@@ -12,7 +12,9 @@
       "external_ui_download_detour": "",
       "secret": "",
       "default_mode": "",
+      "store_mode": false,
       "store_selected": false,
+      "store_fakeip": false,
       "cache_file": "",
       "cache_id": ""
     },
@@ -78,6 +80,10 @@ Clash 中的默认模式,默认使用 `rule`。
 
 此设置没有直接影响,但可以通过 `clash_mode` 规则项在路由和 DNS 规则中使用。
 
+#### store_mode
+
+将 Clash 模式存储在缓存文件中。
+
 #### store_selected
 
 !!! note ""
@@ -86,6 +92,10 @@ Clash 中的默认模式,默认使用 `rule`。
 
 将 `Selector` 中出站的选定的目标出站存储在缓存文件中。
 
+#### store_fakeip
+
+将 fakeip 存储在缓存文件中。
+
 #### cache_file
 
 缓存文件路径,默认使用`cache.db`。

+ 26 - 0
experimental/clashapi.go

@@ -7,6 +7,7 @@ import (
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
 )
 
 type ClashServerConstructor = func(ctx context.Context, router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error)
@@ -23,3 +24,28 @@ func NewClashServer(ctx context.Context, router adapter.Router, logFactory log.O
 	}
 	return clashServerConstructor(ctx, router, logFactory, options)
 }
+
+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, rule.DefaultOptions.ClashMode)
+		}
+		for _, defaultRule := range rule.LogicalOptions.Rules {
+			if defaultRule.ClashMode != "" && !common.Contains(clashMode, defaultRule.ClashMode) {
+				clashMode = append(clashMode, defaultRule.ClashMode)
+			}
+		}
+	}
+	return clashMode
+}

+ 45 - 2
experimental/clashapi/cachefile/cache.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing/common"
 
 	"go.etcd.io/bbolt"
 )
@@ -15,6 +16,15 @@ import (
 var (
 	bucketSelected = []byte("selected")
 	bucketExpand   = []byte("group_expand")
+	bucketMode     = []byte("clash_mode")
+
+	bucketNameList = []string{
+		string(bucketSelected),
+		string(bucketExpand),
+		string(bucketMode),
+	}
+
+	cacheIDDefault = []byte("default")
 )
 
 var _ adapter.ClashCacheFile = (*CacheFile)(nil)
@@ -52,14 +62,14 @@ func Open(path string, cacheID string) (*CacheFile, error) {
 			if name[0] == 0 {
 				return b.ForEachBucket(func(k []byte) error {
 					bucketName := string(k)
-					if !(bucketName == string(bucketSelected) || bucketName == string(bucketExpand)) {
+					if !(common.Contains(bucketNameList, bucketName)) {
 						_ = b.DeleteBucket(name)
 					}
 					return nil
 				})
 			} else {
 				bucketName := string(name)
-				if !(bucketName == string(bucketSelected) || bucketName == string(bucketExpand) || strings.HasPrefix(bucketName, fakeipBucketPrefix)) {
+				if !(common.Contains(bucketNameList, bucketName) || strings.HasPrefix(bucketName, fakeipBucketPrefix)) {
 					_ = tx.DeleteBucket(name)
 				}
 			}
@@ -78,6 +88,39 @@ func Open(path string, cacheID string) (*CacheFile, error) {
 	}, nil
 }
 
+func (c *CacheFile) LoadMode() string {
+	var mode string
+	c.DB.View(func(t *bbolt.Tx) error {
+		bucket := t.Bucket(bucketMode)
+		if bucket == nil {
+			return nil
+		}
+		var modeBytes []byte
+		if len(c.cacheID) > 0 {
+			modeBytes = bucket.Get(c.cacheID)
+		} else {
+			modeBytes = bucket.Get(cacheIDDefault)
+		}
+		mode = string(modeBytes)
+		return nil
+	})
+	return mode
+}
+
+func (c *CacheFile) StoreMode(mode string) error {
+	return c.DB.Batch(func(t *bbolt.Tx) error {
+		bucket, err := t.CreateBucketIfNotExists(bucketMode)
+		if err != nil {
+			return err
+		}
+		if len(c.cacheID) > 0 {
+			return bucket.Put(c.cacheID, []byte(mode))
+		} else {
+			return bucket.Put(cacheIDDefault, []byte(mode))
+		}
+	})
+}
+
 func (c *CacheFile) bucket(t *bbolt.Tx, key []byte) *bbolt.Bucket {
 	if c.cacheID == nil {
 		return t.Bucket(key)

+ 4 - 9
experimental/clashapi/configs.go

@@ -2,7 +2,6 @@ package clashapi
 
 import (
 	"net/http"
-	"strings"
 
 	"github.com/sagernet/sing-box/log"
 
@@ -10,11 +9,11 @@ import (
 	"github.com/go-chi/render"
 )
 
-func configRouter(server *Server, logFactory log.Factory, logger log.Logger) http.Handler {
+func configRouter(server *Server, logFactory log.Factory) http.Handler {
 	r := chi.NewRouter()
 	r.Get("/", getConfigs(server, logFactory))
 	r.Put("/", updateConfigs)
-	r.Patch("/", patchConfigs(server, logger))
+	r.Patch("/", patchConfigs(server))
 	return r
 }
 
@@ -48,7 +47,7 @@ func getConfigs(server *Server, logFactory log.Factory) func(w http.ResponseWrit
 	}
 }
 
-func patchConfigs(server *Server, logger log.Logger) func(w http.ResponseWriter, r *http.Request) {
+func patchConfigs(server *Server) func(w http.ResponseWriter, r *http.Request) {
 	return func(w http.ResponseWriter, r *http.Request) {
 		var newConfig configSchema
 		err := render.DecodeJSON(r.Body, &newConfig)
@@ -58,11 +57,7 @@ func patchConfigs(server *Server, logger log.Logger) func(w http.ResponseWriter,
 			return
 		}
 		if newConfig.Mode != "" {
-			mode := strings.ToLower(newConfig.Mode)
-			if server.mode != mode {
-				server.mode = mode
-				logger.Info("updated mode: ", mode)
-			}
+			server.SetMode(newConfig.Mode)
 		}
 		render.NoContent(w, r)
 	}

+ 61 - 6
experimental/clashapi/server.go

@@ -46,6 +46,9 @@ type Server struct {
 	trafficManager *trafficontrol.Manager
 	urlTestHistory *urltest.HistoryStorage
 	mode           string
+	modeList       []string
+	modeUpdateHook chan<- struct{}
+	storeMode      bool
 	storeSelected  bool
 	storeFakeIP    bool
 	cacheFilePath  string
@@ -70,9 +73,10 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ
 			Handler: chiRouter,
 		},
 		trafficManager:           trafficManager,
-		mode:                     strings.ToLower(options.DefaultMode),
-		storeSelected:            options.StoreSelected,
+		modeList:                 options.ModeList,
 		externalController:       options.ExternalController != "",
+		storeMode:                options.StoreMode,
+		storeSelected:            options.StoreSelected,
 		storeFakeIP:              options.StoreFakeIP,
 		externalUIDownloadURL:    options.ExternalUIDownloadURL,
 		externalUIDownloadDetour: options.ExternalUIDownloadDetour,
@@ -81,10 +85,15 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ
 	if server.urlTestHistory == nil {
 		server.urlTestHistory = urltest.NewHistoryStorage()
 	}
-	if server.mode == "" {
-		server.mode = "rule"
+	defaultMode := "Rule"
+	if options.DefaultMode != "" {
+		defaultMode = options.DefaultMode
+	}
+	if !common.Contains(server.modeList, defaultMode) {
+		server.modeList = append(server.modeList, defaultMode)
 	}
-	if options.StoreSelected || options.StoreFakeIP || options.ExternalController == "" {
+	server.mode = defaultMode
+	if options.StoreMode || options.StoreSelected || options.StoreFakeIP || options.ExternalController == "" {
 		cachePath := os.ExpandEnv(options.CacheFile)
 		if cachePath == "" {
 			cachePath = "cache.db"
@@ -110,7 +119,7 @@ func NewServer(ctx context.Context, router adapter.Router, logFactory log.Observ
 		r.Get("/logs", getLogs(logFactory))
 		r.Get("/traffic", traffic(trafficManager))
 		r.Get("/version", version)
-		r.Mount("/configs", configRouter(server, logFactory, server.logger))
+		r.Mount("/configs", configRouter(server, logFactory))
 		r.Mount("/proxies", proxyRouter(server, router))
 		r.Mount("/rules", ruleRouter(router))
 		r.Mount("/connections", connectionRouter(router, trafficManager))
@@ -143,6 +152,14 @@ func (s *Server) PreStart() error {
 			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
+			}
+		}
 	}
 	return nil
 }
@@ -170,6 +187,7 @@ func (s *Server) Close() error {
 		common.PtrOrNil(s.httpServer),
 		s.trafficManager,
 		s.cacheFile,
+		s.urlTestHistory,
 	)
 }
 
@@ -177,6 +195,43 @@ func (s *Server) Mode() string {
 	return s.mode
 }
 
+func (s *Server) ModeList() []string {
+	return s.modeList
+}
+
+func (s *Server) SetModeUpdateHook(hook chan<- struct{}) {
+	s.modeUpdateHook = hook
+}
+
+func (s *Server) SetMode(newMode string) {
+	if !common.Contains(s.modeList, newMode) {
+		newMode = common.Find(s.modeList, func(it string) bool {
+			return strings.EqualFold(it, newMode)
+		})
+	}
+	if !common.Contains(s.modeList, newMode) {
+		return
+	}
+	if newMode == s.mode {
+		return
+	}
+	s.mode = newMode
+	if s.modeUpdateHook != nil {
+		select {
+		case s.modeUpdateHook <- struct{}{}:
+		default:
+		}
+	}
+	s.router.ClearDNSCache()
+	if s.storeMode {
+		err := s.cacheFile.StoreMode(newMode)
+		if err != nil {
+			s.logger.Error(E.Cause(err, "save mode"))
+		}
+	}
+	s.logger.Info("updated mode: ", newMode)
+}
+
 func (s *Server) StoreSelected() bool {
 	return s.storeSelected
 }

+ 2 - 0
experimental/libbox/command.go

@@ -9,4 +9,6 @@ const (
 	CommandSelectOutbound
 	CommandURLTest
 	CommandGroupExpand
+	CommandClashMode
+	CommandSetClashMode
 )

+ 135 - 0
experimental/libbox/command_clash_mode.go

@@ -0,0 +1,135 @@
+package libbox
+
+import (
+	"encoding/binary"
+	"io"
+	"net"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental/clashapi"
+	E "github.com/sagernet/sing/common/exceptions"
+	"github.com/sagernet/sing/common/rw"
+)
+
+func (c *CommandClient) SetClashMode(newMode string) error {
+	conn, err := c.directConnect()
+	if err != nil {
+		return err
+	}
+	defer conn.Close()
+	err = binary.Write(conn, binary.BigEndian, uint8(CommandSetClashMode))
+	if err != nil {
+		return err
+	}
+	err = rw.WriteVString(conn, newMode)
+	if err != nil {
+		return err
+	}
+	return readError(conn)
+}
+
+func (s *CommandServer) handleSetClashMode(conn net.Conn) error {
+	defer conn.Close()
+	newMode, err := rw.ReadVString(conn)
+	if err != nil {
+		return err
+	}
+	service := s.service
+	if service == nil {
+		return writeError(conn, E.New("service not ready"))
+	}
+	clashServer := service.instance.Router().ClashServer()
+	if clashServer == nil {
+		return writeError(conn, E.New("Clash API disabled"))
+	}
+	clashServer.(*clashapi.Server).SetMode(newMode)
+	return writeError(conn, nil)
+}
+
+func (c *CommandClient) handleModeConn(conn net.Conn) {
+	defer conn.Close()
+
+	for {
+		newMode, err := rw.ReadVString(conn)
+		if err != nil {
+			c.handler.Disconnected(err.Error())
+			return
+		}
+		c.handler.UpdateClashMode(newMode)
+	}
+}
+
+func (s *CommandServer) handleModeConn(conn net.Conn) error {
+	defer conn.Close()
+	ctx := connKeepAlive(conn)
+	for s.service == nil {
+		select {
+		case <-time.After(time.Second):
+			continue
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+	clashServer := s.service.instance.Router().ClashServer()
+	if clashServer == nil {
+		defer conn.Close()
+		return binary.Write(conn, binary.BigEndian, uint16(0))
+	}
+	err := writeClashModeList(conn, clashServer)
+	if err != nil {
+		return err
+	}
+	for {
+		select {
+		case <-s.modeUpdate:
+			err = rw.WriteVString(conn, clashServer.Mode())
+			if err != nil {
+				return err
+			}
+		case <-ctx.Done():
+			return ctx.Err()
+		}
+	}
+}
+
+func readClashModeList(reader io.Reader) (modeList []string, currentMode string, err error) {
+	var modeListLength uint16
+	err = binary.Read(reader, binary.BigEndian, &modeListLength)
+	if err != nil {
+		return
+	}
+	if modeListLength == 0 {
+		return
+	}
+	modeList = make([]string, modeListLength)
+	for i := 0; i < int(modeListLength); i++ {
+		modeList[i], err = rw.ReadVString(reader)
+		if err != nil {
+			return
+		}
+	}
+	currentMode, err = rw.ReadVString(reader)
+	return
+}
+
+func writeClashModeList(writer io.Writer, clashServer adapter.ClashServer) error {
+	modeList := clashServer.ModeList()
+	err := binary.Write(writer, binary.BigEndian, uint16(len(modeList)))
+	if err != nil {
+		return err
+	}
+	if len(modeList) > 0 {
+		for _, mode := range modeList {
+			err = rw.WriteVString(writer, mode)
+			if err != nil {
+				return err
+			}
+		}
+		err = rw.WriteVString(writer, clashServer.Mode())
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 20 - 0
experimental/libbox/command_client.go

@@ -3,6 +3,7 @@ package libbox
 import (
 	"encoding/binary"
 	"net"
+	"os"
 	"path/filepath"
 
 	"github.com/sagernet/sing/common"
@@ -26,6 +27,8 @@ type CommandClientHandler interface {
 	WriteLog(message string)
 	WriteStatus(message *StatusMessage)
 	WriteGroups(message OutboundGroupIterator)
+	InitializeClashMode(modeList StringIterator, currentMode string)
+	UpdateClashMode(newMode string)
 }
 
 func NewStandaloneCommandClient() *CommandClient {
@@ -79,6 +82,23 @@ func (c *CommandClient) Connect() error {
 		}
 		c.handler.Connected()
 		go c.handleGroupConn(conn)
+	case CommandClashMode:
+		var (
+			modeList    []string
+			currentMode string
+		)
+		modeList, currentMode, err = readClashModeList(conn)
+		if err != nil {
+			return err
+		}
+		c.handler.Connected()
+		c.handler.InitializeClashMode(newIterator(modeList), currentMode)
+		if len(modeList) == 0 {
+			conn.Close()
+			c.handler.Disconnected(os.ErrInvalid.Error())
+			return nil
+		}
+		go c.handleModeConn(conn)
 	}
 	return nil
 }

+ 3 - 0
experimental/libbox/command_group.go

@@ -199,6 +199,9 @@ func writeGroups(writer io.Writer, boxService *BoxService) error {
 			}
 			group.items = append(group.items, &item)
 		}
+		if len(group.items) < 2 {
+			continue
+		}
 		groups = append(groups, group)
 	}
 

+ 11 - 8
experimental/libbox/command_server.go

@@ -8,6 +8,7 @@ import (
 	"sync"
 
 	"github.com/sagernet/sing-box/common/urltest"
+	"github.com/sagernet/sing-box/experimental/clashapi"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/debug"
@@ -28,8 +29,8 @@ type CommandServer struct {
 	observer   *observable.Observer[string]
 	service    *BoxService
 
-	urlTestListener *list.Element[func()]
-	urlTestUpdate   chan struct{}
+	urlTestUpdate chan struct{}
+	modeUpdate    chan struct{}
 }
 
 type CommandServerHandler interface {
@@ -43,20 +44,18 @@ func NewCommandServer(handler CommandServerHandler, maxLines int32) *CommandServ
 		maxLines:      int(maxLines),
 		subscriber:    observable.NewSubscriber[string](128),
 		urlTestUpdate: make(chan struct{}, 1),
+		modeUpdate:    make(chan struct{}, 1),
 	}
 	server.observer = observable.NewObserver[string](server.subscriber, 64)
 	return server
 }
 
 func (s *CommandServer) SetService(newService *BoxService) {
-	if s.service != nil && s.listener != nil {
-		service.PtrFromContext[urltest.HistoryStorage](s.service.ctx).RemoveListener(s.urlTestListener)
-		s.urlTestListener = nil
-	}
-	s.service = newService
 	if newService != nil {
-		s.urlTestListener = service.PtrFromContext[urltest.HistoryStorage](newService.ctx).AddListener(s.notifyURLTestUpdate)
+		service.PtrFromContext[urltest.HistoryStorage](newService.ctx).SetHook(s.urlTestUpdate)
+		newService.instance.Router().ClashServer().(*clashapi.Server).SetModeUpdateHook(s.modeUpdate)
 	}
+	s.service = newService
 	s.notifyURLTestUpdate()
 }
 
@@ -156,6 +155,10 @@ func (s *CommandServer) handleConnection(conn net.Conn) error {
 		return s.handleURLTest(conn)
 	case CommandGroupExpand:
 		return s.handleSetGroupExpand(conn)
+	case CommandClashMode:
+		return s.handleModeConn(conn)
+	case CommandSetClashMode:
+		return s.handleSetClashMode(conn)
 	default:
 		return E.New("unknown command: ", command)
 	}

+ 3 - 0
experimental/libbox/dns.go

@@ -49,6 +49,9 @@ func (p *platformLocalDNSTransport) Start() error {
 	return nil
 }
 
+func (p *platformLocalDNSTransport) Reset() {
+}
+
 func (p *platformLocalDNSTransport) Close() error {
 	return nil
 }

+ 1 - 0
experimental/libbox/platform.go

@@ -19,6 +19,7 @@ type PlatformInterface interface {
 	UsePlatformInterfaceGetter() bool
 	GetInterfaces() (NetworkInterfaceIterator, error)
 	UnderNetworkExtension() bool
+	ClearDNSCache()
 }
 
 type TunInterface interface {

+ 1 - 0
experimental/libbox/platform/interface.go

@@ -23,6 +23,7 @@ type Interface interface {
 	UsePlatformInterfaceGetter() bool
 	Interfaces() ([]NetworkInterface, error)
 	UnderNetworkExtension() bool
+	ClearDNSCache()
 	process.Searcher
 	io.Writer
 }

+ 19 - 11
experimental/libbox/service.go

@@ -25,10 +25,11 @@ import (
 )
 
 type BoxService struct {
-	ctx          context.Context
-	cancel       context.CancelFunc
-	instance     *box.Box
-	pauseManager pause.Manager
+	ctx                   context.Context
+	cancel                context.CancelFunc
+	instance              *box.Box
+	pauseManager          pause.Manager
+	urlTestHistoryStorage *urltest.HistoryStorage
 }
 
 func NewService(configContent string, platformInterface PlatformInterface) (*BoxService, error) {
@@ -39,9 +40,10 @@ func NewService(configContent string, platformInterface PlatformInterface) (*Box
 	runtimeDebug.FreeOSMemory()
 	ctx, cancel := context.WithCancel(context.Background())
 	ctx = filemanager.WithDefault(ctx, sWorkingPath, sTempPath, sUserID, sGroupID)
-	ctx = service.ContextWithPtr(ctx, urltest.NewHistoryStorage())
-	sleepManager := pause.NewDefaultManager(ctx)
-	ctx = pause.ContextWithManager(ctx, sleepManager)
+	urlTestHistoryStorage := urltest.NewHistoryStorage()
+	ctx = service.ContextWithPtr(ctx, urlTestHistoryStorage)
+	pauseManager := pause.NewDefaultManager(ctx)
+	ctx = pause.ContextWithManager(ctx, pauseManager)
 	instance, err := box.New(box.Options{
 		Context:           ctx,
 		Options:           options,
@@ -53,10 +55,11 @@ func NewService(configContent string, platformInterface PlatformInterface) (*Box
 	}
 	runtimeDebug.FreeOSMemory()
 	return &BoxService{
-		ctx:          ctx,
-		cancel:       cancel,
-		instance:     instance,
-		pauseManager: sleepManager,
+		ctx:                   ctx,
+		cancel:                cancel,
+		instance:              instance,
+		urlTestHistoryStorage: urlTestHistoryStorage,
+		pauseManager:          pauseManager,
 	}, nil
 }
 
@@ -66,6 +69,7 @@ func (s *BoxService) Start() error {
 
 func (s *BoxService) Close() error {
 	s.cancel()
+	s.urlTestHistoryStorage.Close()
 	return s.instance.Close()
 }
 
@@ -194,3 +198,7 @@ func (w *platformInterfaceWrapper) Interfaces() ([]platform.NetworkInterface, er
 func (w *platformInterfaceWrapper) UnderNetworkExtension() bool {
 	return w.iif.UnderNetworkExtension()
 }
+
+func (w *platformInterfaceWrapper) ClearDNSCache() {
+	w.iif.ClearDNSCache()
+}

+ 2 - 2
go.mod

@@ -25,8 +25,8 @@ require (
 	github.com/sagernet/gvisor v0.0.0-20230627031050-1ab0276e0dd2
 	github.com/sagernet/quic-go v0.0.0-20230824033040-30ef72e3be3e
 	github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691
-	github.com/sagernet/sing v0.2.10-0.20230821073500-620f3a3b882d
-	github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659
+	github.com/sagernet/sing v0.2.10-0.20230824115837-8d731e68853a
+	github.com/sagernet/sing-dns v0.1.9-0.20230824120133-4d5cbceb40c1
 	github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c
 	github.com/sagernet/sing-shadowsocks v0.2.4
 	github.com/sagernet/sing-shadowsocks2 v0.1.3

+ 4 - 4
go.sum

@@ -113,10 +113,10 @@ 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.10-0.20230821073500-620f3a3b882d h1:4kgoOCE48CuQcBUcoRnE0QTPXkl8yM8i7Nipmzp/978=
-github.com/sagernet/sing v0.2.10-0.20230821073500-620f3a3b882d/go.mod h1:9uOZwWkhT2Z2WldolLxX34s+1svAX4i4vvz5hy8u1MA=
-github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659 h1:1DAKccGNqTYJ8nsBR765FS0LVBVXfuFlFAHqKsGN3EI=
-github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659/go.mod h1:W7GHTZFS8RkoLI3bA2LFY27/0E+uoQESWtMFLepO/JA=
+github.com/sagernet/sing v0.2.10-0.20230824115837-8d731e68853a h1:eV4HEz9NP7eAlQ/IHD6OF2VVM6ke4Vw6htuSAsvgtDk=
+github.com/sagernet/sing v0.2.10-0.20230824115837-8d731e68853a/go.mod h1:9uOZwWkhT2Z2WldolLxX34s+1svAX4i4vvz5hy8u1MA=
+github.com/sagernet/sing-dns v0.1.9-0.20230824120133-4d5cbceb40c1 h1:5w+jXz8y/8UQAxO74TjftN5okYkpg5mGvVxXunlKdqI=
+github.com/sagernet/sing-dns v0.1.9-0.20230824120133-4d5cbceb40c1/go.mod h1:Kg98PBJEg/08jsNFtmZWmPomhskn9Ausn50ecNm4M+8=
 github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c h1:35/FowAvt3Z62mck0TXzVc4jS5R5CWq62qcV2P1cp0I=
 github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c/go.mod h1:TKxqIvfQQgd36jp2tzsPavGjYTVZilV+atip1cssjIY=
 github.com/sagernet/sing-shadowsocks v0.2.4 h1:s/CqXlvFAZhlIoHWUwPw5CoNnQ9Ibki9pckjuugtVfY=

+ 3 - 0
option/clash.go

@@ -7,10 +7,13 @@ type ClashAPIOptions struct {
 	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 {

+ 5 - 8
route/router.go

@@ -1005,14 +1005,7 @@ func (r *Router) notifyNetworkUpdate(event int) {
 		}
 	}
 
-	conntrack.Close()
-
-	for _, outbound := range r.outbounds {
-		listener, isListener := outbound.(adapter.InterfaceUpdateListener)
-		if isListener {
-			listener.InterfaceUpdated()
-		}
-	}
+	r.ResetNetwork()
 	return
 }
 
@@ -1025,5 +1018,9 @@ func (r *Router) ResetNetwork() error {
 			listener.InterfaceUpdated()
 		}
 	}
+
+	for _, transport := range r.transports {
+		transport.Reset()
+	}
 	return nil
 }

+ 7 - 0
route/router_dns.go

@@ -146,6 +146,13 @@ func (r *Router) LookupDefault(ctx context.Context, domain string) ([]netip.Addr
 	return r.Lookup(ctx, domain, dns.DomainStrategyAsIS)
 }
 
+func (r *Router) ClearDNSCache() {
+	r.dnsClient.ClearCache()
+	if r.platformInterface != nil {
+		r.platformInterface.ClearDNSCache()
+	}
+}
+
 func LogDNSAnswers(logger log.ContextLogger, ctx context.Context, domain string, answers []mDNS.RR) {
 	for _, answer := range answers {
 		logger.InfoContext(ctx, "exchanged ", domain, " ", mDNS.Type(answer.Header().Rrtype).String(), " ", formatQuestion(answer.String()))

+ 2 - 2
route/rule_item_clash_mode.go

@@ -16,7 +16,7 @@ type ClashModeItem struct {
 func NewClashModeItem(router adapter.Router, mode string) *ClashModeItem {
 	return &ClashModeItem{
 		router: router,
-		mode:   strings.ToLower(mode),
+		mode:   mode,
 	}
 }
 
@@ -25,7 +25,7 @@ func (r *ClashModeItem) Match(metadata *adapter.InboundContext) bool {
 	if clashServer == nil {
 		return false
 	}
-	return clashServer.Mode() == r.mode
+	return strings.EqualFold(clashServer.Mode(), r.mode)
 }
 
 func (r *ClashModeItem) String() string {

+ 2 - 2
test/go.mod

@@ -10,7 +10,7 @@ require (
 	github.com/docker/docker v24.0.5+incompatible
 	github.com/docker/go-connections v0.4.0
 	github.com/gofrs/uuid/v5 v5.0.0
-	github.com/sagernet/sing v0.2.10-0.20230821073500-620f3a3b882d
+	github.com/sagernet/sing v0.2.10-0.20230824115837-8d731e68853a
 	github.com/sagernet/sing-shadowsocks v0.2.4
 	github.com/sagernet/sing-shadowsocks2 v0.1.3
 	github.com/spyzhov/ajson v0.9.0
@@ -72,7 +72,7 @@ require (
 	github.com/sagernet/netlink v0.0.0-20220905062125-8043b4a9aa97 // indirect
 	github.com/sagernet/quic-go v0.0.0-20230824033040-30ef72e3be3e // indirect
 	github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 // indirect
-	github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659 // indirect
+	github.com/sagernet/sing-dns v0.1.9-0.20230824120133-4d5cbceb40c1 // indirect
 	github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c // indirect
 	github.com/sagernet/sing-shadowtls v0.1.4 // indirect
 	github.com/sagernet/sing-tun v0.1.12-0.20230821065522-7545dc2d5641 // indirect

+ 2 - 0
test/go.sum

@@ -131,8 +131,10 @@ github.com/sagernet/sing v0.0.0-20220817130738-ce854cda8522/go.mod h1:QVsS5L/ZA2
 github.com/sagernet/sing v0.1.8/go.mod h1:jt1w2u7lJQFFSGLiRrRIs5YWmx4kAPfWuOejuDW9qMk=
 github.com/sagernet/sing v0.2.10-0.20230821073500-620f3a3b882d h1:4kgoOCE48CuQcBUcoRnE0QTPXkl8yM8i7Nipmzp/978=
 github.com/sagernet/sing v0.2.10-0.20230821073500-620f3a3b882d/go.mod h1:9uOZwWkhT2Z2WldolLxX34s+1svAX4i4vvz5hy8u1MA=
+github.com/sagernet/sing v0.2.10-0.20230824115837-8d731e68853a/go.mod h1:9uOZwWkhT2Z2WldolLxX34s+1svAX4i4vvz5hy8u1MA=
 github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659 h1:1DAKccGNqTYJ8nsBR765FS0LVBVXfuFlFAHqKsGN3EI=
 github.com/sagernet/sing-dns v0.1.9-0.20230731012726-ad50da89b659/go.mod h1:W7GHTZFS8RkoLI3bA2LFY27/0E+uoQESWtMFLepO/JA=
+github.com/sagernet/sing-dns v0.1.9-0.20230824120133-4d5cbceb40c1/go.mod h1:Kg98PBJEg/08jsNFtmZWmPomhskn9Ausn50ecNm4M+8=
 github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c h1:35/FowAvt3Z62mck0TXzVc4jS5R5CWq62qcV2P1cp0I=
 github.com/sagernet/sing-mux v0.1.3-0.20230811111955-dc1639b5204c/go.mod h1:TKxqIvfQQgd36jp2tzsPavGjYTVZilV+atip1cssjIY=
 github.com/sagernet/sing-shadowsocks v0.2.4 h1:s/CqXlvFAZhlIoHWUwPw5CoNnQ9Ibki9pckjuugtVfY=

+ 3 - 0
transport/dhcp/server.go

@@ -85,6 +85,9 @@ func (t *Transport) Start() error {
 	return nil
 }
 
+func (t *Transport) Reset() {
+}
+
 func (t *Transport) Close() error {
 	if t.interfaceCallback != nil {
 		t.router.InterfaceMonitor().UnregisterCallback(t.interfaceCallback)

+ 3 - 0
transport/fakeip/server.go

@@ -54,6 +54,9 @@ func (s *Transport) Start() error {
 	return nil
 }
 
+func (s *Transport) Reset() {
+}
+
 func (s *Transport) Close() error {
 	return nil
 }