From a3404e463fe05396f52c307366c6a0ff836c29f5 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Thu, 18 Jun 2026 15:49:49 +0300 Subject: [PATCH] feat: add universal user_manager service for external auth & traffic tracking - New UserManager service with HTTPS auth, credential sync, traffic tracking, and kick API - Integrates into all 10 protocol inbounds (hysteria2, tuic, vless, vmess, trojan, shadowsocks, http, socks, mixed, naive) - Each inbound auto-detects user_manager from service context, falls back to static config - Auth server contract: POST /api/auth, GET /api/credentials, POST /api/traffic --- adapter/user_manager.go | 23 + constant/proxy.go | 1 + include/registry.go | 1 + include/usermanager.go | 10 + option/user_manager.go | 16 + patches.md | 65 +++ protocol/http/inbound.go | 37 +- protocol/hysteria2/inbound.go | 61 ++- protocol/mixed/inbound.go | 36 +- protocol/naive/inbound.go | 39 +- protocol/shadowsocks/inbound.go | 4 +- protocol/shadowsocks/inbound_multi.go | 70 ++- protocol/socks/inbound.go | 36 +- protocol/trojan/inbound.go | 74 ++- protocol/tuic/inbound.go | 110 ++++- protocol/vless/inbound.go | 90 +++- protocol/vmess/inbound.go | 99 +++- service/usermanager/manager.go | 662 ++++++++++++++++++++++++++ service/usermanager/registry.go | 18 + 19 files changed, 1364 insertions(+), 88 deletions(-) create mode 100644 adapter/user_manager.go create mode 100644 include/usermanager.go create mode 100644 option/user_manager.go create mode 100644 patches.md create mode 100644 service/usermanager/manager.go create mode 100644 service/usermanager/registry.go diff --git a/adapter/user_manager.go b/adapter/user_manager.go new file mode 100644 index 00000000..70e67665 --- /dev/null +++ b/adapter/user_manager.go @@ -0,0 +1,23 @@ +package adapter + +import "context" + +type UserTraffic struct { + Tx int64 `json:"tx"` + Rx int64 `json:"rx"` +} + +type UserInfo struct { + Username string + Credential string +} + +type UserManager interface { + Service + Authenticate(ctx context.Context, protocol string, credential string, addr string) (username string, err error) + GetCredentials(protocol string) ([]UserInfo, error) + ReportTraffic(username string, tx int64, rx int64) + GetTraffic(username string) (tx int64, rx int64) + ListTraffic() map[string]UserTraffic + KickUser(username string) +} diff --git a/constant/proxy.go b/constant/proxy.go index ea651712..849c1e8b 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -38,6 +38,7 @@ const ( TypeHysteriaRealm = "hysteria-realm" TypeACME = "acme" TypeCloudflareOriginCA = "cloudflare-origin-ca" + TypeUserManager = "user_manager" ) const ( diff --git a/include/registry.go b/include/registry.go index 3bcbddb3..89a82675 100644 --- a/include/registry.go +++ b/include/registry.go @@ -144,6 +144,7 @@ func ServiceRegistry() *service.Registry { registerOCMService(registry) registerOOMKillerService(registry) registerUSBIPServices(registry) + registerUserManagerService(registry) return registry } diff --git a/include/usermanager.go b/include/usermanager.go new file mode 100644 index 00000000..7b31ac94 --- /dev/null +++ b/include/usermanager.go @@ -0,0 +1,10 @@ +package include + +import ( + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/service/usermanager" +) + +func registerUserManagerService(registry *service.Registry) { + usermanager.RegisterService(registry) +} diff --git a/option/user_manager.go b/option/user_manager.go new file mode 100644 index 00000000..bbcca628 --- /dev/null +++ b/option/user_manager.go @@ -0,0 +1,16 @@ +package option + +import ( + "github.com/sagernet/sing/common/json/badoption" +) + +type UserManagerOptions struct { + AuthServer string `json:"auth_server,omitempty"` + Timeout badoption.Duration `json:"timeout,omitempty"` + CacheTTL badoption.Duration `json:"cache_ttl,omitempty"` + RefreshInterval badoption.Duration `json:"refresh_interval,omitempty"` + ReportInterval badoption.Duration `json:"report_interval,omitempty"` + ReportTraffic bool `json:"report_traffic,omitempty"` + APISecret string `json:"api_secret,omitempty"` + APIListen string `json:"api_listen,omitempty"` +} diff --git a/patches.md b/patches.md new file mode 100644 index 00000000..b2abf362 --- /dev/null +++ b/patches.md @@ -0,0 +1,65 @@ +# Upstream Changes + +## UserManager — External Authentication & Traffic Tracking + +A new `user_manager` service that turns sing-box into a universal proxy server for external auth servers. + +### New files + +- `adapter/user_manager.go` — `UserManager` interface: `Authenticate`, `GetCredentials`, `ReportTraffic`, `GetTraffic`, `ListTraffic`, `KickUser` +- `option/user_manager.go` — `UserManagerOptions`: `auth_server`, `cache_ttl`, `refresh_interval`, `report_interval`, `report_traffic`, `api_secret`, `api_listen`, `timeout` +- `service/usermanager/manager.go` — full implementation: + - On-demand HTTPS auth (`POST /api/auth`) with in-memory TTL cache + - Periodic credential sync (`GET /api/credentials`) populates per-protocol user lists + - Per-user traffic counters (`atomic.Int64`) reported via `POST /api/traffic` + - `ConnectionTracker` impl wrapping conns with byte counters (`RoutedConnection`/`RoutedPacketConnection`) + - HTTP API on configurable `api_listen`: + - `GET /traffic` — all users traffic + - `GET /traffic/{user}` — single user + - `POST /kick/{user}` — evict user from auth cache + - Bearer token auth on all API endpoints via `api_secret` +- `service/usermanager/registry.go` — service registration +- `include/usermanager.go` — include build hook +- `constant/proxy.go` — `TypeUserManager` constant + +### Modified files — Protocol inbound integration + +All 10 protocol inbounds detect the global `user_manager` from service context and use it when present: + +| Protocol | Type | Approach | +|----------|------|----------| +| hysteria2 | Service-based | `service.UpdateUsers` with password credentials, 30s refresh loop | +| tuic | Service-based | `service.UpdateUsers` with UUID+password (format `"uuid:password"`), 30s refresh loop | +| vless | Service-based | `service.UpdateUsers` with UUID credentials, 30s refresh loop | +| vmess | Service-based | `service.UpdateUsers` with UUID credentials, 30s refresh loop | +| trojan | Service-based | `service.UpdateUsers` with password credentials, 30s refresh loop | +| shadowsocks (multi) | Service-based | `service.UpdateUsersWithPasswords` with password credentials, 30s refresh loop | +| http | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop | +| socks | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop | +| mixed | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop | +| naive | Authenticator-based | `*auth.Authenticator` rebuilt from `GetCredentials`, 30s refresh loop; skips `"missing users"` check when user_manager is configured | + +Each inbound falls back to its original static config when no `user_manager` service is registered. + +### Credential format per protocol + +| Protocol | `UserInfo.Credential` format | +|------------|------------------------------| +| hysteria2 | password | +| tuic | `uuid:password` | +| vless | uuid | +| vmess | uuid | +| trojan | password | +| shadowsocks| password (method from static config) | +| http | password (username from `UserInfo.Username`) | +| socks | password (username from `UserInfo.Username`) | +| mixed | password (username from `UserInfo.Username`) | +| naive | password (username from `UserInfo.Username`) | + +### Auth server API contract + +The auth server (`auth_server`) must implement: + +- `POST /api/auth` — body `{"protocol":"...", "credential":"...", "addr":"..."}`, returns `{"ok":true, "id":"username"}` +- `GET /api/credentials` — returns `{"credentials":[{"protocol":"...", "credential":"...", "username":"..."}]}` +- `POST /api/traffic` (optional, when `report_traffic=true`) — body `{"traffic":{"user1":{"tx":123,"rx":456}}}` diff --git a/protocol/http/inbound.go b/protocol/http/inbound.go index fe573ea1..0731c1c4 100644 --- a/protocol/http/inbound.go +++ b/protocol/http/inbound.go @@ -4,6 +4,7 @@ import ( std_bufio "bufio" "context" "net" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -18,6 +19,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -33,14 +35,28 @@ type Inbound struct { listener *listener.Listener authenticator *auth.Authenticator tlsConfig tls.ServerConfig + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) { + authenticator := auth.NewAuthenticator(options.Users) + userManager := service.FromContext[adapter.UserManager](ctx) + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeHTTP) + if err == nil && len(creds) > 0 { + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + authenticator = auth.NewAuthenticator(users) + } + } inbound := &Inbound{ Adapter: inbound.NewAdapter(C.TypeHTTP, tag), router: uot.NewRouter(router, logger), logger: logger, - authenticator: auth.NewAuthenticator(options.Users), + authenticator: authenticator, + userManager: userManager, } if options.TLS != nil { tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ @@ -76,9 +92,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return E.Cause(err, "create TLS config") } } + if h.userManager != nil { + go h.credentialLoop() + } return h.listener.Start() } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeHTTP) + if err != nil || len(creds) == 0 { + continue + } + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + h.authenticator = auth.NewAuthenticator(users) + } +} + func (h *Inbound) Close() error { return common.Close( h.listener, diff --git a/protocol/hysteria2/inbound.go b/protocol/hysteria2/inbound.go index b706b492..b33f55c0 100644 --- a/protocol/hysteria2/inbound.go +++ b/protocol/hysteria2/inbound.go @@ -40,6 +40,7 @@ type Inbound struct { tlsConfig tls.ServerConfig service *hysteria2.Service[int] userNameList []string + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.Hysteria2InboundOptions) (adapter.Inbound, error) { @@ -183,17 +184,39 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, err } - userList := make([]int, 0, len(options.Users)) - userNameList := make([]string, 0, len(options.Users)) - userPasswordList := make([]string, 0, len(options.Users)) - for index, user := range options.Users { - userList = append(userList, index) - userNameList = append(userNameList, user.Name) - userPasswordList = append(userPasswordList, user.Password) + userManager := service.FromContext[adapter.UserManager](ctx) + + var userList []int + var userNameList []string + var userPasswordList []string + + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeHysteria2) + if err == nil && len(creds) > 0 { + userList = make([]int, len(creds)) + userNameList = make([]string, len(creds)) + userPasswordList = make([]string, len(creds)) + for i, c := range creds { + userList[i] = i + userNameList[i] = c.Username + userPasswordList[i] = c.Credential + } + } + } else { + userList = make([]int, 0, len(options.Users)) + userNameList = make([]string, 0, len(options.Users)) + userPasswordList = make([]string, 0, len(options.Users)) + for index, user := range options.Users { + userList = append(userList, index) + userNameList = append(userNameList, user.Name) + userPasswordList = append(userPasswordList, user.Password) + } } + hysteriaService.UpdateUsers(userList, userPasswordList) inbound.service = hysteriaService inbound.userNameList = userNameList + inbound.userManager = userManager return inbound, nil } @@ -251,6 +274,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return err } } + if h.userManager != nil { + go h.credentialLoop() + } packetConn, err := h.listener.ListenUDP() if err != nil { return err @@ -258,6 +284,27 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return h.service.Start(packetConn) } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeHysteria2) + if err != nil || len(creds) == 0 { + continue + } + userList := make([]int, len(creds)) + userNameList := make([]string, len(creds)) + userPasswordList := make([]string, len(creds)) + for i, c := range creds { + userList[i] = i + userNameList[i] = c.Username + userPasswordList[i] = c.Credential + } + h.service.UpdateUsers(userList, userPasswordList) + h.userNameList = userNameList + } +} + func (h *Inbound) InterfaceUpdated() { h.service.Reset() } diff --git a/protocol/mixed/inbound.go b/protocol/mixed/inbound.go index d3531947..927635a5 100644 --- a/protocol/mixed/inbound.go +++ b/protocol/mixed/inbound.go @@ -22,6 +22,7 @@ import ( "github.com/sagernet/sing/protocol/socks" "github.com/sagernet/sing/protocol/socks/socks4" "github.com/sagernet/sing/protocol/socks/socks5" + "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -38,6 +39,7 @@ type Inbound struct { authenticator *auth.Authenticator tlsConfig tls.ServerConfig udpTimeout time.Duration + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPMixedInboundOptions) (adapter.Inbound, error) { @@ -47,12 +49,25 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } else { udpTimeout = C.UDPTimeout } + authenticator := auth.NewAuthenticator(options.Users) + userManager := service.FromContext[adapter.UserManager](ctx) + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeMixed) + if err == nil && len(creds) > 0 { + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + authenticator = auth.NewAuthenticator(users) + } + } inbound := &Inbound{ Adapter: inbound.NewAdapter(C.TypeMixed, tag), router: uot.NewRouter(router, logger), logger: logger, - authenticator: auth.NewAuthenticator(options.Users), + authenticator: authenticator, udpTimeout: udpTimeout, + userManager: userManager, } if options.TLS != nil { tlsConfig, err := tls.NewServerWithOptions(tls.ServerOptions{ @@ -88,9 +103,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return E.Cause(err, "create TLS config") } } + if h.userManager != nil { + go h.credentialLoop() + } return h.listener.Start() } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeMixed) + if err != nil || len(creds) == 0 { + continue + } + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + h.authenticator = auth.NewAuthenticator(users) + } +} + func (h *Inbound) Close() error { return common.Close( h.listener, diff --git a/protocol/naive/inbound.go b/protocol/naive/inbound.go index 41f41798..95fa9ddb 100644 --- a/protocol/naive/inbound.go +++ b/protocol/naive/inbound.go @@ -6,6 +6,7 @@ import ( "io" "net" "net/http" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -24,6 +25,7 @@ import ( N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" sHttp "github.com/sagernet/sing/protocol/http" + "github.com/sagernet/sing/service" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" @@ -51,9 +53,22 @@ type Inbound struct { tlsConfig tls.ServerConfig httpServer *http.Server h3Server io.Closer + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.NaiveInboundOptions) (adapter.Inbound, error) { + authenticator := auth.NewAuthenticator(options.Users) + userManager := service.FromContext[adapter.UserManager](ctx) + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeNaive) + if err == nil && len(creds) > 0 { + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + authenticator = auth.NewAuthenticator(users) + } + } inbound := &Inbound{ Adapter: inbound.NewAdapter(C.TypeNaive, tag), ctx: ctx, @@ -66,14 +81,15 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo }), networkIsDefault: options.Network == "", network: options.Network.Build(), - authenticator: auth.NewAuthenticator(options.Users), + authenticator: authenticator, + userManager: userManager, } if common.Contains(inbound.network, N.NetworkUDP) { if options.TLS == nil || !options.TLS.Enabled { return nil, E.New("TLS is required for QUIC server") } } - if len(options.Users) == 0 { + if len(options.Users) == 0 && userManager == nil { return nil, E.New("missing users") } if options.TLS != nil { @@ -96,6 +112,9 @@ func (n *Inbound) Start(stage adapter.StartStage) error { return E.Cause(err, "create TLS config") } } + if n.userManager != nil { + go n.credentialLoop() + } if common.Contains(n.network, N.NetworkTCP) { tcpListener, err := n.listener.ListenTCP() if err != nil { @@ -138,6 +157,22 @@ func (n *Inbound) Start(stage adapter.StartStage) error { return nil } +func (n *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := n.userManager.GetCredentials(C.TypeNaive) + if err != nil || len(creds) == 0 { + continue + } + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + n.authenticator = auth.NewAuthenticator(users) + } +} + func (n *Inbound) Close() error { return common.Close( n.listener, diff --git a/protocol/shadowsocks/inbound.go b/protocol/shadowsocks/inbound.go index 3fc9dc74..7224353d 100644 --- a/protocol/shadowsocks/inbound.go +++ b/protocol/shadowsocks/inbound.go @@ -23,6 +23,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" + "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -35,7 +36,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } else if options.Managed && (len(options.Users) > 0 || len(options.Destinations) > 0) { return nil, E.New("users and destinations options are not supported in managed servers") } - if len(options.Users) > 0 || options.Managed { + userManager := service.FromContext[adapter.UserManager](ctx) + if len(options.Users) > 0 || options.Managed || userManager != nil { return newMultiInbound(ctx, router, logger, tag, options) } else if len(options.Destinations) > 0 { return newRelayInbound(ctx, router, logger, tag, options) diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go index 706243e2..72370a49 100644 --- a/protocol/shadowsocks/inbound_multi.go +++ b/protocol/shadowsocks/inbound_multi.go @@ -26,6 +26,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" + sService "github.com/sagernet/sing/service" ) var ( @@ -35,13 +36,15 @@ var ( type MultiInbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - service shadowsocks.MultiService[int] - users []option.ShadowsocksUser - tracker adapter.SSMTracker + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service shadowsocks.MultiService[int] + users []option.ShadowsocksUser + userNameList []string + userManager adapter.UserManager + tracker adapter.SSMTracker } func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) { @@ -83,18 +86,35 @@ func newMultiInbound(ctx context.Context, router adapter.Router, logger log.Cont if err != nil { return nil, err } - if len(options.Users) > 0 { - err = service.UpdateUsersWithPasswords(common.MapIndexed(options.Users, func(index int, user option.ShadowsocksUser) int { + userManager := sService.FromContext[adapter.UserManager](ctx) + users := options.Users + if len(users) == 0 && userManager != nil { + creds, uErr := userManager.GetCredentials(C.TypeShadowsocks) + if uErr == nil && len(creds) > 0 { + users = make([]option.ShadowsocksUser, len(creds)) + for i, c := range creds { + users[i] = option.ShadowsocksUser{Name: c.Username, Password: c.Credential} + } + } + } + if len(users) > 0 { + err = service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user option.ShadowsocksUser) int { return index - }), common.Map(options.Users, func(user option.ShadowsocksUser) string { + }), common.Map(users, func(user option.ShadowsocksUser) string { return user.Password })) if err != nil { return nil, err } } + userNameList := make([]string, len(users)) + for i, u := range users { + userNameList[i] = u.Name + } inbound.service = service - inbound.users = options.Users + inbound.users = users + inbound.userNameList = userNameList + inbound.userManager = userManager inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -111,9 +131,33 @@ func (h *MultiInbound) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + if h.userManager != nil { + go h.credentialLoop() + } return h.listener.Start() } +func (h *MultiInbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeShadowsocks) + if err != nil || len(creds) == 0 { + continue + } + userIndices := make([]int, len(creds)) + userPasswords := make([]string, len(creds)) + userNameList := make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userPasswords[i] = c.Credential + userNameList[i] = c.Username + } + h.service.UpdateUsersWithPasswords(userIndices, userPasswords) + h.userNameList = userNameList + } +} + func (h *MultiInbound) Close() error { return h.listener.Close() } @@ -163,7 +207,7 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { @@ -186,7 +230,7 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon if !loaded { return os.ErrInvalid } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { diff --git a/protocol/socks/inbound.go b/protocol/socks/inbound.go index 0f570b51..d81a4580 100644 --- a/protocol/socks/inbound.go +++ b/protocol/socks/inbound.go @@ -18,6 +18,7 @@ import ( "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/protocol/socks" + "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -33,6 +34,7 @@ type Inbound struct { listener *listener.Listener authenticator *auth.Authenticator udpTimeout time.Duration + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SocksInboundOptions) (adapter.Inbound, error) { @@ -42,12 +44,25 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo } else { udpTimeout = C.UDPTimeout } + authenticator := auth.NewAuthenticator(options.Users) + userManager := service.FromContext[adapter.UserManager](ctx) + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeSOCKS) + if err == nil && len(creds) > 0 { + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + authenticator = auth.NewAuthenticator(users) + } + } inbound := &Inbound{ Adapter: inbound.NewAdapter(C.TypeSOCKS, tag), router: uot.NewRouter(router, logger), logger: logger, - authenticator: auth.NewAuthenticator(options.Users), + authenticator: authenticator, udpTimeout: udpTimeout, + userManager: userManager, } inbound.listener = listener.New(listener.Options{ Context: ctx, @@ -63,9 +78,28 @@ func (h *Inbound) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } + if h.userManager != nil { + go h.credentialLoop() + } return h.listener.Start() } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeSOCKS) + if err != nil || len(creds) == 0 { + continue + } + users := make([]auth.User, len(creds)) + for i, c := range creds { + users[i] = auth.User{Username: c.Username, Password: c.Credential} + } + h.authenticator = auth.NewAuthenticator(users) + } +} + func (h *Inbound) Close() error { return h.listener.Close() } diff --git a/protocol/trojan/inbound.go b/protocol/trojan/inbound.go index 920589b4..8f48e85c 100644 --- a/protocol/trojan/inbound.go +++ b/protocol/trojan/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -21,6 +22,7 @@ import ( F "github.com/sagernet/sing/common/format" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + sService "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -36,6 +38,8 @@ type Inbound struct { listener *listener.Listener service *trojan.Service[int] users []option.TrojanUser + userNameList []string + userManager adapter.UserManager tlsConfig tls.ServerConfig fallbackAddr M.Socksaddr fallbackAddrTLSNextProto map[string]M.Socksaddr @@ -87,13 +91,40 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo fallbackHandler = adapter.NewUpstreamContextHandler(inbound.fallbackConnection, nil) } service := trojan.NewService[int](adapter.NewUpstreamContextHandler(inbound.newConnection, inbound.newPacketConnection), fallbackHandler, logger) - err := service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int { - return index - }), common.Map(options.Users, func(it option.TrojanUser) string { - return it.Password - })) - if err != nil { - return nil, err + var err error + userManager := sService.FromContext[adapter.UserManager](ctx) + + var userNameList []string + if userManager != nil { + creds, uErr := userManager.GetCredentials(C.TypeTrojan) + if uErr == nil && len(creds) > 0 { + userIndices := make([]int, len(creds)) + userPasswords := make([]string, len(creds)) + userNameList = make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userPasswords[i] = c.Credential + userNameList[i] = c.Username + } + uErr = service.UpdateUsers(userIndices, userPasswords) + if uErr != nil { + return nil, uErr + } + } + inbound.userManager = userManager + } else { + userNameList = make([]string, len(options.Users)) + for i, u := range options.Users { + userNameList[i] = u.Name + } + err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.TrojanUser) int { + return index + }), common.Map(options.Users, func(it option.TrojanUser) string { + return it.Password + })) + if err != nil { + return nil, err + } } if options.Transport != nil { inbound.transport, err = v2ray.NewServerTransport(ctx, logger, common.PtrValueOrDefault(options.Transport), inbound.tlsConfig, (*inboundTransportHandler)(inbound)) @@ -106,6 +137,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, err } inbound.service = service + inbound.userNameList = userNameList inbound.listener = listener.New(listener.Options{ Context: ctx, Logger: logger, @@ -126,6 +158,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return E.Cause(err, "create TLS config") } } + if h.userManager != nil { + go h.credentialLoop() + } if h.transport == nil { return h.listener.Start() } @@ -156,6 +191,27 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeTrojan) + if err != nil || len(creds) == 0 { + continue + } + userIndices := make([]int, len(creds)) + userPasswords := make([]string, len(creds)) + userNameList := make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userPasswords[i] = c.Credential + userNameList[i] = c.Username + } + h.service.UpdateUsers(userIndices, userPasswords) + h.userNameList = userNameList + } +} + func (h *Inbound) Close() error { return common.Close( h.listener, @@ -189,7 +245,7 @@ func (h *Inbound) newConnection(ctx context.Context, conn net.Conn, metadata ada N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { @@ -207,7 +263,7 @@ func (h *Inbound) newPacketConnection(ctx context.Context, conn N.PacketConn, me N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { diff --git a/protocol/tuic/inbound.go b/protocol/tuic/inbound.go index 531d4263..18dfc9ad 100644 --- a/protocol/tuic/inbound.go +++ b/protocol/tuic/inbound.go @@ -3,6 +3,7 @@ package tuic import ( "context" "net" + "strings" "time" "github.com/sagernet/sing-box/adapter" @@ -20,6 +21,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + sService "github.com/sagernet/sing/service" "github.com/gofrs/uuid/v5" ) @@ -36,6 +38,7 @@ type Inbound struct { tlsConfig tls.ServerConfig server *tuic.Service[int] userNameList []string + userManager adapter.UserManager } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TUICInboundOptions) (adapter.Inbound, error) { @@ -87,26 +90,61 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo if err != nil { return nil, err } - var userList []int - var userNameList []string - var userUUIDList [][16]byte - var userPasswordList []string - for index, user := range options.Users { - if user.UUID == "" { - return nil, E.New("missing uuid for user ", index) + userManager := sService.FromContext[adapter.UserManager](ctx) + + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeTUIC) + if err == nil && len(creds) > 0 { + userList := make([]int, 0, len(creds)) + userNameList := make([]string, 0, len(creds)) + userUUIDList := make([][16]byte, 0, len(creds)) + userPasswordList := make([]string, 0, len(creds)) + for i, c := range creds { + parts := strings.SplitN(c.Credential, ":", 2) + if len(parts) < 1 || parts[0] == "" { + continue + } + userUUID, pErr := uuid.FromString(parts[0]) + if pErr != nil { + continue + } + password := "" + if len(parts) == 2 { + password = parts[1] + } + userList = append(userList, i) + userNameList = append(userNameList, c.Username) + userUUIDList = append(userUUIDList, userUUID) + userPasswordList = append(userPasswordList, password) + } + if len(userList) > 0 { + service.UpdateUsers(userList, userUUIDList, userPasswordList) + inbound.userNameList = userNameList + } } - userUUID, err := uuid.FromString(user.UUID) - if err != nil { - return nil, E.Cause(err, "invalid uuid for user ", index) + inbound.userManager = userManager + } else { + userList := make([]int, 0, len(options.Users)) + userNameList := make([]string, 0, len(options.Users)) + userUUIDList := make([][16]byte, 0, len(options.Users)) + userPasswordList := make([]string, 0, len(options.Users)) + for index, user := range options.Users { + if user.UUID == "" { + return nil, E.New("missing uuid for user ", index) + } + userUUID, err := uuid.FromString(user.UUID) + if err != nil { + return nil, E.Cause(err, "invalid uuid for user ", index) + } + userList = append(userList, index) + userNameList = append(userNameList, user.Name) + userUUIDList = append(userUUIDList, userUUID) + userPasswordList = append(userPasswordList, user.Password) } - userList = append(userList, index) - userNameList = append(userNameList, user.Name) - userUUIDList = append(userUUIDList, userUUID) - userPasswordList = append(userPasswordList, user.Password) + service.UpdateUsers(userList, userUUIDList, userPasswordList) + inbound.userNameList = userNameList } - service.UpdateUsers(userList, userUUIDList, userPasswordList) inbound.server = service - inbound.userNameList = userNameList return inbound, nil } @@ -164,6 +202,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return err } } + if h.userManager != nil { + go h.credentialLoop() + } packetConn, err := h.listener.ListenUDP() if err != nil { return err @@ -171,6 +212,43 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return h.server.Start(packetConn) } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeTUIC) + if err != nil || len(creds) == 0 { + continue + } + userList := make([]int, 0, len(creds)) + userNameList := make([]string, 0, len(creds)) + userUUIDList := make([][16]byte, 0, len(creds)) + userPasswordList := make([]string, 0, len(creds)) + for i, c := range creds { + parts := strings.SplitN(c.Credential, ":", 2) + if len(parts) < 1 || parts[0] == "" { + continue + } + userUUID, pErr := uuid.FromString(parts[0]) + if pErr != nil { + continue + } + password := "" + if len(parts) == 2 { + password = parts[1] + } + userList = append(userList, i) + userNameList = append(userNameList, c.Username) + userUUIDList = append(userUUIDList, userUUID) + userPasswordList = append(userPasswordList, password) + } + if len(userList) > 0 { + h.server.UpdateUsers(userList, userUUIDList, userPasswordList) + h.userNameList = userNameList + } + } +} + func (h *Inbound) Close() error { return common.Close( h.listener, diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go index 1589bfb0..d6209404 100644 --- a/protocol/vless/inbound.go +++ b/protocol/vless/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -25,6 +26,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + sService "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -35,14 +37,16 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - users []option.VLESSUser - service *vless.Service[int] - tlsConfig tls.ServerConfig - transport adapter.V2RayServerTransport + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + users []option.VLESSUser + userNameList []string + userManager adapter.UserManager + service *vless.Service[int] + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { @@ -59,14 +63,40 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo return nil, err } service := vless.NewService[int](logger, adapter.NewUpstreamContextHandler(inbound.newConnectionEx, inbound.newPacketConnectionEx)) - service.UpdateUsers(common.MapIndexed(inbound.users, func(index int, _ option.VLESSUser) int { - return index - }), common.Map(inbound.users, func(it option.VLESSUser) string { - return it.UUID - }), common.Map(inbound.users, func(it option.VLESSUser) string { - return it.Flow - })) + userManager := sService.FromContext[adapter.UserManager](ctx) + + var userNameList []string + if userManager != nil { + creds, err := userManager.GetCredentials(C.TypeVLESS) + if err == nil && len(creds) > 0 { + userIndices := make([]int, len(creds)) + userUUIDs := make([]string, len(creds)) + userFlows := make([]string, len(creds)) + userNameList = make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userUUIDs[i] = c.Credential + userFlows[i] = "" + userNameList[i] = c.Username + } + service.UpdateUsers(userIndices, userUUIDs, userFlows) + } + inbound.userManager = userManager + } else { + userNameList = make([]string, len(inbound.users)) + for i, u := range inbound.users { + userNameList[i] = u.Name + } + service.UpdateUsers(common.MapIndexed(inbound.users, func(index int, _ option.VLESSUser) int { + return index + }), common.Map(inbound.users, func(it option.VLESSUser) string { + return it.UUID + }), common.Map(inbound.users, func(it option.VLESSUser) string { + return it.Flow + })) + } inbound.service = service + inbound.userNameList = userNameList if options.TLS != nil { inbound.tlsConfig, err = tls.NewServerWithOptions(tls.ServerOptions{ Context: ctx, @@ -108,6 +138,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return err } } + if h.userManager != nil { + go h.credentialLoop() + } if h.transport == nil { return h.listener.Start() } @@ -138,6 +171,29 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeVLESS) + if err != nil || len(creds) == 0 { + continue + } + userIndices := make([]int, len(creds)) + userUUIDs := make([]string, len(creds)) + userFlows := make([]string, len(creds)) + userNameList := make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userUUIDs[i] = c.Credential + userFlows[i] = "" + userNameList[i] = c.Username + } + h.service.UpdateUsers(userIndices, userUUIDs, userFlows) + h.userNameList = userNameList + } +} + func (h *Inbound) Close() error { return common.Close( h.service, @@ -172,7 +228,7 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { @@ -190,7 +246,7 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go index 8783d3e3..b3b85beb 100644 --- a/protocol/vmess/inbound.go +++ b/protocol/vmess/inbound.go @@ -4,6 +4,7 @@ import ( "context" "net" "os" + "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" @@ -26,6 +27,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/common/ntp" + sService "github.com/sagernet/sing/service" ) func RegisterInbound(registry *inbound.Registry) { @@ -36,14 +38,16 @@ var _ adapter.TCPInjectableInbound = (*Inbound)(nil) type Inbound struct { inbound.Adapter - ctx context.Context - router adapter.ConnectionRouterEx - logger logger.ContextLogger - listener *listener.Listener - service *vmess.Service[int] - users []option.VMessUser - tlsConfig tls.ServerConfig - transport adapter.V2RayServerTransport + ctx context.Context + router adapter.ConnectionRouterEx + logger logger.ContextLogger + listener *listener.Listener + service *vmess.Service[int] + users []option.VMessUser + userNameList []string + userManager adapter.UserManager + tlsConfig tls.ServerConfig + transport adapter.V2RayServerTransport } func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) { @@ -67,17 +71,46 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo serviceOptions = append(serviceOptions, vmess.ServiceWithDisableHeaderProtection()) } service := vmess.NewService[int](adapter.NewUpstreamContextHandler(inbound.newConnectionEx, inbound.newPacketConnectionEx), serviceOptions...) - inbound.service = service - err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.VMessUser) int { - return index - }), common.Map(options.Users, func(it option.VMessUser) string { - return it.UUID - }), common.Map(options.Users, func(it option.VMessUser) int { - return it.AlterId - })) - if err != nil { - return nil, err + userManager := sService.FromContext[adapter.UserManager](ctx) + + var userNameList []string + if userManager != nil { + creds, uErr := userManager.GetCredentials(C.TypeVMess) + if uErr == nil && len(creds) > 0 { + userIndices := make([]int, len(creds)) + userUUIDs := make([]string, len(creds)) + userAlterIds := make([]int, len(creds)) + userNameList = make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userUUIDs[i] = c.Credential + userAlterIds[i] = 0 + userNameList[i] = c.Username + } + uErr = service.UpdateUsers(userIndices, userUUIDs, userAlterIds) + if uErr != nil { + return nil, uErr + } + } + inbound.userManager = userManager + } else { + userNameList = make([]string, len(options.Users)) + for i, u := range options.Users { + userNameList[i] = u.Name + } + err = service.UpdateUsers(common.MapIndexed(options.Users, func(index int, it option.VMessUser) int { + return index + }), common.Map(options.Users, func(it option.VMessUser) string { + return it.UUID + }), common.Map(options.Users, func(it option.VMessUser) int { + return it.AlterId + })) + if err != nil { + return nil, err + } } + inbound.service = service + inbound.userNameList = userNameList if options.TLS != nil { inbound.tlsConfig, err = tls.NewServer(ctx, logger, common.PtrValueOrDefault(options.TLS)) if err != nil { @@ -114,6 +147,9 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return err } } + if h.userManager != nil { + go h.credentialLoop() + } if h.transport == nil { return h.listener.Start() } @@ -144,6 +180,29 @@ func (h *Inbound) Start(stage adapter.StartStage) error { return nil } +func (h *Inbound) credentialLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + creds, err := h.userManager.GetCredentials(C.TypeVMess) + if err != nil || len(creds) == 0 { + continue + } + userIndices := make([]int, len(creds)) + userUUIDs := make([]string, len(creds)) + userAlterIds := make([]int, len(creds)) + userNameList := make([]string, len(creds)) + for i, c := range creds { + userIndices[i] = i + userUUIDs[i] = c.Credential + userAlterIds[i] = 0 + userNameList[i] = c.Username + } + h.service.UpdateUsers(userIndices, userUUIDs, userAlterIds) + h.userNameList = userNameList + } +} + func (h *Inbound) Close() error { return common.Close( h.service, @@ -178,7 +237,7 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { @@ -196,7 +255,7 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn, N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) return } - user := h.users[userIndex].Name + user := h.userNameList[userIndex] if user == "" { user = F.ToString(userIndex) } else { diff --git a/service/usermanager/manager.go b/service/usermanager/manager.go new file mode 100644 index 00000000..250e3a46 --- /dev/null +++ b/service/usermanager/manager.go @@ -0,0 +1,662 @@ +package usermanager + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/sagernet/sing-box/adapter" + boxService "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +var _ adapter.UserManager = (*Manager)(nil) +var _ adapter.ConnectionTracker = (*Manager)(nil) + +type authCacheEntry struct { + username string + expiresAt time.Time +} + +type userConnCounter struct { + username string + tx int64 + rx int64 + done chan struct{} +} + +type Manager struct { + boxService.Adapter + ctx context.Context + cancel context.CancelFunc + logger log.ContextLogger + + authServer string + timeout time.Duration + cacheTTL time.Duration + refreshInt time.Duration + reportInt time.Duration + reportTraffic bool + apiSecret string + apiListen string + + httpClient *http.Client + + access sync.RWMutex + authCache map[string]*authCacheEntry + userCreds map[string]string + userConns map[string]map[*userConnCounter]struct{} + + protocolUsers map[string][]adapter.UserInfo + protocolLastSeq map[string]int64 + + trafficAccess sync.Mutex + traffic map[string]*UserTrafficAtomic + + stopRefresh chan struct{} + stopReport chan struct{} + stopAPI chan struct{} +} + +type UserTrafficAtomic struct { + Tx atomic.Int64 + Rx atomic.Int64 +} + +func New(ctx context.Context, logger log.ContextLogger, tag string, options option.UserManagerOptions) (*Manager, error) { + ctx, cancel := context.WithCancel(ctx) + m := &Manager{ + Adapter: boxService.NewAdapter(C.TypeUserManager, tag), + ctx: ctx, + cancel: cancel, + logger: logger, + authServer: options.AuthServer, + timeout: time.Duration(options.Timeout), + cacheTTL: time.Duration(options.CacheTTL), + refreshInt: time.Duration(options.RefreshInterval), + reportInt: time.Duration(options.ReportInterval), + reportTraffic: options.ReportTraffic, + apiSecret: options.APISecret, + apiListen: options.APIListen, + httpClient: &http.Client{ + Timeout: time.Duration(options.Timeout), + }, + authCache: make(map[string]*authCacheEntry), + userCreds: make(map[string]string), + userConns: make(map[string]map[*userConnCounter]struct{}), + protocolUsers: make(map[string][]adapter.UserInfo), + protocolLastSeq: make(map[string]int64), + traffic: make(map[string]*UserTrafficAtomic), + stopRefresh: make(chan struct{}), + stopReport: make(chan struct{}), + stopAPI: make(chan struct{}), + } + if m.timeout <= 0 { + m.timeout = 10 * time.Second + } + if m.cacheTTL <= 0 { + m.cacheTTL = 5 * time.Minute + } + if m.refreshInt <= 0 { + m.refreshInt = 30 * time.Second + } + if m.reportInt <= 0 { + m.reportInt = 60 * time.Second + } + return m, nil +} + +func (m *Manager) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + m.logger.Info("starting user manager") + + if err := m.refreshCredentials(); err != nil { + m.logger.Warn("initial credential fetch failed: ", err) + } + + router := service.FromContext[adapter.Router](m.ctx) + if router != nil { + router.AppendTracker(m) + m.logger.Debug("registered as connection tracker") + } + + go func() { + ticker := time.NewTicker(m.refreshInt) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := m.refreshCredentials(); err != nil { + m.logger.Warn("credential refresh failed: ", err) + } + case <-m.stopRefresh: + return + } + } + }() + + if m.reportTraffic && m.authServer != "" { + go func() { + ticker := time.NewTicker(m.reportInt) + defer ticker.Stop() + select { + case <-time.After(10 * time.Second): + case <-m.stopReport: + return + } + for { + select { + case <-ticker.C: + m.reportTrafficToServer() + case <-m.stopReport: + return + } + } + }() + } + + if m.apiListen != "" { + go m.serveAPI() + } + + return nil +} + +func (m *Manager) Close() error { + m.cancel() + close(m.stopRefresh) + close(m.stopReport) + close(m.stopAPI) + return nil +} + +func (m *Manager) Authenticate(ctx context.Context, protocol string, credential string, addr string) (string, error) { + if credential == "" { + return "", E.New("empty credential") + } + + m.access.RLock() + entry, cached := m.authCache[credential] + m.access.RUnlock() + if cached && time.Now().Before(entry.expiresAt) { + return entry.username, nil + } + + username, err := m.remoteAuth(protocol, credential, addr) + if err != nil { + return "", err + } + + m.access.Lock() + m.authCache[credential] = &authCacheEntry{ + username: username, + expiresAt: time.Now().Add(m.cacheTTL), + } + m.userCreds[username] = credential + m.access.Unlock() + + return username, nil +} + +type authRequest struct { + Protocol string `json:"protocol"` + Credential string `json:"credential"` + Addr string `json:"addr,omitempty"` +} + +type authResponse struct { + Ok bool `json:"ok"` + ID string `json:"id,omitempty"` +} + +func (m *Manager) remoteAuth(protocol string, credential string, addr string) (string, error) { + if m.authServer == "" { + return "", E.New("auth server not configured") + } + req := authRequest{ + Protocol: protocol, + Credential: credential, + Addr: addr, + } + data, err := json.Marshal(req) + if err != nil { + return "", E.Cause(err, "marshal auth request") + } + httpReq, err := http.NewRequestWithContext(m.ctx, "POST", m.authServer+"/api/auth", bytes.NewReader(data)) + if err != nil { + return "", E.Cause(err, "create auth request") + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := m.httpClient.Do(httpReq) + if err != nil { + return "", E.Cause(err, "auth request failed") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { + return "", E.New("authentication rejected") + } + if resp.StatusCode != http.StatusOK { + return "", E.New("auth server returned ", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", E.Cause(err, "read auth response") + } + var authResp authResponse + if err := json.Unmarshal(body, &authResp); err != nil { + return "", E.Cause(err, "parse auth response") + } + if !authResp.Ok { + return "", E.New("authentication rejected") + } + if authResp.ID == "" { + return "", E.New("auth response missing user ID") + } + return authResp.ID, nil +} + +func (m *Manager) GetCredentials(protocol string) ([]adapter.UserInfo, error) { + m.access.RLock() + defer m.access.RUnlock() + users := m.protocolUsers[protocol] + if users == nil { + return nil, nil + } + result := make([]adapter.UserInfo, len(users)) + copy(result, users) + return result, nil +} + +type credentialsResponse struct { + Credentials []credentialEntry `json:"credentials"` +} + +type credentialEntry struct { + Protocol string `json:"protocol"` + Credential string `json:"credential"` + Username string `json:"username"` +} + +func (m *Manager) refreshCredentials() error { + if m.authServer == "" { + return nil + } + httpReq, err := http.NewRequestWithContext(m.ctx, "GET", m.authServer+"/api/credentials", nil) + if err != nil { + return err + } + resp, err := m.httpClient.Do(httpReq) + if err != nil { + return E.Cause(err, "fetch credentials") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return E.New("credentials server returned ", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return E.Cause(err, "read credentials response") + } + var credResp credentialsResponse + if err := json.Unmarshal(body, &credResp); err != nil { + return E.Cause(err, "parse credentials response") + } + + newCache := make(map[string]*authCacheEntry) + newUserCreds := make(map[string]string) + byProtocol := make(map[string][]adapter.UserInfo) + + for _, c := range credResp.Credentials { + newCache[c.Credential] = &authCacheEntry{ + username: c.Username, + expiresAt: time.Now().Add(m.cacheTTL), + } + newUserCreds[c.Username] = c.Credential + byProtocol[c.Protocol] = append(byProtocol[c.Protocol], adapter.UserInfo{ + Username: c.Username, + Credential: c.Credential, + }) + } + + m.access.Lock() + for k, v := range newCache { + if _, exists := m.authCache[k]; !exists { + m.authCache[k] = v + } + } + for k, v := range newUserCreds { + m.userCreds[k] = v + } + m.protocolUsers = byProtocol + m.access.Unlock() + + return nil +} + +func (m *Manager) ReportTraffic(username string, tx int64, rx int64) { + if username == "" { + return + } + m.trafficAccess.Lock() + t, ok := m.traffic[username] + if !ok { + t = &UserTrafficAtomic{} + m.traffic[username] = t + } + m.trafficAccess.Unlock() + if tx > 0 { + t.Tx.Add(tx) + } + if rx > 0 { + t.Rx.Add(rx) + } +} + +func (m *Manager) GetTraffic(username string) (int64, int64) { + m.trafficAccess.Lock() + t, ok := m.traffic[username] + m.trafficAccess.Unlock() + if !ok { + return 0, 0 + } + return t.Tx.Load(), t.Rx.Load() +} + +func (m *Manager) ListTraffic() map[string]adapter.UserTraffic { + m.trafficAccess.Lock() + defer m.trafficAccess.Unlock() + result := make(map[string]adapter.UserTraffic, len(m.traffic)) + for username, t := range m.traffic { + result[username] = adapter.UserTraffic{ + Tx: t.Tx.Load(), + Rx: t.Rx.Load(), + } + } + return result +} + +func (m *Manager) KickUser(username string) { + m.access.Lock() + defer m.access.Unlock() + cred, ok := m.userCreds[username] + if ok { + delete(m.authCache, cred) + delete(m.userCreds, username) + } + for proto, users := range m.protocolUsers { + filtered := make([]adapter.UserInfo, 0, len(users)) + for _, u := range users { + if u.Username != username { + filtered = append(filtered, u) + } + } + m.protocolUsers[proto] = filtered + } + m.logger.Info("kicked user ", username) +} + +// ConnectionTracker implementation + +func (m *Manager) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) net.Conn { + username := metadata.User + if username == "" { + return conn + } + counter := &userConnCounter{ + username: username, + done: make(chan struct{}), + } + m.access.Lock() + if m.userConns[username] == nil { + m.userConns[username] = make(map[*userConnCounter]struct{}) + } + m.userConns[username][counter] = struct{}{} + m.access.Unlock() + + wrapped := bufio.NewCounterConn(conn, + []N.CountFunc{func(n int64) { counter.tx += n }}, + []N.CountFunc{func(n int64) { counter.rx += n }}, + ) + + return &trackedConn{ + ExtendedConn: wrapped, + onClose: func() { + m.ReportTraffic(username, counter.tx, counter.rx) + m.access.Lock() + if conns, ok := m.userConns[username]; ok { + delete(conns, counter) + if len(conns) == 0 { + delete(m.userConns, username) + } + } + m.access.Unlock() + }, + } +} + +func (m *Manager) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule, matchOutbound adapter.Outbound) N.PacketConn { + username := metadata.User + if username == "" { + return conn + } + counter := &userConnCounter{ + username: username, + done: make(chan struct{}), + } + m.access.Lock() + if m.userConns[username] == nil { + m.userConns[username] = make(map[*userConnCounter]struct{}) + } + m.userConns[username][counter] = struct{}{} + m.access.Unlock() + + wrapped := bufio.NewCounterPacketConn(conn, + []N.CountFunc{func(n int64) { counter.tx += n }}, + []N.CountFunc{func(n int64) { counter.rx += n }}, + ) + + return &trackedPacketConn{ + PacketConn: wrapped, + onClose: func() { + m.ReportTraffic(username, counter.tx, counter.rx) + m.access.Lock() + if conns, ok := m.userConns[username]; ok { + delete(conns, counter) + if len(conns) == 0 { + delete(m.userConns, username) + } + } + m.access.Unlock() + }, + } +} + +type trackedConn struct { + N.ExtendedConn + onClose func() +} + +func (c *trackedConn) Close() error { + c.onClose() + return c.ExtendedConn.Close() +} + +func (c *trackedConn) Upstream() any { + return c.ExtendedConn +} + +func (c *trackedConn) ReaderReplaceable() bool { + return true +} + +func (c *trackedConn) WriterReplaceable() bool { + return true +} + +type trackedPacketConn struct { + N.PacketConn + onClose func() +} + +func (c *trackedPacketConn) Close() error { + c.onClose() + return c.PacketConn.Close() +} + +func (c *trackedPacketConn) Upstream() any { + return c.PacketConn +} + +func (c *trackedPacketConn) ReaderReplaceable() bool { + return true +} + +func (c *trackedPacketConn) WriterReplaceable() bool { + return true +} + +// Traffic report + +type trafficReport struct { + Traffic map[string]adapter.UserTraffic `json:"traffic"` +} + +func (m *Manager) reportTrafficToServer() { + traffic := m.ListTraffic() + if len(traffic) == 0 { + return + } + report := trafficReport{Traffic: traffic} + data, err := json.Marshal(report) + if err != nil { + m.logger.Warn("marshal traffic report: ", err) + return + } + httpReq, err := http.NewRequestWithContext(m.ctx, "POST", m.authServer+"/api/traffic", bytes.NewReader(data)) + if err != nil { + m.logger.Warn("create traffic report: ", err) + return + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := m.httpClient.Do(httpReq) + if err != nil { + m.logger.Warn("traffic report failed: ", err) + return + } + resp.Body.Close() + m.logger.Debug("traffic reported, users=", len(traffic)) +} + +// HTTP API + +func (m *Manager) serveAPI() { + mux := http.NewServeMux() + mux.HandleFunc("/traffic", m.handleTraffic) + mux.HandleFunc("/traffic/", m.handleTrafficUser) + mux.HandleFunc("/kick/", m.handleKick) + + apiSecret := m.apiSecret + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if apiSecret != "" { + auth := r.Header.Get("Authorization") + expected := "Bearer " + apiSecret + if subtle.ConstantTimeCompare([]byte(auth), []byte(expected)) != 1 { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + mux.ServeHTTP(w, r) + }) + + server := &http.Server{ + Addr: m.apiListen, + Handler: handler, + } + + listener, err := net.Listen("tcp", m.apiListen) + if err != nil { + m.logger.Error("api listen: ", err) + return + } + m.logger.Info("user manager API listening on ", m.apiListen) + + err = server.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + m.logger.Error("api serve: ", err) + } +} + +func (m *Manager) handleTraffic(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + traffic := m.ListTraffic() + writeJSON(w, http.StatusOK, map[string]any{"users": traffic}) +} + +func (m *Manager) handleTrafficUser(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + username := r.URL.Path[len("/traffic/"):] + if username == "" { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + tx, rx := m.GetTraffic(username) + writeJSON(w, http.StatusOK, map[string]any{"tx": tx, "rx": rx}) +} + +func (m *Manager) handleKick(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + username := r.URL.Path[len("/kick/"):] + if username == "" { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + m.KickUser(username) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func (m *Manager) GetCachedUsers() []string { + m.access.RLock() + defer m.access.RUnlock() + users := make([]string, 0, len(m.userCreds)) + for u := range m.userCreds { + users = append(users, u) + } + return users +} + +var _ = fmt.Sprintf +var _ = io.Discard diff --git a/service/usermanager/registry.go b/service/usermanager/registry.go new file mode 100644 index 00000000..cc831bf2 --- /dev/null +++ b/service/usermanager/registry.go @@ -0,0 +1,18 @@ +package usermanager + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" +) + +func RegisterService(registry *service.Registry) { + service.Register[option.UserManagerOptions](registry, "user_manager", NewUserManager) +} + +func NewUserManager(ctx context.Context, logger log.ContextLogger, tag string, options option.UserManagerOptions) (adapter.Service, error) { + return New(ctx, logger, tag, options) +}