فهرست منبع

Add ssm api server

世界 2 سال پیش
والد
کامیت
d34091f8fc

+ 2 - 0
.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:

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

+ 13 - 0
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
+}

+ 4 - 0
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{}

+ 21 - 0
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),
 	)
 }

+ 24 - 0
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)
+}

+ 214 - 0
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,
+	})
+}

+ 117 - 0
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)
+}

+ 54 - 0
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)
+}

+ 227 - 0
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)
+}

+ 86 - 0
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()
+}

+ 16 - 3
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))
 }

+ 33 - 7
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))
 }

+ 15 - 2
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))
 }

+ 5 - 0
include/ssmapi.go

@@ -0,0 +1,5 @@
+//go:build with_ssm_api
+
+package include
+
+import _ "github.com/sagernet/sing-box/experimental/ssmapi"

+ 17 - 0
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`)
+	})
+}

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

+ 1 - 0
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 {

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

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