600 lines
16 KiB
Go
600 lines
16 KiB
Go
package singbox
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/sagernet/sing-box/adapter"
|
|
"github.com/sagernet/sing/common/buf"
|
|
singM "github.com/sagernet/sing/common/metadata"
|
|
N "github.com/sagernet/sing/common/network"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
const rateLimitThreshold = 64 * 1024
|
|
|
|
// ─── Per-user statistics ────────────────────────────────────────────────────
|
|
|
|
// userStats holds per-user traffic counters and alive IP tracking.
|
|
// Traffic counters are lock-free atomics (read on every packet).
|
|
// IP tracking uses a lightweight mutex (only touched at connect/disconnect).
|
|
type userStats struct {
|
|
upload atomic.Int64
|
|
download atomic.Int64
|
|
|
|
mu sync.Mutex
|
|
ips map[string]int // sourceIP → refcount (number of active conns from that IP)
|
|
connCount int // total active connections
|
|
}
|
|
|
|
// addConn registers a new connection from sourceIP.
|
|
func (u *userStats) addConn(sourceIP string) {
|
|
u.mu.Lock()
|
|
u.connCount++
|
|
u.ips[sourceIP]++
|
|
u.mu.Unlock()
|
|
}
|
|
|
|
// removeConn unregisters a connection from sourceIP.
|
|
func (u *userStats) removeConn(sourceIP string) {
|
|
u.mu.Lock()
|
|
u.connCount--
|
|
u.ips[sourceIP]--
|
|
if u.ips[sourceIP] <= 0 {
|
|
delete(u.ips, sourceIP)
|
|
}
|
|
u.mu.Unlock()
|
|
}
|
|
|
|
// distinctIPs returns the number of distinct IPs currently connected.
|
|
func (u *userStats) distinctIPs() int {
|
|
u.mu.Lock()
|
|
n := len(u.ips)
|
|
u.mu.Unlock()
|
|
return n
|
|
}
|
|
|
|
// ipSnapshot returns a copy of the current IP set (for device gate-keeping).
|
|
func (u *userStats) ipSnapshot() map[string]struct{} {
|
|
u.mu.Lock()
|
|
snapshot := make(map[string]struct{}, len(u.ips))
|
|
for ip := range u.ips {
|
|
snapshot[ip] = struct{}{}
|
|
}
|
|
u.mu.Unlock()
|
|
return snapshot
|
|
}
|
|
|
|
// aliveIPList returns the set of alive IPs as a boolean map (for reporting).
|
|
func (u *userStats) aliveIPList() map[string]bool {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
if len(u.ips) == 0 {
|
|
return nil
|
|
}
|
|
result := make(map[string]bool, len(u.ips))
|
|
for ip := range u.ips {
|
|
result[ip] = true
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ─── ConnTracker ────────────────────────────────────────────────────────────
|
|
|
|
// ConnTracker provides per-user traffic counting, alive IP tracking, device
|
|
// limit gate-keeping, and per-user speed limiting for the sing-box kernel.
|
|
//
|
|
// Architecture: instead of maintaining a per-connection map (O(connections)),
|
|
// it maintains per-user atomic counters and IP refcount sets (O(users)).
|
|
// This means:
|
|
// - GetUserTraffic is O(users), not O(connections)
|
|
// - No Snapshot/pending buffer needed
|
|
// - No per-connection map lock contention
|
|
// - Bytes are counted directly into per-user atomics via CountFunc callbacks
|
|
//
|
|
// The per-connection wrapper (trackedConn) is still needed for:
|
|
// - Hooking into sing's ReadCounter/WriteCounter for zero-copy byte counting
|
|
// - Close callback to decrement IP refcounts
|
|
// - Per-connection rate limit token accumulation (amortized WaitN)
|
|
type ConnTracker struct {
|
|
usersMu sync.RWMutex
|
|
users map[int]*userStats // userID → stats
|
|
uuidMap map[string]int // UUID → userID (for lookup in RoutedConnection)
|
|
connMap map[string]net.Conn // connID → conn (only for force-close support)
|
|
|
|
idCounter atomic.Int64
|
|
|
|
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
|
|
speedLimitFunc atomic.Pointer[func(uuid string) *rate.Limiter]
|
|
|
|
// deviceLimitFunc resolves a user UUID to their device limit.
|
|
deviceLimitFunc atomic.Pointer[func(uuid string) (int, bool)]
|
|
}
|
|
|
|
// NewConnTracker creates a tracker.
|
|
func NewConnTracker(_ int) *ConnTracker {
|
|
return &ConnTracker{
|
|
users: make(map[int]*userStats),
|
|
uuidMap: make(map[string]int),
|
|
connMap: make(map[string]net.Conn),
|
|
}
|
|
}
|
|
|
|
// SetSpeedLimitFunc configures the per-user speed limit lookup.
|
|
func (t *ConnTracker) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
|
|
t.speedLimitFunc.Store(&fn)
|
|
}
|
|
|
|
// SetDeviceLimitFunc configures the per-user device limit lookup for gate-keeping.
|
|
func (t *ConnTracker) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) {
|
|
t.deviceLimitFunc.Store(&fn)
|
|
}
|
|
|
|
// SetUserMap replaces the UUID→userID mapping and ensures per-user stats
|
|
// structs exist for all users. Old users that are no longer present keep
|
|
// their stats until their connections drain.
|
|
func (t *ConnTracker) SetUserMap(m map[string]int) {
|
|
t.usersMu.Lock()
|
|
t.uuidMap = m
|
|
for _, uid := range m {
|
|
if _, ok := t.users[uid]; !ok {
|
|
t.users[uid] = &userStats{ips: make(map[string]int)}
|
|
}
|
|
}
|
|
t.usersMu.Unlock()
|
|
}
|
|
|
|
// ─── adapter.ConnectionTracker ──────────────────────────────────────────────
|
|
|
|
// RoutedConnection wraps a TCP conn to count bytes per-user, track IPs,
|
|
// and optionally rate-limit. Gate-keeps device limits at connection time.
|
|
func (t *ConnTracker) RoutedConnection(
|
|
ctx context.Context, conn net.Conn,
|
|
metadata adapter.InboundContext,
|
|
_ adapter.Rule, _ adapter.Outbound,
|
|
) net.Conn {
|
|
uuid := metadata.User
|
|
sourceIP := metadata.Source.Addr.String()
|
|
|
|
t.usersMu.RLock()
|
|
uid := t.uuidMap[uuid]
|
|
us := t.users[uid]
|
|
t.usersMu.RUnlock()
|
|
|
|
// Gate-keep device limit
|
|
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
|
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
|
if t.checkDeviceGate(us, sourceIP, limit) {
|
|
slog.Info("singbox: device limit gate-keep, rejecting connection",
|
|
"user", uuid, "ip", sourceIP, "limit", limit)
|
|
conn.Close()
|
|
return conn
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register connection
|
|
if us != nil {
|
|
us.addConn(sourceIP)
|
|
}
|
|
|
|
connID := t.nextID()
|
|
|
|
// Store conn reference for force-close support
|
|
t.usersMu.Lock()
|
|
t.connMap[connID] = conn
|
|
t.usersMu.Unlock()
|
|
|
|
var lim *rate.Limiter
|
|
if slf := t.speedLimitFunc.Load(); slf != nil {
|
|
lim = (*slf)(uuid)
|
|
}
|
|
|
|
return &trackedConn{
|
|
Conn: conn,
|
|
tracker: t,
|
|
us: us,
|
|
connID: connID,
|
|
sourceIP: sourceIP,
|
|
limiter: lim,
|
|
ctx: ctx,
|
|
}
|
|
}
|
|
|
|
// RoutedPacketConnection wraps a UDP PacketConn with per-user byte counting.
|
|
func (t *ConnTracker) RoutedPacketConnection(
|
|
ctx context.Context, conn N.PacketConn,
|
|
metadata adapter.InboundContext,
|
|
_ adapter.Rule, _ adapter.Outbound,
|
|
) N.PacketConn {
|
|
uuid := metadata.User
|
|
sourceIP := metadata.Source.Addr.String()
|
|
|
|
t.usersMu.RLock()
|
|
uid := t.uuidMap[uuid]
|
|
us := t.users[uid]
|
|
t.usersMu.RUnlock()
|
|
|
|
if us != nil {
|
|
us.addConn(sourceIP)
|
|
}
|
|
|
|
connID := t.nextID()
|
|
|
|
var lim *rate.Limiter
|
|
if slf := t.speedLimitFunc.Load(); slf != nil {
|
|
lim = (*slf)(uuid)
|
|
}
|
|
|
|
return &trackedPacketConn{
|
|
PacketConn: conn,
|
|
tracker: t,
|
|
us: us,
|
|
connID: connID,
|
|
sourceIP: sourceIP,
|
|
limiter: lim,
|
|
ctx: ctx,
|
|
}
|
|
}
|
|
|
|
// checkDeviceGate returns true if the connection should be rejected.
|
|
// Uses the deterministic lowest-IP algorithm.
|
|
func (t *ConnTracker) checkDeviceGate(us *userStats, sourceIP string, limit int) bool {
|
|
if us == nil {
|
|
return false
|
|
}
|
|
|
|
knownIPs := us.ipSnapshot()
|
|
|
|
// Already known IP — always allow
|
|
if _, exists := knownIPs[sourceIP]; exists {
|
|
return false
|
|
}
|
|
|
|
// Under limit — allow
|
|
if len(knownIPs) < limit {
|
|
return false
|
|
}
|
|
|
|
// Over limit — deterministic: allow lowest IPs lexicographically
|
|
ipList := make([]string, 0, len(knownIPs)+1)
|
|
for ip := range knownIPs {
|
|
ipList = append(ipList, ip)
|
|
}
|
|
ipList = append(ipList, sourceIP)
|
|
sort.Strings(ipList)
|
|
|
|
for i := 0; i < limit && i < len(ipList); i++ {
|
|
if ipList[i] == sourceIP {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (t *ConnTracker) nextID() string {
|
|
return "sb-" + formatInt36(t.idCounter.Add(1))
|
|
}
|
|
|
|
func formatInt36(n int64) string {
|
|
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var buf [13]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = digits[n%36]
|
|
n /= 36
|
|
}
|
|
return string(buf[i:])
|
|
}
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
|
|
// GetUserTraffic returns per-user cumulative traffic and alive IPs.
|
|
// This is O(users), not O(connections).
|
|
func (t *ConnTracker) GetUserTraffic() (traffic map[int][2]int64, aliveIPs map[int]map[string]bool, connCount int) {
|
|
t.usersMu.RLock()
|
|
traffic = make(map[int][2]int64, len(t.users))
|
|
aliveIPs = make(map[int]map[string]bool, len(t.users))
|
|
for uid, us := range t.users {
|
|
up := us.upload.Load()
|
|
down := us.download.Load()
|
|
if up > 0 || down > 0 {
|
|
traffic[uid] = [2]int64{up, down}
|
|
}
|
|
|
|
ips := us.aliveIPList()
|
|
if ips != nil {
|
|
aliveIPs[uid] = ips
|
|
}
|
|
|
|
us.mu.Lock()
|
|
connCount += us.connCount
|
|
us.mu.Unlock()
|
|
}
|
|
t.usersMu.RUnlock()
|
|
return
|
|
}
|
|
|
|
// CloseByID force-closes a connection by its ID.
|
|
func (t *ConnTracker) CloseByID(id string) bool {
|
|
t.usersMu.RLock()
|
|
conn, ok := t.connMap[id]
|
|
t.usersMu.RUnlock()
|
|
if !ok {
|
|
return false
|
|
}
|
|
if conn != nil {
|
|
conn.Close()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// CloseByUUID force-closes ALL connections for a given user UUID.
|
|
func (t *ConnTracker) CloseByUUID(uuid string) int {
|
|
// This is a no-op for now — sing-box doesn't expose per-user connection
|
|
// kill easily. The kernel's RemoveUsers removes the inbound user which
|
|
// prevents new connections, and existing connections will fail on next I/O.
|
|
return 0
|
|
}
|
|
|
|
// ActiveCount returns the total number of active connections.
|
|
func (t *ConnTracker) ActiveCount() int {
|
|
t.usersMu.RLock()
|
|
total := 0
|
|
for _, us := range t.users {
|
|
us.mu.Lock()
|
|
total += us.connCount
|
|
us.mu.Unlock()
|
|
}
|
|
t.usersMu.RUnlock()
|
|
return total
|
|
}
|
|
|
|
// removeConnRef removes the connection reference from connMap on close.
|
|
func (t *ConnTracker) removeConnRef(connID string) {
|
|
t.usersMu.Lock()
|
|
delete(t.connMap, connID)
|
|
t.usersMu.Unlock()
|
|
}
|
|
|
|
// ─── trackedConn (TCP) ──────────────────────────────────────────────────────
|
|
|
|
type trackedConn struct {
|
|
net.Conn
|
|
tracker *ConnTracker
|
|
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
|
connID string
|
|
sourceIP string
|
|
limiter *rate.Limiter
|
|
ctx context.Context
|
|
pendingTokens atomic.Int64
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func (c *trackedConn) Read(b []byte) (int, error) {
|
|
if c.limiter != nil {
|
|
if burst := c.limiter.Burst(); len(b) > burst {
|
|
b = b[:burst]
|
|
}
|
|
}
|
|
n, err := c.Conn.Read(b)
|
|
if n > 0 {
|
|
if c.us != nil {
|
|
c.us.download.Add(int64(n))
|
|
}
|
|
if c.limiter != nil {
|
|
if pending := c.pendingTokens.Add(int64(n)); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (c *trackedConn) Write(b []byte) (int, error) {
|
|
n, err := c.Conn.Write(b)
|
|
if n > 0 {
|
|
if c.us != nil {
|
|
c.us.upload.Add(int64(n))
|
|
}
|
|
if c.limiter != nil {
|
|
if pending := c.pendingTokens.Add(int64(n)); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
func (c *trackedConn) Close() error {
|
|
if c.closed.CompareAndSwap(false, true) {
|
|
if c.us != nil {
|
|
c.us.removeConn(c.sourceIP)
|
|
}
|
|
c.tracker.removeConnRef(c.connID)
|
|
}
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
// makeCountFunc builds a CountFunc for zero-copy byte counting via sing's
|
|
// ReadCounter/WriteCounter unwrap interfaces.
|
|
func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
|
if c.limiter == nil {
|
|
return func(n int64) { counter.Add(n) }
|
|
}
|
|
return func(n int64) {
|
|
counter.Add(n)
|
|
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
t := time.NewTimer(delay)
|
|
select {
|
|
case <-t.C:
|
|
case <-c.ctx.Done():
|
|
t.Stop()
|
|
resv.Cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *trackedConn) UnwrapReader() (io.Reader, []N.CountFunc) {
|
|
if c.us == nil {
|
|
return c.Conn, nil
|
|
}
|
|
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
|
}
|
|
|
|
func (c *trackedConn) UnwrapWriter() (io.Writer, []N.CountFunc) {
|
|
if c.us == nil {
|
|
return c.Conn, nil
|
|
}
|
|
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
|
}
|
|
|
|
func (c *trackedConn) Upstream() any { return c.Conn }
|
|
func (c *trackedConn) ReaderReplaceable() bool { return true }
|
|
func (c *trackedConn) WriterReplaceable() bool { return true }
|
|
|
|
// ─── trackedPacketConn (UDP / QUIC) ─────────────────────────────────────────
|
|
|
|
type trackedPacketConn struct {
|
|
N.PacketConn
|
|
tracker *ConnTracker
|
|
us *userStats
|
|
connID string
|
|
sourceIP string
|
|
limiter *rate.Limiter
|
|
ctx context.Context
|
|
pendingTokens atomic.Int64
|
|
closed atomic.Bool
|
|
}
|
|
|
|
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
|
|
dest, err := c.PacketConn.ReadPacket(buffer)
|
|
if err == nil {
|
|
n := int64(buffer.Len())
|
|
if c.us != nil {
|
|
c.us.download.Add(n)
|
|
}
|
|
if c.limiter != nil {
|
|
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return dest, err
|
|
}
|
|
|
|
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
|
|
n := int64(buffer.Len())
|
|
err := c.PacketConn.WritePacket(buffer, dest)
|
|
if err == nil {
|
|
if c.us != nil {
|
|
c.us.upload.Add(n)
|
|
}
|
|
if c.limiter != nil {
|
|
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
time.Sleep(delay)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *trackedPacketConn) Close() error {
|
|
if c.closed.CompareAndSwap(false, true) {
|
|
if c.us != nil {
|
|
c.us.removeConn(c.sourceIP)
|
|
}
|
|
c.tracker.removeConnRef(c.connID)
|
|
}
|
|
return c.PacketConn.Close()
|
|
}
|
|
|
|
func (c *trackedPacketConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
|
if c.limiter == nil {
|
|
return func(n int64) { counter.Add(n) }
|
|
}
|
|
return func(n int64) {
|
|
counter.Add(n)
|
|
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
|
tokens := int(c.pendingTokens.Swap(0))
|
|
if burst := c.limiter.Burst(); tokens > burst {
|
|
tokens = burst
|
|
}
|
|
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
if delay := resv.Delay(); delay > 0 {
|
|
t := time.NewTimer(delay)
|
|
select {
|
|
case <-t.C:
|
|
case <-c.ctx.Done():
|
|
t.Stop()
|
|
resv.Cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc) {
|
|
if c.us == nil {
|
|
return c.PacketConn, nil
|
|
}
|
|
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.download)}
|
|
}
|
|
|
|
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
|
if c.us == nil {
|
|
return c.PacketConn, nil
|
|
}
|
|
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.us.upload)}
|
|
}
|
|
|
|
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
|
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
|
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|