diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 57e2bff9..469d5607 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,6 +17,7 @@ builds: - with_wireguard - with_utls - with_clash_api + - with_ssm_api env: - CGO_ENABLED=0 targets: @@ -50,6 +51,7 @@ builds: - with_wireguard - with_utls - with_clash_api + - with_ssm_api env: - CGO_ENABLED=1 overrides: diff --git a/Makefile b/Makefile index 19a53276..6e9ee879 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = sing-box COMMIT = $(shell git rev-parse --short HEAD) -TAGS ?= with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api +TAGS ?= with_gvisor,with_quic,with_wireguard,with_utls,with_clash_api,with_ssm_api TAGS_TEST ?= with_gvisor,with_quic,with_wireguard,with_grpc,with_ech,with_utls,with_shadowsocksr PARAMS = -v -trimpath -tags "$(TAGS)" -ldflags "-s -w -buildid=" MAIN = ./cmd/sing-box diff --git a/adapter/experimental.go b/adapter/experimental.go index e223aa50..abd44091 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -48,3 +48,16 @@ type V2RayStatsService interface { RoutedConnection(inbound string, outbound string, user string, conn net.Conn) net.Conn RoutedPacketConnection(inbound string, outbound string, user string, conn N.PacketConn) N.PacketConn } + +type SSMServer interface { + Service + RoutedConnection(metadata InboundContext, conn net.Conn) net.Conn + RoutedPacketConnection(metadata InboundContext, conn N.PacketConn) N.PacketConn +} + +type ManagedShadowsocksServer interface { + Inbound + Method() string + Password() string + UpdateUsers(users []string, uPSKs []string) error +} diff --git a/adapter/router.go b/adapter/router.go index 7a07c852..41b4f8b0 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -17,6 +17,7 @@ import ( type Router interface { Service + Inbound(tag string) (Inbound, bool) Outbounds() []Outbound Outbound(tag string) (Outbound, bool) DefaultOutbound(network string) Outbound @@ -45,6 +46,9 @@ type Router interface { V2RayServer() V2RayServer SetV2RayServer(server V2RayServer) + + SSMServer() SSMServer + SetSSMServer(server SSMServer) } type routerContextKey struct{} diff --git a/box.go b/box.go index 01bb7701..3c4d7da8 100644 --- a/box.go +++ b/box.go @@ -32,6 +32,7 @@ type Box struct { logFile *os.File clashServer adapter.ClashServer v2rayServer adapter.V2RayServer + ssmServer adapter.SSMServer done chan struct{} } @@ -41,6 +42,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) { var needClashAPI bool var needV2RayAPI bool + var needSSMAPI bool if options.Experimental != nil { if options.Experimental.ClashAPI != nil && options.Experimental.ClashAPI.ExternalController != "" { needClashAPI = true @@ -48,6 +50,9 @@ func New(ctx context.Context, options option.Options) (*Box, error) { if options.Experimental.V2RayAPI != nil && options.Experimental.V2RayAPI.Listen != "" { needV2RayAPI = true } + if options.Experimental.SSMAPI != nil && options.Experimental.SSMAPI.Listen != "" { + needSSMAPI = true + } } var logFactory log.Factory @@ -156,6 +161,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) { var clashServer adapter.ClashServer var v2rayServer adapter.V2RayServer + var ssmServer adapter.SSMServer if needClashAPI { clashServer, err = experimental.NewClashServer(router, observableLogFactory, common.PtrValueOrDefault(options.Experimental.ClashAPI)) if err != nil { @@ -170,6 +176,13 @@ func New(ctx context.Context, options option.Options) (*Box, error) { } router.SetV2RayServer(v2rayServer) } + if needSSMAPI { + ssmServer, err = experimental.NewSSMServer(router, logFactory.NewLogger("ssm-api"), common.PtrValueOrDefault(options.Experimental.SSMAPI)) + if err != nil { + return nil, E.Cause(err, "create ssm api server") + } + router.SetSSMServer(ssmServer) + } return &Box{ router: router, inbounds: inbounds, @@ -180,6 +193,7 @@ func New(ctx context.Context, options option.Options) (*Box, error) { logFile: logFile, clashServer: clashServer, v2rayServer: v2rayServer, + ssmServer: ssmServer, done: make(chan struct{}), }, nil } @@ -244,6 +258,12 @@ func (s *Box) start() error { return E.Cause(err, "start v2ray api server") } } + if s.ssmServer != nil { + err = s.ssmServer.Start() + if err != nil { + return E.Cause(err, "start ssm api server") + } + } s.logger.Info("sing-box started (", F.Seconds(time.Since(s.createdAt).Seconds()), "s)") return nil } @@ -266,6 +286,7 @@ func (s *Box) Close() error { s.logFactory, s.clashServer, s.v2rayServer, + s.ssmServer, common.PtrOrNil(s.logFile), ) } diff --git a/experimental/ssmapi.go b/experimental/ssmapi.go new file mode 100644 index 00000000..60ae97d1 --- /dev/null +++ b/experimental/ssmapi.go @@ -0,0 +1,24 @@ +package experimental + +import ( + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +type SSMServerConstructor = func(router adapter.Router, logger log.Logger, options option.SSMAPIOptions) (adapter.SSMServer, error) + +var ssmServerConstructor SSMServerConstructor + +func RegisterSSMServerConstructor(constructor SSMServerConstructor) { + ssmServerConstructor = constructor +} + +func NewSSMServer(router adapter.Router, logger log.Logger, options option.SSMAPIOptions) (adapter.SSMServer, error) { + if ssmServerConstructor == nil { + return nil, os.ErrInvalid + } + return ssmServerConstructor(router, logger, options) +} diff --git a/experimental/ssmapi/api.go b/experimental/ssmapi/api.go new file mode 100644 index 00000000..0cae2f7b --- /dev/null +++ b/experimental/ssmapi/api.go @@ -0,0 +1,214 @@ +package ssmapi + +import ( + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func (s *Server) setupRoutes(r chi.Router) { + r.Group(func(r chi.Router) { + r.Get("/", s.getServerInfo) + + r.Get("/nodes", s.getNodes) + r.Post("/nodes", s.addNode) + r.Get("/nodes/{id}", s.getNode) + r.Put("/nodes/{id}", s.updateNode) + r.Delete("/nodes/{id}", s.deleteNode) + + r.Get("/users", s.listUser) + r.Post("/users", s.addUser) + r.Get("/users/{username}", s.getUser) + r.Put("/users/{username}", s.updateUser) + r.Delete("/users/{username}", s.deleteUser) + + r.Get("/stats", s.getStats) + }) +} + +func (s *Server) getServerInfo(writer http.ResponseWriter, request *http.Request) { + render.JSON(writer, request, render.M{ + "server": "sing-box", + "apiVersion": "v1", + "_sing_box_version": C.Version, + }) +} + +func (s *Server) getNodes(writer http.ResponseWriter, request *http.Request) { + var response struct { + Protocols []string `json:"protocols"` + Shadowsocks []ShadowsocksNodeObject `json:"shadowsocks,omitempty"` + } + for _, node := range s.nodes { + if !common.Contains(response.Protocols, node.Protocol()) { + response.Protocols = append(response.Protocols, node.Protocol()) + } + switch node.Protocol() { + case C.TypeShadowsocks: + response.Shadowsocks = append(response.Shadowsocks, node.Shadowsocks()) + } + } + render.JSON(writer, request, &response) +} + +func (s *Server) addNode(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNotImplemented) +} + +func (s *Server) getNode(writer http.ResponseWriter, request *http.Request) { + nodeID := chi.URLParam(request, "id") + if nodeID == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + for _, node := range s.nodes { + if nodeID == node.ID() { + render.JSON(writer, request, render.M{ + node.Protocol(): node.Object(), + }) + return + } + } +} + +func (s *Server) updateNode(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNotImplemented) +} + +func (s *Server) deleteNode(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(http.StatusNotImplemented) +} + +type SSMUserObject struct { + UserName string `json:"username"` + Password string `json:"uPSK,omitempty"` + DownlinkBytes int64 `json:"downlinkBytes"` + UplinkBytes int64 `json:"uplinkBytes"` + + DownlinkPackets int64 `json:"downlinkPackets"` + UplinkPackets int64 `json:"uplinkPackets"` + TCPSessions int64 `json:"tcpSessions"` + UDPSessions int64 `json:"udpSessions"` +} + +func (s *Server) listUser(writer http.ResponseWriter, request *http.Request) { + render.JSON(writer, request, render.M{ + "users": s.userManager.List(), + }) +} + +func (s *Server) addUser(writer http.ResponseWriter, request *http.Request) { + var addRequest struct { + UserName string `json:"username"` + Password string `json:"uPSK"` + } + err := render.DecodeJSON(request.Body, &addRequest) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + err = s.userManager.Add(addRequest.UserName, addRequest.Password) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusCreated) +} + +func (s *Server) getUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + uPSK, loaded := s.userManager.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + user := SSMUserObject{ + UserName: userName, + Password: uPSK, + } + s.trafficManager.ReadUser(&user) + render.JSON(writer, request, user) +} + +func (s *Server) updateUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + var updateRequest struct { + Password string `json:"uPSK"` + } + err := render.DecodeJSON(request.Body, &updateRequest) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + _, loaded := s.userManager.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + err = s.userManager.Update(userName, updateRequest.Password) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +func (s *Server) deleteUser(writer http.ResponseWriter, request *http.Request) { + userName := chi.URLParam(request, "username") + if userName == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } + _, loaded := s.userManager.Get(userName) + if !loaded { + writer.WriteHeader(http.StatusNotFound) + return + } + err := s.userManager.Delete(userName) + if err != nil { + render.Status(request, http.StatusBadRequest) + render.PlainText(writer, request, err.Error()) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +func (s *Server) getStats(writer http.ResponseWriter, request *http.Request) { + requireClear := chi.URLParam(request, "clear") == "true" + + users := s.userManager.List() + s.trafficManager.ReadUsers(users) + for i := range users { + users[i].Password = "" + } + uplinkBytes, downlinkBytes, uplinkPackets, downlinkPackets, tcpSessions, udpSessions := s.trafficManager.ReadGlobal() + + if requireClear { + s.trafficManager.Clear() + } + + render.JSON(writer, request, render.M{ + "uplinkBytes": uplinkBytes, + "downlinkBytes": downlinkBytes, + "uplinkPackets": uplinkPackets, + "downlinkPackets": downlinkPackets, + "tcpSessions": tcpSessions, + "udpSessions": udpSessions, + "users": users, + }) +} diff --git a/experimental/ssmapi/server.go b/experimental/ssmapi/server.go new file mode 100644 index 00000000..204f1b83 --- /dev/null +++ b/experimental/ssmapi/server.go @@ -0,0 +1,117 @@ +package ssmapi + +import ( + "errors" + "net" + "net/http" + "strings" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + + "github.com/go-chi/chi/v5" +) + +func init() { + experimental.RegisterSSMServerConstructor(NewServer) +} + +var _ adapter.SSMServer = (*Server)(nil) + +type Server struct { + router adapter.Router + logger log.Logger + httpServer *http.Server + tcpListener net.Listener + + nodes []Node + userManager *UserManager + trafficManager *TrafficManager +} + +type Node interface { + Protocol() string + ID() string + Shadowsocks() ShadowsocksNodeObject + Object() any + Tag() string + UpdateUsers(users []string, uPSKs []string) error +} + +func NewServer(router adapter.Router, logger log.Logger, options option.SSMAPIOptions) (adapter.SSMServer, error) { + chiRouter := chi.NewRouter() + server := &Server{ + router: router, + logger: logger, + httpServer: &http.Server{ + Addr: options.Listen, + Handler: chiRouter, + }, + nodes: make([]Node, 0, len(options.Nodes)), + } + for i, nodeOptions := range options.Nodes { + switch nodeOptions.Type { + case C.TypeShadowsocks: + ssOptions := nodeOptions.ShadowsocksOptions + inbound, loaded := router.Inbound(ssOptions.Inbound) + if !loaded { + return nil, E.New("parse SSM node[", i, "]: inbound", ssOptions.Inbound, "not found") + } + ssInbound, isSS := inbound.(adapter.ManagedShadowsocksServer) + if !isSS { + return nil, E.New("parse SSM node[", i, "]: inbound", ssOptions.Inbound, "is not a shadowsocks inbound") + } + node := &ShadowsocksNode{ + ssOptions, + ssInbound, + } + server.nodes = append(server.nodes, node) + } + } + server.trafficManager = NewTrafficManager(server.nodes) + server.userManager = NewUserManager(server.nodes, server.trafficManager) + listenPrefix := options.ListenPrefix + if !strings.HasPrefix(listenPrefix, "/") { + listenPrefix = "/" + listenPrefix + } + chiRouter.Route(listenPrefix+"server/v1", server.setupRoutes) + return server, nil +} + +func (s *Server) Start() error { + listener, err := net.Listen("tcp", s.httpServer.Addr) + if err != nil { + return err + } + s.logger.Info("ssm-api started at ", listener.Addr()) + s.tcpListener = listener + go func() { + err = s.httpServer.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.logger.Error("ssm-api serve error: ", err) + } + }() + return nil +} + +func (s *Server) Close() error { + return common.Close( + common.PtrOrNil(s.httpServer), + s.tcpListener, + s.trafficManager, + ) +} + +func (s *Server) RoutedConnection(metadata adapter.InboundContext, conn net.Conn) net.Conn { + return s.trafficManager.RoutedConnection(metadata, conn) +} + +func (s *Server) RoutedPacketConnection(metadata adapter.InboundContext, conn N.PacketConn) N.PacketConn { + return s.trafficManager.RoutedPacketConnection(metadata, conn) +} diff --git a/experimental/ssmapi/shadowsocks.go b/experimental/ssmapi/shadowsocks.go new file mode 100644 index 00000000..430a151d --- /dev/null +++ b/experimental/ssmapi/shadowsocks.go @@ -0,0 +1,54 @@ +package ssmapi + +import ( + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" +) + +var _ Node = (*ShadowsocksNode)(nil) + +type ShadowsocksNode struct { + node option.SSMShadowsocksNode + inbound adapter.ManagedShadowsocksServer +} + +type ShadowsocksNodeObject struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Method string `json:"method,omitempty"` + Passwords []string `json:"iPSKs,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +func (n *ShadowsocksNode) Protocol() string { + return C.TypeShadowsocks +} + +func (n *ShadowsocksNode) ID() string { + return n.node.ID +} + +func (n *ShadowsocksNode) Shadowsocks() ShadowsocksNodeObject { + return ShadowsocksNodeObject{ + ID: n.node.ID, + Name: n.node.Name, + Endpoint: n.node.Address, + Method: n.inbound.Method(), + Passwords: []string{n.inbound.Password()}, + Tags: n.node.Tags, + } +} + +func (n *ShadowsocksNode) Object() any { + return n.Shadowsocks() +} + +func (n *ShadowsocksNode) Tag() string { + return n.inbound.Tag() +} + +func (n *ShadowsocksNode) UpdateUsers(users []string, uPSKs []string) error { + return n.inbound.UpdateUsers(users, uPSKs) +} diff --git a/experimental/ssmapi/traffic.go b/experimental/ssmapi/traffic.go new file mode 100644 index 00000000..e881ad27 --- /dev/null +++ b/experimental/ssmapi/traffic.go @@ -0,0 +1,227 @@ +package ssmapi + +import ( + "net" + "sync" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental/trackerconn" + N "github.com/sagernet/sing/common/network" + + "go.uber.org/atomic" +) + +type TrafficManager struct { + nodeTags map[string]bool + nodeUsers map[string]bool + globalUplink *atomic.Int64 + globalDownlink *atomic.Int64 + globalUplinkPackets *atomic.Int64 + globalDownlinkPackets *atomic.Int64 + globalTCPSessions *atomic.Int64 + globalUDPSessions *atomic.Int64 + userAccess sync.Mutex + userUplink map[string]*atomic.Int64 + userDownlink map[string]*atomic.Int64 + userUplinkPackets map[string]*atomic.Int64 + userDownlinkPackets map[string]*atomic.Int64 + userTCPSessions map[string]*atomic.Int64 + userUDPSessions map[string]*atomic.Int64 +} + +func NewTrafficManager(nodes []Node) *TrafficManager { + manager := &TrafficManager{ + nodeTags: make(map[string]bool), + globalUplink: atomic.NewInt64(0), + globalDownlink: atomic.NewInt64(0), + globalUplinkPackets: atomic.NewInt64(0), + globalDownlinkPackets: atomic.NewInt64(0), + globalTCPSessions: atomic.NewInt64(0), + globalUDPSessions: atomic.NewInt64(0), + userUplink: make(map[string]*atomic.Int64), + userDownlink: make(map[string]*atomic.Int64), + userUplinkPackets: make(map[string]*atomic.Int64), + userDownlinkPackets: make(map[string]*atomic.Int64), + userTCPSessions: make(map[string]*atomic.Int64), + userUDPSessions: make(map[string]*atomic.Int64), + } + for _, node := range nodes { + manager.nodeTags[node.Tag()] = true + } + return manager +} + +func (s *TrafficManager) UpdateUsers(users []string) { + nodeUsers := make(map[string]bool) + for _, user := range users { + nodeUsers[user] = true + } + s.nodeUsers = nodeUsers +} + +func (s *TrafficManager) userCounter(user string) (*atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64, *atomic.Int64) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + upCounter, loaded := s.userUplink[user] + if !loaded { + upCounter = atomic.NewInt64(0) + s.userUplink[user] = upCounter + } + downCounter, loaded := s.userDownlink[user] + if !loaded { + downCounter = atomic.NewInt64(0) + s.userDownlink[user] = downCounter + } + upPacketsCounter, loaded := s.userUplinkPackets[user] + if !loaded { + upPacketsCounter = atomic.NewInt64(0) + s.userUplinkPackets[user] = upPacketsCounter + } + downPacketsCounter, loaded := s.userDownlinkPackets[user] + if !loaded { + downPacketsCounter = atomic.NewInt64(0) + s.userDownlinkPackets[user] = downPacketsCounter + } + tcpSessionsCounter, loaded := s.userTCPSessions[user] + if !loaded { + tcpSessionsCounter = atomic.NewInt64(0) + s.userTCPSessions[user] = tcpSessionsCounter + } + udpSessionsCounter, loaded := s.userUDPSessions[user] + if !loaded { + udpSessionsCounter = atomic.NewInt64(0) + s.userUDPSessions[user] = udpSessionsCounter + } + return upCounter, downCounter, upPacketsCounter, downPacketsCounter, tcpSessionsCounter, udpSessionsCounter +} + +func createCounter(counterList []*atomic.Int64, packetCounterList []*atomic.Int64) func(n int64) { + return func(n int64) { + for _, counter := range counterList { + counter.Add(n) + } + for _, counter := range packetCounterList { + counter.Inc() + } + } +} + +func (s *TrafficManager) RoutedConnection(metadata adapter.InboundContext, conn net.Conn) net.Conn { + s.globalTCPSessions.Inc() + + var readCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + + if s.nodeTags[metadata.Inbound] { + readCounter = append(readCounter, s.globalUplink) + writeCounter = append(writeCounter, s.globalDownlink) + } + if s.nodeUsers[metadata.User] { + upCounter, downCounter, _, _, tcpSessionCounter, _ := s.userCounter(metadata.User) + readCounter = append(readCounter, upCounter) + writeCounter = append(writeCounter, downCounter) + tcpSessionCounter.Inc() + } + if len(readCounter) > 0 { + return trackerconn.New(conn, readCounter, writeCounter) + } + return conn +} + +func (s *TrafficManager) RoutedPacketConnection(metadata adapter.InboundContext, conn N.PacketConn) N.PacketConn { + s.globalUDPSessions.Inc() + + var readCounter []*atomic.Int64 + var readPacketCounter []*atomic.Int64 + var writeCounter []*atomic.Int64 + var writePacketCounter []*atomic.Int64 + + if s.nodeTags[metadata.Inbound] { + readCounter = append(readCounter, s.globalUplink) + writeCounter = append(writeCounter, s.globalDownlink) + readPacketCounter = append(readPacketCounter, s.globalUplinkPackets) + writePacketCounter = append(writePacketCounter, s.globalDownlinkPackets) + } + if s.nodeUsers[metadata.User] { + upCounter, downCounter, upPacketsCounter, downPacketsCounter, _, udpSessionCounter := s.userCounter(metadata.User) + readCounter = append(readCounter, upCounter) + writeCounter = append(writeCounter, downCounter) + readPacketCounter = append(readPacketCounter, upPacketsCounter) + writePacketCounter = append(writePacketCounter, downPacketsCounter) + udpSessionCounter.Inc() + } + if len(readCounter) > 0 { + return trackerconn.NewHookPacket(conn, createCounter(readCounter, readPacketCounter), createCounter(writeCounter, writePacketCounter)) + } + return conn +} + +func (s *TrafficManager) ReadUser(user *SSMUserObject) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + + s.readUser(user) +} + +func (s *TrafficManager) readUser(user *SSMUserObject) { + if counter, loaded := s.userUplink[user.UserName]; loaded { + user.UplinkBytes = counter.Load() + } + if counter, loaded := s.userDownlink[user.UserName]; loaded { + user.DownlinkBytes = counter.Load() + } + if counter, loaded := s.userUplinkPackets[user.UserName]; loaded { + user.UplinkPackets = counter.Load() + } + if counter, loaded := s.userDownlinkPackets[user.UserName]; loaded { + user.DownlinkPackets = counter.Load() + } + if counter, loaded := s.userTCPSessions[user.UserName]; loaded { + user.TCPSessions = counter.Load() + } + if counter, loaded := s.userUDPSessions[user.UserName]; loaded { + user.UDPSessions = counter.Load() + } +} + +func (s *TrafficManager) ReadUsers(users []*SSMUserObject) { + s.userAccess.Lock() + defer s.userAccess.Unlock() + for _, user := range users { + s.readUser(user) + } + return +} + +func (s *TrafficManager) ReadGlobal() ( + uplinkBytes int64, + downlinkBytes int64, + uplinkPackets int64, + downlinkPackets int64, + tcpSessions int64, + udpSessions int64, +) { + return s.globalUplink.Load(), + s.globalDownlink.Load(), + s.globalUplinkPackets.Load(), + s.globalDownlinkPackets.Load(), + s.globalTCPSessions.Load(), + s.globalUDPSessions.Load() +} + +func (s *TrafficManager) Clear() { + s.globalUplink.Store(0) + s.globalDownlink.Store(0) + s.globalUplinkPackets.Store(0) + s.globalDownlinkPackets.Store(0) + s.globalTCPSessions.Store(0) + s.globalUDPSessions.Store(0) + s.userAccess.Lock() + defer s.userAccess.Unlock() + s.userUplink = make(map[string]*atomic.Int64) + s.userDownlink = make(map[string]*atomic.Int64) + s.userUplinkPackets = make(map[string]*atomic.Int64) + s.userDownlinkPackets = make(map[string]*atomic.Int64) + s.userTCPSessions = make(map[string]*atomic.Int64) + s.userUDPSessions = make(map[string]*atomic.Int64) +} diff --git a/experimental/ssmapi/user.go b/experimental/ssmapi/user.go new file mode 100644 index 00000000..cdb77715 --- /dev/null +++ b/experimental/ssmapi/user.go @@ -0,0 +1,86 @@ +package ssmapi + +import ( + "sync" + + E "github.com/sagernet/sing/common/exceptions" +) + +type UserManager struct { + access sync.Mutex + usersMap map[string]string + nodes []Node + trafficManager *TrafficManager +} + +func NewUserManager(nodes []Node, trafficManager *TrafficManager) *UserManager { + return &UserManager{ + usersMap: make(map[string]string), + nodes: nodes, + trafficManager: trafficManager, + } +} + +func (m *UserManager) postUpdate() error { + users := make([]string, 0, len(m.usersMap)) + uPSKs := make([]string, 0, len(m.usersMap)) + for username, password := range m.usersMap { + users = append(users, username) + uPSKs = append(uPSKs, password) + } + for _, node := range m.nodes { + err := node.UpdateUsers(users, uPSKs) + if err != nil { + return err + } + } + m.trafficManager.UpdateUsers(users) + return nil +} + +func (m *UserManager) List() []*SSMUserObject { + m.access.Lock() + defer m.access.Unlock() + + users := make([]*SSMUserObject, 0, len(m.usersMap)) + for username, password := range m.usersMap { + users = append(users, &SSMUserObject{ + UserName: username, + Password: password, + }) + } + return users +} + +func (m *UserManager) Add(username string, password string) error { + m.access.Lock() + defer m.access.Unlock() + if _, found := m.usersMap[username]; found { + return E.New("user", username, "already exists") + } + m.usersMap[username] = password + return m.postUpdate() +} + +func (m *UserManager) Get(username string) (string, bool) { + m.access.Lock() + defer m.access.Unlock() + if password, found := m.usersMap[username]; found { + return password, true + } + return "", false +} + +func (m *UserManager) Update(username string, password string) error { + m.access.Lock() + defer m.access.Unlock() + m.usersMap[username] = password + return m.postUpdate() +} + +func (m *UserManager) Delete(username string) error { + m.access.Lock() + defer m.access.Unlock() + delete(m.usersMap, username) + return m.postUpdate() +} diff --git a/inbound/shadowsocks.go b/inbound/shadowsocks.go index 2bbb304b..5f6c8dad 100644 --- a/inbound/shadowsocks.go +++ b/inbound/shadowsocks.go @@ -22,7 +22,7 @@ func NewShadowsocks(ctx context.Context, router adapter.Router, logger log.Conte if len(options.Users) > 0 && len(options.Destinations) > 0 { return nil, E.New("users and destinations options must not be combined") } - if len(options.Users) > 0 { + if len(options.Users) > 0 || options.Managed { return newShadowsocksMulti(ctx, router, logger, tag, options) } else if len(options.Destinations) > 0 { return newShadowsocksRelay(ctx, router, logger, tag, options) @@ -32,8 +32,9 @@ func NewShadowsocks(ctx context.Context, router adapter.Router, logger log.Conte } var ( - _ adapter.Inbound = (*Shadowsocks)(nil) - _ adapter.InjectableInbound = (*Shadowsocks)(nil) + _ adapter.Inbound = (*Shadowsocks)(nil) + _ adapter.InjectableInbound = (*Shadowsocks)(nil) + _ adapter.ManagedShadowsocksServer = (*Shadowsocks)(nil) ) type Shadowsocks struct { @@ -76,6 +77,18 @@ func newShadowsocks(ctx context.Context, router adapter.Router, logger log.Conte return inbound, err } +func (h *Shadowsocks) Method() string { + return h.service.Name() +} + +func (h *Shadowsocks) Password() string { + return h.service.Password() +} + +func (h *Shadowsocks) UpdateUsers(names []string, uPSKs []string) error { + return os.ErrInvalid +} + func (h *Shadowsocks) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { return h.service.NewConnection(adapter.WithContext(log.ContextWithNewID(ctx), &metadata), conn, adapter.UpstreamMetadata(metadata)) } diff --git a/inbound/shadowsocks_multi.go b/inbound/shadowsocks_multi.go index fc9debd5..93bd7395 100644 --- a/inbound/shadowsocks_multi.go +++ b/inbound/shadowsocks_multi.go @@ -19,8 +19,9 @@ import ( ) var ( - _ adapter.Inbound = (*ShadowsocksMulti)(nil) - _ adapter.InjectableInbound = (*ShadowsocksMulti)(nil) + _ adapter.Inbound = (*ShadowsocksMulti)(nil) + _ adapter.InjectableInbound = (*ShadowsocksMulti)(nil) + _ adapter.ManagedShadowsocksServer = (*ShadowsocksMulti)(nil) ) type ShadowsocksMulti struct { @@ -61,11 +62,13 @@ func newShadowsocksMulti(ctx context.Context, router adapter.Router, logger log. if err != nil { return nil, err } - err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Users, func(index int, user option.ShadowsocksUser) int { - return index - }), common.Map(options.Users, func(user option.ShadowsocksUser) string { - return user.Password - })) + if len(options.Users) > 0 { + err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Users, func(index int, user option.ShadowsocksUser) int { + return index + }), common.Map(options.Users, func(user option.ShadowsocksUser) string { + return user.Password + })) + } if err != nil { return nil, err } @@ -75,6 +78,29 @@ func newShadowsocksMulti(ctx context.Context, router adapter.Router, logger log. return inbound, err } +func (h *ShadowsocksMulti) Method() string { + return h.service.Name() +} + +func (h *ShadowsocksMulti) Password() string { + return h.service.Password() +} + +func (h *ShadowsocksMulti) UpdateUsers(users []string, uPSKs []string) error { + err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { + return index + }), uPSKs) + if err != nil { + return err + } + h.users = common.Map(users, func(user string) option.ShadowsocksUser { + return option.ShadowsocksUser{ + Name: user, + } + }) + return nil +} + func (h *ShadowsocksMulti) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { return h.service.NewConnection(adapter.WithContext(log.ContextWithNewID(ctx), &metadata), conn, adapter.UpstreamMetadata(metadata)) } diff --git a/inbound/shadowsocks_relay.go b/inbound/shadowsocks_relay.go index 2f624447..72570957 100644 --- a/inbound/shadowsocks_relay.go +++ b/inbound/shadowsocks_relay.go @@ -18,8 +18,9 @@ import ( ) var ( - _ adapter.Inbound = (*ShadowsocksRelay)(nil) - _ adapter.InjectableInbound = (*ShadowsocksRelay)(nil) + _ adapter.Inbound = (*ShadowsocksRelay)(nil) + _ adapter.InjectableInbound = (*ShadowsocksRelay)(nil) + _ adapter.ManagedShadowsocksServer = (*ShadowsocksRelay)(nil) ) type ShadowsocksRelay struct { @@ -71,6 +72,18 @@ func newShadowsocksRelay(ctx context.Context, router adapter.Router, logger log. return inbound, err } +func (h *ShadowsocksRelay) Method() string { + return h.service.Name() +} + +func (h *ShadowsocksRelay) Password() string { + return h.service.Password() +} + +func (h *ShadowsocksRelay) UpdateUsers(users []string, uPSKs []string) error { + return os.ErrInvalid +} + func (h *ShadowsocksRelay) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { return h.service.NewConnection(adapter.WithContext(log.ContextWithNewID(ctx), &metadata), conn, adapter.UpstreamMetadata(metadata)) } diff --git a/include/ssmapi.go b/include/ssmapi.go new file mode 100644 index 00000000..ddb76be4 --- /dev/null +++ b/include/ssmapi.go @@ -0,0 +1,5 @@ +//go:build with_ssm_api + +package include + +import _ "github.com/sagernet/sing-box/experimental/ssmapi" diff --git a/include/ssmapi_stub.go b/include/ssmapi_stub.go new file mode 100644 index 00000000..8dbc5fd0 --- /dev/null +++ b/include/ssmapi_stub.go @@ -0,0 +1,17 @@ +//go:build !with_ssm_api + +package include + +import ( + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/experimental" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func init() { + experimental.RegisterSSMServerConstructor(func(router adapter.Router, logger log.Logger, options option.SSMAPIOptions) (adapter.SSMServer, error) { + return nil, E.New(`SSM api is not included in this build, rebuild with -tags with_ssm_api`) + }) +} diff --git a/option/experimental.go b/option/experimental.go index 2167ddaa..47eac9c0 100644 --- a/option/experimental.go +++ b/option/experimental.go @@ -3,4 +3,5 @@ package option type ExperimentalOptions struct { ClashAPI *ClashAPIOptions `json:"clash_api,omitempty"` V2RayAPI *V2RayAPIOptions `json:"v2ray_api,omitempty"` + SSMAPI *SSMAPIOptions `json:"ssm_api,omitempty"` } diff --git a/option/shadowsocks.go b/option/shadowsocks.go index 56b528b2..e54b94e4 100644 --- a/option/shadowsocks.go +++ b/option/shadowsocks.go @@ -7,6 +7,7 @@ type ShadowsocksInboundOptions struct { Password string `json:"password"` Users []ShadowsocksUser `json:"users,omitempty"` Destinations []ShadowsocksDestination `json:"destinations,omitempty"` + Managed bool `json:"managed,omitempty"` } type ShadowsocksUser struct { diff --git a/option/ssmapi.go b/option/ssmapi.go new file mode 100644 index 00000000..027d042e --- /dev/null +++ b/option/ssmapi.go @@ -0,0 +1,54 @@ +package option + +import ( + "github.com/sagernet/sing-box/common/json" + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" +) + +type SSMAPIOptions struct { + Listen string `json:"listen,omitempty"` + ListenPrefix string `json:"listen_prefix,omitempty"` + Nodes []SSMNode `json:"nodes,omitempty"` +} + +type _SSMNode struct { + Type string `json:"type,omitempty"` + ShadowsocksOptions SSMShadowsocksNode `json:"-"` +} + +type SSMNode _SSMNode + +func (h SSMNode) MarshalJSON() ([]byte, error) { + var v any + switch h.Type { + case C.TypeShadowsocks: + v = h.ShadowsocksOptions + default: + return nil, E.New("unknown ssm node type: ", h.Type) + } + return MarshallObjects((_SSMNode)(h), v) +} + +func (h *SSMNode) UnmarshalJSON(data []byte) error { + err := json.Unmarshal(data, (*_SSMNode)(h)) + if err != nil { + return err + } + var v any + switch h.Type { + case C.TypeShadowsocks: + v = &h.ShadowsocksOptions + default: + return E.New("unknown ssm node type: ", h.Type) + } + return UnmarshallExcluded(data, (*_SSMNode)(h), v) +} + +type SSMShadowsocksNode struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Tags []string `json:"tags"` + Inbound string `json:"inbound"` +} diff --git a/route/router.go b/route/router.go index 0944741e..d4980188 100644 --- a/route/router.go +++ b/route/router.go @@ -97,6 +97,7 @@ type Router struct { processSearcher process.Searcher clashServer adapter.ClashServer v2rayServer adapter.V2RayServer + ssmServer adapter.SSMServer } func NewRouter(ctx context.Context, logFactory log.Factory, options option.RouteOptions, dnsOptions option.DNSOptions, inbounds []option.Inbound) (*Router, error) { @@ -380,10 +381,28 @@ func (r *Router) Initialize(inbounds []adapter.Inbound, outbounds []adapter.Outb return nil } +func (r *Router) Inbound(tag string) (adapter.Inbound, bool) { + inbound, loaded := r.inboundByTag[tag] + return inbound, loaded +} + func (r *Router) Outbounds() []adapter.Outbound { return r.outbounds } +func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { + outbound, loaded := r.outboundByTag[tag] + return outbound, loaded +} + +func (r *Router) DefaultOutbound(network string) adapter.Outbound { + if network == N.NetworkTCP { + return r.defaultOutboundForConnection + } else { + return r.defaultOutboundForPacketConnection + } +} + func (r *Router) Start() error { if r.needGeoIPDatabase { err := r.prepareGeoIPDatabase() @@ -504,19 +523,6 @@ func (r *Router) LoadGeosite(code string) (adapter.Rule, error) { return rule, nil } -func (r *Router) Outbound(tag string) (adapter.Outbound, bool) { - outbound, loaded := r.outboundByTag[tag] - return outbound, loaded -} - -func (r *Router) DefaultOutbound(network string) adapter.Outbound { - if network == N.NetworkTCP { - return r.defaultOutboundForConnection - } else { - return r.defaultOutboundForPacketConnection - } -} - func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { if metadata.InboundDetour != "" { if metadata.LastInbound == metadata.InboundDetour { @@ -603,6 +609,9 @@ func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata ad conn = statsService.RoutedConnection(metadata.Inbound, detour.Tag(), metadata.User, conn) } } + if r.ssmServer != nil { + conn = r.ssmServer.RoutedConnection(metadata, conn) + } return detour.NewConnection(ctx, conn, metadata) } @@ -681,6 +690,9 @@ func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, m conn = statsService.RoutedPacketConnection(metadata.Inbound, detour.Tag(), metadata.User, conn) } } + if r.ssmServer != nil { + conn = r.ssmServer.RoutedPacketConnection(metadata, conn) + } return detour.NewPacketConnection(ctx, conn, metadata) } @@ -777,6 +789,14 @@ func (r *Router) SetV2RayServer(server adapter.V2RayServer) { r.v2rayServer = server } +func (r *Router) SSMServer() adapter.SSMServer { + return r.ssmServer +} + +func (r *Router) SetSSMServer(server adapter.SSMServer) { + r.ssmServer = server +} + func hasRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool { for _, rule := range rules { switch rule.Type {