Some checks failed
Build / Calculate version (push) Has been cancelled
Build / Build binary (push) Has been cancelled
Build / Build Darwin binaries (push) Has been cancelled
Build / Build Windows binaries (push) Has been cancelled
Build / Build Android (push) Has been cancelled
Build / Publish Android (push) Has been cancelled
Build / Build Apple clients (push) Has been cancelled
Build / Upload builds (push) Has been cancelled
- Remove second user_manager creation via serviceManager.Create to prevent double-counting (both instances tracked the same connections) - Reset per-user atomic counters after each successful traffic report so the control panel receives independent per-interval deltas instead of unbounded cumulative values
689 lines
16 KiB
Go
689 lines
16 KiB
Go
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: options.CacheTTL.Build(),
|
|
refreshInt: options.RefreshInterval.Build(),
|
|
reportInt: options.ReportInterval.Build(),
|
|
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")
|
|
|
|
m.logger.Debug("running initial credential fetch")
|
|
if err := m.refreshCredentials(); err != nil {
|
|
m.logger.Warn("initial credential fetch failed: ", err)
|
|
} else {
|
|
m.logger.Debug("initial credential fetch succeeded, protocols=", len(m.protocolUsers))
|
|
}
|
|
|
|
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) Name() string {
|
|
return "user_manager[" + m.Tag() + "]"
|
|
}
|
|
|
|
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
|
|
}
|
|
if m.apiSecret != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+m.apiSecret)
|
|
}
|
|
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")
|
|
if m.apiSecret != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+m.apiSecret)
|
|
}
|
|
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))
|
|
|
|
// Reset counters after successful report so the next report only
|
|
// contains fresh traffic (delta since last report).
|
|
// This avoids overcounting when multiple nodes or user_manager
|
|
// instances report for the same user — the control panel always
|
|
// receives independent per-interval values.
|
|
m.trafficAccess.Lock()
|
|
for username := range traffic {
|
|
if stored, ok := m.traffic[username]; ok {
|
|
stored.Tx.Add(-traffic[username].Tx)
|
|
stored.Rx.Add(-traffic[username].Rx)
|
|
}
|
|
}
|
|
m.trafficAccess.Unlock()
|
|
}
|
|
|
|
// 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
|