diff --git a/adapter/experimental.go b/adapter/experimental.go index 66438986..bc7906cf 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -10,10 +10,17 @@ import ( type ClashServer interface { Service Mode() string + StoreSelected() bool + CacheFile() ClashCacheFile 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 { + LoadSelected(group string) string + StoreSelected(group string, selected string) error +} + type Tracker interface { Leave() } diff --git a/experimental/clashapi.go b/experimental/clashapi.go index f5373501..e29f518e 100644 --- a/experimental/clashapi.go +++ b/experimental/clashapi.go @@ -10,5 +10,5 @@ import ( ) func NewClashServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (adapter.ClashServer, error) { - return clashapi.NewServer(router, logFactory, options), nil + return clashapi.NewServer(router, logFactory, options) } diff --git a/experimental/clashapi/cachefile/cache.go b/experimental/clashapi/cachefile/cache.go new file mode 100644 index 00000000..7ca7e4c6 --- /dev/null +++ b/experimental/clashapi/cachefile/cache.go @@ -0,0 +1,61 @@ +package cachefile + +import ( + "os" + "time" + + "github.com/sagernet/sing-box/adapter" + + "go.etcd.io/bbolt" +) + +var bucketSelected = []byte("selected") + +var _ adapter.ClashCacheFile = (*CacheFile)(nil) + +type CacheFile struct { + DB *bbolt.DB +} + +func Open(path string) (*CacheFile, error) { + const fileMode = 0o666 + options := bbolt.Options{Timeout: time.Second} + db, err := bbolt.Open(path, fileMode, &options) + switch err { + case bbolt.ErrInvalid, bbolt.ErrChecksum, bbolt.ErrVersionMismatch: + if err = os.Remove(path); err != nil { + break + } + db, err = bbolt.Open(path, 0o666, &options) + } + if err != nil { + return nil, err + } + return &CacheFile{db}, nil +} + +func (c *CacheFile) LoadSelected(group string) string { + var selected string + c.DB.View(func(t *bbolt.Tx) error { + bucket := t.Bucket(bucketSelected) + if bucket == nil { + return nil + } + selectedBytes := bucket.Get([]byte(group)) + if len(selectedBytes) > 0 { + selected = string(selectedBytes) + } + return nil + }) + return selected +} + +func (c *CacheFile) StoreSelected(group, selected string) error { + return c.DB.Batch(func(t *bbolt.Tx) error { + bucket, err := t.CreateBucketIfNotExists(bucketSelected) + if err != nil { + return err + } + return bucket.Put([]byte(group), []byte(selected)) + }) +} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index ac7cfa0b..397f3572 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -14,6 +14,7 @@ import ( "github.com/sagernet/sing-box/common/json" "github.com/sagernet/sing-box/common/urltest" C "github.com/sagernet/sing-box/constant" + "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" @@ -38,9 +39,11 @@ type Server struct { urlTestHistory *urltest.HistoryStorage tcpListener net.Listener mode string + storeSelected bool + cacheFile adapter.ClashCacheFile } -func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) *Server { +func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) (*Server, error) { trafficManager := trafficontrol.NewManager() chiRouter := chi.NewRouter() server := &Server{ @@ -57,6 +60,17 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options if server.mode == "" { server.mode = "rule" } + if options.StoreSelected { + cachePath := os.ExpandEnv(options.CacheFile) + if cachePath == "" { + cachePath = "cache.db" + } + cacheFile, err := cachefile.Open(cachePath) + if err != nil { + return nil, E.Cause(err, "open cache file") + } + server.cacheFile = cacheFile + } cors := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, @@ -89,7 +103,7 @@ func NewServer(router adapter.Router, logFactory log.ObservableFactory, options }) }) } - return server + return server, nil } func (s *Server) Start() error { @@ -113,9 +127,22 @@ func (s *Server) Close() error { common.PtrOrNil(s.httpServer), s.tcpListener, s.trafficManager, + s.cacheFile, ) } +func (s *Server) Mode() string { + return s.mode +} + +func (s *Server) StoreSelected() bool { + return s.storeSelected +} + +func (s *Server) CacheFile() adapter.ClashCacheFile { + return s.cacheFile +} + func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule) (net.Conn, adapter.Tracker) { tracker := trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule) return tracker, tracker @@ -126,10 +153,6 @@ func (s *Server) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, return tracker, tracker } -func (s *Server) Mode() string { - return s.mode -} - func castMetadata(metadata adapter.InboundContext) trafficontrol.Metadata { var inbound string if metadata.Inbound != "" { diff --git a/go.mod b/go.mod index 1a3779a7..25584487 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/cors v1.2.1 github.com/go-chi/render v1.0.2 - github.com/gofrs/uuid v4.2.0+incompatible + github.com/gofrs/uuid v4.3.0+incompatible github.com/gorilla/websocket v1.5.0 github.com/hashicorp/yamux v0.1.1 github.com/logrusorgru/aurora v2.0.3+incompatible @@ -28,11 +28,12 @@ require ( github.com/sagernet/smux v0.0.0-20220907034654-1acb8471c15a github.com/spf13/cobra v1.5.0 github.com/stretchr/testify v1.8.0 + go.etcd.io/bbolt v1.3.6 go.uber.org/atomic v1.10.0 go4.org/netipx v0.0.0-20220812043211-3cc044ffd68d golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 - golang.org/x/net v0.0.0-20220907135653-1e95f45603a7 - golang.org/x/sys v0.0.0-20220908164124-27713097b956 + golang.org/x/net v0.0.0-20220909164309-bea034e7d591 + golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 golang.zx2c4.com/wireguard v0.0.0-20220904105730-b51010ba13f0 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.28.1 diff --git a/go.sum b/go.sum index d89ad829..d8bbff7d 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg= github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= -github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.0+incompatible h1:CaSVZxm5B+7o45rtab4jC2G37WGYX1zQfuU2i6DSvnc= +github.com/gofrs/uuid v4.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= @@ -163,6 +163,8 @@ github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695AP github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= @@ -212,8 +214,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220907135653-1e95f45603a7 h1:1WGATo9HAhkWMbfyuVU0tEFP88OIkUvwaHFveQPvzCQ= -golang.org/x/net v0.0.0-20220907135653-1e95f45603a7/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -233,6 +235,7 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -244,8 +247,8 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2 h1:wM1k/lXfpc5HdkJJyW9GELpd8ERGdnh8sMGL6Gzq3Ho= +golang.org/x/sys v0.0.0-20220909162455-aba9fc2a8ff2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/option/clash.go b/option/clash.go index 2c059fe9..13b5f7b3 100644 --- a/option/clash.go +++ b/option/clash.go @@ -1,10 +1,13 @@ package option type ClashAPIOptions struct { - DefaultMode string `json:"default_mode,omitempty"` ExternalController string `json:"external_controller,omitempty"` ExternalUI string `json:"external_ui,omitempty"` Secret string `json:"secret,omitempty"` + + DefaultMode string `json:"default_mode,omitempty"` + StoreSelected bool `json:"store_selected,omitempty"` + CacheFile string `json:"cache_file,omitempty"` } type SelectorOutboundOptions struct { diff --git a/outbound/selector.go b/outbound/selector.go index 7ebc5869..84b50aac 100644 --- a/outbound/selector.go +++ b/outbound/selector.go @@ -59,15 +59,30 @@ func (s *Selector) Start() error { } s.outbounds[tag] = detour } + + if s.tag != "" { + if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreSelected() { + selected := clashServer.CacheFile().LoadSelected(s.tag) + if selected != "" { + detour, loaded := s.outbounds[selected] + if loaded { + s.selected = detour + return nil + } + } + } + } + if s.defaultTag != "" { detour, loaded := s.outbounds[s.defaultTag] if !loaded { return E.New("default outbound not found: ", s.defaultTag) } s.selected = detour - } else { - s.selected = s.outbounds[s.tags[0]] + return nil } + + s.selected = s.outbounds[s.tags[0]] return nil } @@ -85,6 +100,14 @@ func (s *Selector) SelectOutbound(tag string) bool { return false } s.selected = detour + if s.tag != "" { + if clashServer := s.router.ClashServer(); clashServer != nil && clashServer.StoreSelected() { + err := clashServer.CacheFile().StoreSelected(s.tag, tag) + if err != nil { + s.logger.Error("store selected: ", err) + } + } + } return true }