feat: multi-node device limit, optimize rate limiter
This commit is contained in:
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
Cert CertConfig `yaml:"cert"`
|
Cert CertConfig `yaml:"cert"`
|
||||||
Log LogConfig `yaml:"log"`
|
Log LogConfig `yaml:"log"`
|
||||||
Runtime RuntimeConfig `yaml:"runtime"`
|
Runtime RuntimeConfig `yaml:"runtime"`
|
||||||
|
WS WSConfig `yaml:"ws"`
|
||||||
// HealthPort enables a lightweight HTTP health-check endpoint on the
|
// HealthPort enables a lightweight HTTP health-check endpoint on the
|
||||||
// given port (e.g. 65530). 0 = disabled (default).
|
// given port (e.g. 65530). 0 = disabled (default).
|
||||||
HealthPort int `yaml:"health_port"`
|
HealthPort int `yaml:"health_port"`
|
||||||
@@ -77,8 +78,19 @@ type PanelConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NodeConfig struct {
|
type NodeConfig struct {
|
||||||
PushInterval int `yaml:"push_interval"`
|
PushInterval int `yaml:"push_interval"`
|
||||||
PullInterval int `yaml:"pull_interval"`
|
PullInterval int `yaml:"pull_interval"`
|
||||||
|
TrackInterval int `yaml:"track_interval"` // sec, default 10
|
||||||
|
DeviceReportInterval int `yaml:"device_report_interval"` // sec, default 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSConfig holds WebSocket client tuning options.
|
||||||
|
type WSConfig struct {
|
||||||
|
StatusInterval int `yaml:"status_interval"` // node.status interval (sec), default 10
|
||||||
|
HandshakeTimeout int `yaml:"handshake_timeout"` // WS handshake timeout (sec), default 15
|
||||||
|
BackoffInitial int `yaml:"backoff_initial"` // initial reconnect delay (sec), default 1
|
||||||
|
BackoffMax int `yaml:"backoff_max"` // max reconnect delay (sec), default 60
|
||||||
|
DiscoveryInterval int `yaml:"discovery_interval"` // WS discovery interval (sec), default 300
|
||||||
}
|
}
|
||||||
|
|
||||||
type KernelConfig struct {
|
type KernelConfig struct {
|
||||||
@@ -240,6 +252,29 @@ func (c *Config) setDefaults() {
|
|||||||
if c.Cert.HTTPPort == 0 {
|
if c.Cert.HTTPPort == 0 {
|
||||||
c.Cert.HTTPPort = 80
|
c.Cert.HTTPPort = 80
|
||||||
}
|
}
|
||||||
|
// WS defaults
|
||||||
|
if c.WS.StatusInterval == 0 {
|
||||||
|
c.WS.StatusInterval = 10
|
||||||
|
}
|
||||||
|
if c.WS.HandshakeTimeout == 0 {
|
||||||
|
c.WS.HandshakeTimeout = 15
|
||||||
|
}
|
||||||
|
if c.WS.BackoffInitial == 0 {
|
||||||
|
c.WS.BackoffInitial = 1
|
||||||
|
}
|
||||||
|
if c.WS.BackoffMax == 0 {
|
||||||
|
c.WS.BackoffMax = 60
|
||||||
|
}
|
||||||
|
if c.WS.DiscoveryInterval == 0 {
|
||||||
|
c.WS.DiscoveryInterval = 300
|
||||||
|
}
|
||||||
|
// Node defaults
|
||||||
|
if c.Node.TrackInterval == 0 {
|
||||||
|
c.Node.TrackInterval = 10
|
||||||
|
}
|
||||||
|
if c.Node.DeviceReportInterval == 0 {
|
||||||
|
c.Node.DeviceReportInterval = 30
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ type Kernel interface {
|
|||||||
// The function resolves a user UUID to (limit, hasLimit).
|
// The function resolves a user UUID to (limit, hasLimit).
|
||||||
// Kernels that already gate-keep internally (e.g. xray) may no-op.
|
// Kernels that already gate-keep internally (e.g. xray) may no-op.
|
||||||
SetDeviceLimitFunc(fn func(uuid string) (int, bool))
|
SetDeviceLimitFunc(fn func(uuid string) (int, bool))
|
||||||
|
// UpdateGlobalDevices updates the global device state from panel (for multi-node).
|
||||||
|
UpdateGlobalDevices(users map[int][]string)
|
||||||
|
// ClearGlobalDevices clears the global device state (on WS disconnect).
|
||||||
|
ClearGlobalDevices()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputeHash returns a hash of config + user identities that would
|
// ComputeHash returns a hash of config + user identities that would
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import (
|
|||||||
"github.com/cedar2025/xboard-node/internal/nlog"
|
"github.com/cedar2025/xboard-node/internal/nlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rateLimitThreshold = 64 * 1024
|
// ipPool caches ipSnapshot maps to reduce allocations.
|
||||||
|
var ipPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return make(map[string]struct{}, 16)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Per-user statistics ────────────────────────────────────────────────────
|
// ─── Per-user statistics ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -29,7 +34,7 @@ type userStats struct {
|
|||||||
upload atomic.Int64
|
upload atomic.Int64
|
||||||
download atomic.Int64
|
download atomic.Int64
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.RWMutex // RWMutex for concurrent reads
|
||||||
ips map[string]int // sourceIP → refcount (number of active conns from that IP)
|
ips map[string]int // sourceIP → refcount (number of active conns from that IP)
|
||||||
connCount int // total active connections
|
connCount int // total active connections
|
||||||
}
|
}
|
||||||
@@ -61,15 +66,28 @@ func (u *userStats) distinctIPs() int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipSnapshot returns a copy of the current IP set (for device gate-keeping).
|
// ipSnapshot returns a pooled copy of the current IP set.
|
||||||
func (u *userStats) ipSnapshot() map[string]struct{} {
|
func (u *userStats) ipSnapshot() map[string]struct{} {
|
||||||
|
cp := ipPool.Get().(map[string]struct{})
|
||||||
|
for k := range cp {
|
||||||
|
delete(cp, k)
|
||||||
|
}
|
||||||
u.mu.Lock()
|
u.mu.Lock()
|
||||||
snapshot := make(map[string]struct{}, len(u.ips))
|
|
||||||
for ip := range u.ips {
|
for ip := range u.ips {
|
||||||
snapshot[ip] = struct{}{}
|
cp[ip] = struct{}{}
|
||||||
}
|
}
|
||||||
u.mu.Unlock()
|
u.mu.Unlock()
|
||||||
return snapshot
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseIPSnapshot returns the snapshot to pool.
|
||||||
|
// Large maps are discarded to prevent memory bloat.
|
||||||
|
func releaseIPSnapshot(m map[string]struct{}) {
|
||||||
|
// Discard large maps to prevent pool memory bloat
|
||||||
|
if len(m) > 64 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ipPool.Put(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aliveIPList returns the set of alive IPs as a boolean map (for reporting).
|
// aliveIPList returns the set of alive IPs as a boolean map (for reporting).
|
||||||
@@ -116,14 +134,20 @@ type ConnTracker struct {
|
|||||||
|
|
||||||
// deviceLimitFunc resolves a user UUID to their device limit.
|
// deviceLimitFunc resolves a user UUID to their device limit.
|
||||||
deviceLimitFunc atomic.Pointer[func(uuid string) (int, bool)]
|
deviceLimitFunc atomic.Pointer[func(uuid string) (int, bool)]
|
||||||
|
|
||||||
|
// Multi-node device state from panel
|
||||||
|
globalDevices map[int]map[string]bool // userID → IP → exists
|
||||||
|
globalMu sync.RWMutex
|
||||||
|
globalLastUpdate time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConnTracker creates a tracker.
|
// NewConnTracker creates a tracker.
|
||||||
func NewConnTracker(_ int) *ConnTracker {
|
func NewConnTracker(_ int) *ConnTracker {
|
||||||
return &ConnTracker{
|
return &ConnTracker{
|
||||||
users: make(map[int]*userStats),
|
users: make(map[int]*userStats),
|
||||||
uuidMap: make(map[string]int),
|
uuidMap: make(map[string]int),
|
||||||
connMap: make(map[string]net.Conn),
|
connMap: make(map[string]net.Conn),
|
||||||
|
globalDevices: make(map[int]map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +175,31 @@ func (t *ConnTracker) SetUserMap(m map[string]int) {
|
|||||||
t.usersMu.Unlock()
|
t.usersMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateGlobalDevices syncs global device state from panel.
|
||||||
|
func (t *ConnTracker) UpdateGlobalDevices(users map[int][]string) {
|
||||||
|
t.globalMu.Lock()
|
||||||
|
t.globalDevices = make(map[int]map[string]bool, len(users))
|
||||||
|
for uid, ips := range users {
|
||||||
|
m := make(map[string]bool, len(ips))
|
||||||
|
for _, ip := range ips {
|
||||||
|
m[ip] = true
|
||||||
|
}
|
||||||
|
t.globalDevices[uid] = m
|
||||||
|
}
|
||||||
|
t.globalLastUpdate = time.Now()
|
||||||
|
t.globalMu.Unlock()
|
||||||
|
nlog.Core().Debug("global device state updated", "users", len(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearGlobalDevices resets global device state (on WS disconnect).
|
||||||
|
func (t *ConnTracker) ClearGlobalDevices() {
|
||||||
|
t.globalMu.Lock()
|
||||||
|
t.globalDevices = make(map[int]map[string]bool)
|
||||||
|
t.globalLastUpdate = time.Time{}
|
||||||
|
t.globalMu.Unlock()
|
||||||
|
nlog.Core().Debug("global device state cleared")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── adapter.ConnectionTracker ──────────────────────────────────────────────
|
// ─── adapter.ConnectionTracker ──────────────────────────────────────────────
|
||||||
|
|
||||||
// RoutedConnection wraps a TCP conn to count bytes per-user, track IPs,
|
// RoutedConnection wraps a TCP conn to count bytes per-user, track IPs,
|
||||||
@@ -168,10 +217,10 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
us := t.users[uid]
|
us := t.users[uid]
|
||||||
t.usersMu.RUnlock()
|
t.usersMu.RUnlock()
|
||||||
|
|
||||||
// Gate-keep device limit
|
// Device limit gate-keeping
|
||||||
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||||
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||||
if t.checkDeviceGate(us, sourceIP, limit) {
|
if t.checkDeviceGate(us, uid, sourceIP, limit) {
|
||||||
nlog.Core().Info("singbox: device limit gate-keep, rejecting connection",
|
nlog.Core().Info("singbox: device limit gate-keep, rejecting connection",
|
||||||
"user", uuid, "ip", sourceIP, "limit", limit)
|
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -201,6 +250,7 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
Conn: conn,
|
Conn: conn,
|
||||||
tracker: t,
|
tracker: t,
|
||||||
us: us,
|
us: us,
|
||||||
|
userID: uid,
|
||||||
connID: connID,
|
connID: connID,
|
||||||
sourceIP: sourceIP,
|
sourceIP: sourceIP,
|
||||||
limiter: lim,
|
limiter: lim,
|
||||||
@@ -209,6 +259,9 @@ func (t *ConnTracker) RoutedConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
// RoutedPacketConnection wraps UDP with per-user counting (UDP not in connMap).
|
||||||
|
// Note: UDP connections are NOT stored in connMap because connMap is typed as
|
||||||
|
// map[string]net.Conn, but PacketConn is a different interface. Force-close
|
||||||
|
// for UDP connections is handled directly via trackedPacketConn.Close().
|
||||||
func (t *ConnTracker) RoutedPacketConnection(
|
func (t *ConnTracker) RoutedPacketConnection(
|
||||||
ctx context.Context, conn N.PacketConn,
|
ctx context.Context, conn N.PacketConn,
|
||||||
metadata adapter.InboundContext,
|
metadata adapter.InboundContext,
|
||||||
@@ -222,9 +275,10 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
us := t.users[uid]
|
us := t.users[uid]
|
||||||
t.usersMu.RUnlock()
|
t.usersMu.RUnlock()
|
||||||
|
|
||||||
|
// Device limit gate-keeping
|
||||||
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
if dlf := t.deviceLimitFunc.Load(); dlf != nil {
|
||||||
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
if limit, hasLimit := (*dlf)(uuid); hasLimit {
|
||||||
if t.checkDeviceGate(us, sourceIP, limit) {
|
if t.checkDeviceGate(us, uid, sourceIP, limit) {
|
||||||
nlog.Core().Info("singbox: device limit gate-keep, rejecting UDP connection",
|
nlog.Core().Info("singbox: device limit gate-keep, rejecting UDP connection",
|
||||||
"user", uuid, "ip", sourceIP, "limit", limit)
|
"user", uuid, "ip", sourceIP, "limit", limit)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -248,6 +302,7 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
PacketConn: conn,
|
PacketConn: conn,
|
||||||
tracker: t,
|
tracker: t,
|
||||||
us: us,
|
us: us,
|
||||||
|
userID: uid,
|
||||||
connID: connID,
|
connID: connID,
|
||||||
sourceIP: sourceIP,
|
sourceIP: sourceIP,
|
||||||
limiter: lim,
|
limiter: lim,
|
||||||
@@ -255,28 +310,74 @@ func (t *ConnTracker) RoutedPacketConnection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDeviceGate returns true if the connection should be rejected.
|
// checkDeviceGate rejects connections exceeding device limit.
|
||||||
// Uses the deterministic lowest-IP algorithm.
|
// Strategy: merge local + global state when fresh; local-only when stale.
|
||||||
func (t *ConnTracker) checkDeviceGate(us *userStats, sourceIP string, limit int) bool {
|
func (t *ConnTracker) checkDeviceGate(us *userStats, userID int, sourceIP string, limit int) bool {
|
||||||
if us == nil {
|
if us == nil || limit <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
knownIPs := us.ipSnapshot()
|
us.mu.RLock()
|
||||||
|
localIPs := make(map[string]bool, len(us.ips))
|
||||||
|
for ip := range us.ips {
|
||||||
|
localIPs[ip] = true
|
||||||
|
}
|
||||||
|
localCount := len(us.ips)
|
||||||
|
us.mu.RUnlock()
|
||||||
|
|
||||||
// Already known IP — always allow
|
// Already known locally
|
||||||
if _, exists := knownIPs[sourceIP]; exists {
|
if localIPs[sourceIP] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Under limit — allow
|
// Check global state freshness
|
||||||
if len(knownIPs) < limit {
|
t.globalMu.RLock()
|
||||||
|
globalStale := time.Since(t.globalLastUpdate) > 60*time.Second
|
||||||
|
globalIPs := t.globalDevices[userID]
|
||||||
|
t.globalMu.RUnlock()
|
||||||
|
|
||||||
|
// Stale or missing global state → local-only check
|
||||||
|
if globalStale || globalIPs == nil {
|
||||||
|
if localCount < limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ipList := make([]string, 0, localCount+1)
|
||||||
|
for ip := range localIPs {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nlog.Core().Debug("device limit: local over limit, rejecting",
|
||||||
|
"userID", userID, "ip", sourceIP, "localIPs", localCount, "limit", limit)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known globally (from other node)
|
||||||
|
if globalIPs[sourceIP] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Over limit — deterministic: allow lowest IPs lexicographically
|
// Merge local + global
|
||||||
ipList := make([]string, 0, len(knownIPs)+1)
|
allIPs := make(map[string]bool)
|
||||||
for ip := range knownIPs {
|
for ip := range localIPs {
|
||||||
|
allIPs[ip] = true
|
||||||
|
}
|
||||||
|
for ip := range globalIPs {
|
||||||
|
allIPs[ip] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allIPs) < limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over limit → lexicographic selection
|
||||||
|
ipList := make([]string, 0, len(allIPs)+1)
|
||||||
|
for ip := range allIPs {
|
||||||
ipList = append(ipList, ip)
|
ipList = append(ipList, ip)
|
||||||
}
|
}
|
||||||
ipList = append(ipList, sourceIP)
|
ipList = append(ipList, sourceIP)
|
||||||
@@ -287,6 +388,8 @@ func (t *ConnTracker) checkDeviceGate(us *userStats, sourceIP string, limit int)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nlog.Core().Debug("device limit: total over limit, rejecting",
|
||||||
|
"userID", userID, "ip", sourceIP, "totalIPs", len(allIPs), "limit", limit)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,16 +484,89 @@ func (t *ConnTracker) removeConnRef(connID string) {
|
|||||||
|
|
||||||
// ─── trackedConn (TCP) ──────────────────────────────────────────────────────
|
// ─── trackedConn (TCP) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// RateLimitedWriter wraps a Writer with non-blocking rate limiting.
|
||||||
|
// Uses AllowN for instant checks and context-aware waiting when needed.
|
||||||
|
type RateLimitedWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
limiter *rate.Limiter
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements io.Writer with rate limiting.
|
||||||
|
func (w *RateLimitedWriter) Write(b []byte) (int, error) {
|
||||||
|
// Truncate to burst size
|
||||||
|
if burst := w.limiter.Burst(); len(b) > burst {
|
||||||
|
b = b[:burst]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: check if tokens available (non-blocking)
|
||||||
|
if w.limiter.AllowN(time.Now(), len(b)) {
|
||||||
|
return w.writer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: wait for tokens with context cancellation support
|
||||||
|
resv := w.limiter.ReserveN(time.Now(), len(b))
|
||||||
|
if delay := resv.Delay(); delay > 0 {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
// Tokens available, proceed
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
resv.Cancel()
|
||||||
|
return 0, w.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.writer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitedReadCloser wraps a Reader with non-blocking rate limiting.
|
||||||
|
type RateLimitedReadCloser struct {
|
||||||
|
reader io.Reader
|
||||||
|
limiter *rate.Limiter
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements io.Reader with rate limiting.
|
||||||
|
func (r *RateLimitedReadCloser) Read(b []byte) (int, error) {
|
||||||
|
// Truncate to burst size
|
||||||
|
if burst := r.limiter.Burst(); len(b) > burst {
|
||||||
|
b = b[:burst]
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := r.reader.Read(b)
|
||||||
|
if n > 0 && r.limiter != nil {
|
||||||
|
// Fast path: check if tokens available
|
||||||
|
if !r.limiter.AllowN(time.Now(), n) {
|
||||||
|
// Slow path: wait with context cancellation
|
||||||
|
resv := r.limiter.ReserveN(time.Now(), n)
|
||||||
|
if delay := resv.Delay(); delay > 0 {
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
// Tokens available
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
resv.Cancel()
|
||||||
|
return n, r.ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
type trackedConn struct {
|
type trackedConn struct {
|
||||||
net.Conn
|
net.Conn
|
||||||
tracker *ConnTracker
|
tracker *ConnTracker
|
||||||
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
us *userStats // per-user stats (upload/download atomics + IP tracking)
|
||||||
connID string
|
userID int // user ID for device tracking
|
||||||
sourceIP string
|
connID string
|
||||||
limiter *rate.Limiter
|
sourceIP string
|
||||||
ctx context.Context
|
limiter *rate.Limiter
|
||||||
pendingTokens atomic.Int64
|
ctx context.Context
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedConn) Read(b []byte) (int, error) {
|
func (c *trackedConn) Read(b []byte) (int, error) {
|
||||||
@@ -405,14 +581,19 @@ func (c *trackedConn) Read(b []byte) (int, error) {
|
|||||||
c.us.download.Add(int64(n))
|
c.us.download.Add(int64(n))
|
||||||
}
|
}
|
||||||
if c.limiter != nil {
|
if c.limiter != nil {
|
||||||
if pending := c.pendingTokens.Add(int64(n)); pending >= rateLimitThreshold {
|
// Non-blocking rate limiting
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
if !c.limiter.AllowN(time.Now(), n) {
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
resv := c.limiter.ReserveN(time.Now(), n)
|
||||||
tokens = burst
|
|
||||||
}
|
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
||||||
if delay := resv.Delay(); delay > 0 {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
time.Sleep(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
// Tokens available
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
resv.Cancel()
|
||||||
|
return n, c.ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,24 +602,32 @@ func (c *trackedConn) Read(b []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedConn) Write(b []byte) (int, error) {
|
func (c *trackedConn) Write(b []byte) (int, error) {
|
||||||
n, err := c.Conn.Write(b)
|
// Apply rate limiting before write
|
||||||
if n > 0 {
|
if c.limiter != nil {
|
||||||
if c.us != nil {
|
if burst := c.limiter.Burst(); len(b) > burst {
|
||||||
c.us.upload.Add(int64(n))
|
b = b[:burst]
|
||||||
}
|
}
|
||||||
if c.limiter != nil {
|
// Non-blocking check first
|
||||||
if pending := c.pendingTokens.Add(int64(n)); pending >= rateLimitThreshold {
|
if !c.limiter.AllowN(time.Now(), len(b)) {
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
resv := c.limiter.ReserveN(time.Now(), len(b))
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
tokens = burst
|
timer := time.NewTimer(delay)
|
||||||
}
|
defer timer.Stop()
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
select {
|
||||||
if delay := resv.Delay(); delay > 0 {
|
case <-timer.C:
|
||||||
time.Sleep(delay)
|
// Tokens available
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
resv.Cancel()
|
||||||
|
return 0, c.ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n, err := c.Conn.Write(b)
|
||||||
|
if n > 0 && c.us != nil {
|
||||||
|
c.us.upload.Add(int64(n))
|
||||||
|
}
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,18 +649,16 @@ func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
|||||||
}
|
}
|
||||||
return func(n int64) {
|
return func(n int64) {
|
||||||
counter.Add(n)
|
counter.Add(n)
|
||||||
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
// Non-blocking rate limiting with context cancellation
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||||
tokens = burst
|
|
||||||
}
|
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
||||||
if delay := resv.Delay(); delay > 0 {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
t := time.NewTimer(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-t.C:
|
case <-timer.C:
|
||||||
|
// Tokens available
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
t.Stop()
|
|
||||||
resv.Cancel()
|
resv.Cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,14 +688,14 @@ func (c *trackedConn) WriterReplaceable() bool { return true }
|
|||||||
|
|
||||||
type trackedPacketConn struct {
|
type trackedPacketConn struct {
|
||||||
N.PacketConn
|
N.PacketConn
|
||||||
tracker *ConnTracker
|
tracker *ConnTracker
|
||||||
us *userStats
|
us *userStats
|
||||||
connID string
|
userID int
|
||||||
sourceIP string
|
connID string
|
||||||
limiter *rate.Limiter
|
sourceIP string
|
||||||
ctx context.Context
|
limiter *rate.Limiter
|
||||||
pendingTokens atomic.Int64
|
ctx context.Context
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
|
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
|
||||||
@@ -519,14 +706,19 @@ func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, err
|
|||||||
c.us.download.Add(n)
|
c.us.download.Add(n)
|
||||||
}
|
}
|
||||||
if c.limiter != nil {
|
if c.limiter != nil {
|
||||||
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
// Non-blocking rate limiting with context cancellation
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||||
tokens = burst
|
|
||||||
}
|
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
||||||
if delay := resv.Delay(); delay > 0 {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
time.Sleep(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
// Tokens available
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
resv.Cancel()
|
||||||
|
return dest, c.ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,24 +728,29 @@ func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, err
|
|||||||
|
|
||||||
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
|
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
|
||||||
n := int64(buffer.Len())
|
n := int64(buffer.Len())
|
||||||
err := c.PacketConn.WritePacket(buffer, dest)
|
|
||||||
if err == nil {
|
// Apply rate limiting before write
|
||||||
if c.us != nil {
|
if c.limiter != nil {
|
||||||
c.us.upload.Add(n)
|
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||||
}
|
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||||
if c.limiter != nil {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
timer := time.NewTimer(delay)
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
defer timer.Stop()
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
select {
|
||||||
tokens = burst
|
case <-timer.C:
|
||||||
}
|
// Tokens available
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
case <-c.ctx.Done():
|
||||||
if delay := resv.Delay(); delay > 0 {
|
resv.Cancel()
|
||||||
time.Sleep(delay)
|
return c.ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := c.PacketConn.WritePacket(buffer, dest)
|
||||||
|
if err == nil && c.us != nil {
|
||||||
|
c.us.upload.Add(n)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,18 +770,16 @@ func (c *trackedPacketConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
|||||||
}
|
}
|
||||||
return func(n int64) {
|
return func(n int64) {
|
||||||
counter.Add(n)
|
counter.Add(n)
|
||||||
if pending := c.pendingTokens.Add(n); pending >= rateLimitThreshold {
|
// Non-blocking rate limiting with context cancellation
|
||||||
tokens := int(c.pendingTokens.Swap(0))
|
if !c.limiter.AllowN(time.Now(), int(n)) {
|
||||||
if burst := c.limiter.Burst(); tokens > burst {
|
resv := c.limiter.ReserveN(time.Now(), int(n))
|
||||||
tokens = burst
|
|
||||||
}
|
|
||||||
resv := c.limiter.ReserveN(time.Now(), tokens)
|
|
||||||
if delay := resv.Delay(); delay > 0 {
|
if delay := resv.Delay(); delay > 0 {
|
||||||
t := time.NewTimer(delay)
|
timer := time.NewTimer(delay)
|
||||||
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-t.C:
|
case <-timer.C:
|
||||||
|
// Tokens available
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
t.Stop()
|
|
||||||
resv.Cancel()
|
resv.Cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const drainTimeout = 5 * time.Second
|
|||||||
type SingBox struct {
|
type SingBox struct {
|
||||||
cfg config.KernelConfig
|
cfg config.KernelConfig
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.RWMutex
|
||||||
box *box.Box
|
box *box.Box
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -392,6 +392,24 @@ func (s *SingBox) SetDeviceLimitFunc(fn func(uuid string) (int, bool)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateGlobalDevices updates the global device state from panel (for multi-node).
|
||||||
|
func (s *SingBox) UpdateGlobalDevices(users map[int][]string) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if s.connTracker != nil {
|
||||||
|
s.connTracker.UpdateGlobalDevices(users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearGlobalDevices clears the global device state (on WS disconnect).
|
||||||
|
func (s *SingBox) ClearGlobalDevices() {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if s.connTracker != nil {
|
||||||
|
s.connTracker.ClearGlobalDevices()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── User management (non-disruptive) ───────────────────────────────────────
|
// ─── User management (non-disruptive) ───────────────────────────────────────
|
||||||
|
|
||||||
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
|
// AddUsers hot-swaps users into running inbounds. Zero connection disruption.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destina
|
|||||||
isTCP = dest.Network == net.Network_TCP
|
isTCP = dest.Network == net.Network_TCP
|
||||||
|
|
||||||
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
||||||
nlog.Core().Info("xray: device limit exceeded", "email", email, "ip", sourceIP)
|
nlog.Core().Debug("xray: device limit exceeded", "email", email, "ip", sourceIP)
|
||||||
return "", "", false, errors.New("device limit exceeded for " + email)
|
return "", "", false, errors.New("device limit exceeded for " + email)
|
||||||
}
|
}
|
||||||
return email, sourceIP, isTCP, nil
|
return email, sourceIP, isTCP, nil
|
||||||
|
|||||||
@@ -244,6 +244,12 @@ func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
|
|||||||
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
|
// gate-kept by LimitDispatcher.checkDeviceLimit at Dispatch time.
|
||||||
func (x *Xray) SetDeviceLimitFunc(_ func(string) (int, bool)) {}
|
func (x *Xray) SetDeviceLimitFunc(_ func(string) (int, bool)) {}
|
||||||
|
|
||||||
|
// UpdateGlobalDevices is a no-op for xray — xray handles device limits differently.
|
||||||
|
func (x *Xray) UpdateGlobalDevices(_ map[int][]string) {}
|
||||||
|
|
||||||
|
// ClearGlobalDevices is a no-op for xray.
|
||||||
|
func (x *Xray) ClearGlobalDevices() {}
|
||||||
|
|
||||||
// ─── User management (non-disruptive where possible) ────────────────────────
|
// ─── User management (non-disruptive where possible) ────────────────────────
|
||||||
|
|
||||||
// AddUsers adds new users to the running kernel via xray's UserManager API.
|
// AddUsers adds new users to the running kernel via xray's UserManager API.
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ func (t *SpeedTracker) SetLogCallback(f SpeedTrackerLogCallback) {
|
|||||||
t.logFunc = f
|
t.logFunc = f
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBuckets performs an incremental update of rate limiter buckets.
|
// UpdateBuckets updates the UUID→userID mapping and cleans up removed users.
|
||||||
// It reuses existing rate.Limiter instances to avoid burst resets and
|
// Rate limiters are created on-demand in GetLimiter.
|
||||||
// minimizes memory allocations.
|
|
||||||
func (t *SpeedTracker) UpdateBuckets() {
|
func (t *SpeedTracker) UpdateBuckets() {
|
||||||
currentUsers := make([]panel.User, 0, 32)
|
currentUsers := make([]panel.User, 0, 32)
|
||||||
t.limiter.mu.RLock()
|
t.limiter.mu.RLock()
|
||||||
@@ -66,29 +65,9 @@ func (t *SpeedTracker) UpdateBuckets() {
|
|||||||
if user.UUID != "" {
|
if user.UUID != "" {
|
||||||
newUUIDMap[user.UUID] = user.ID
|
newUUIDMap[user.UUID] = user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.SpeedLimit <= 0 {
|
|
||||||
delete(t.buckets, user.ID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8
|
|
||||||
burst := bytesPerSec
|
|
||||||
if burst < 64*1024 {
|
|
||||||
burst = 64 * 1024
|
|
||||||
}
|
|
||||||
if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s {
|
|
||||||
burst = cap4s
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing, ok := t.buckets[user.ID]; ok {
|
|
||||||
existing.SetLimit(rate.Limit(bytesPerSec))
|
|
||||||
existing.SetBurst(burst)
|
|
||||||
} else {
|
|
||||||
t.buckets[user.ID] = rate.NewLimiter(rate.Limit(bytesPerSec), burst)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up buckets for removed users
|
||||||
for id := range t.buckets {
|
for id := range t.buckets {
|
||||||
if _, ok := activeIDs[id]; !ok {
|
if _, ok := activeIDs[id]; !ok {
|
||||||
delete(t.buckets, id)
|
delete(t.buckets, id)
|
||||||
@@ -105,15 +84,53 @@ func (t *SpeedTracker) UpdateBuckets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLimiter returns the rate limiter for the given user UUID, or nil if
|
// GetLimiter returns the rate limiter for the given user UUID, or nil if
|
||||||
// no limit applies. This is called by ConnTracker for every new connection.
|
// no limit applies. Creates limiter on-demand if not exists.
|
||||||
// Thread-safe.
|
// Thread-safe.
|
||||||
func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter {
|
func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter {
|
||||||
if !t.hasLimits.Load() {
|
t.mu.RLock()
|
||||||
|
uid, exists := t.uuidMap[user]
|
||||||
|
if !exists {
|
||||||
|
t.mu.RUnlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
t.mu.RLock()
|
if lim, ok := t.buckets[uid]; ok {
|
||||||
defer t.mu.RUnlock()
|
t.mu.RUnlock()
|
||||||
return t.buckets[t.uuidMap[user]]
|
return lim
|
||||||
|
}
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
// Get user info from limiter
|
||||||
|
t.limiter.mu.RLock()
|
||||||
|
u, userExists := t.limiter.users[uid]
|
||||||
|
t.limiter.mu.RUnlock()
|
||||||
|
|
||||||
|
if !userExists || u.SpeedLimit <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create limiter on-demand
|
||||||
|
bytesPerSec := int(u.SpeedLimit) * 1_000_000 / 8
|
||||||
|
burst := bytesPerSec
|
||||||
|
if burst < 64*1024 {
|
||||||
|
burst = 64 * 1024
|
||||||
|
}
|
||||||
|
if cap4s := bytesPerSec * 4; cap4s > 64*1024 && burst > cap4s {
|
||||||
|
burst = cap4s
|
||||||
|
}
|
||||||
|
|
||||||
|
lim := rate.NewLimiter(rate.Limit(bytesPerSec), burst)
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
// Double-check after acquiring write lock
|
||||||
|
if existing, ok := t.buckets[uid]; ok {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
t.buckets[uid] = lim
|
||||||
|
t.hasLimits.Store(true)
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
return lim
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasLimits returns true if any user currently has a speed limit configured.
|
// HasLimits returns true if any user currently has a speed limit configured.
|
||||||
|
|||||||
+109
-12
@@ -19,6 +19,8 @@ const (
|
|||||||
WSEventSyncConfig = "sync.config"
|
WSEventSyncConfig = "sync.config"
|
||||||
WSEventSyncUsers = "sync.users"
|
WSEventSyncUsers = "sync.users"
|
||||||
WSEventSyncUserDelta = "sync.user.delta"
|
WSEventSyncUserDelta = "sync.user.delta"
|
||||||
|
WSEventSyncDevices = "sync.devices" // panel → node: global device state
|
||||||
|
WSEventReportDevices = "report.devices" // node → panel: report device snapshot
|
||||||
)
|
)
|
||||||
|
|
||||||
// WSEvent is a parsed data event delivered to the service layer.
|
// WSEvent is a parsed data event delivered to the service layer.
|
||||||
@@ -28,6 +30,9 @@ type WSEvent struct {
|
|||||||
Users []User
|
Users []User
|
||||||
DeltaAction string // "add" or "remove" (only for sync.user.delta)
|
DeltaAction string // "add" or "remove" (only for sync.user.delta)
|
||||||
DeltaUsers []User // users affected by the delta
|
DeltaUsers []User // users affected by the delta
|
||||||
|
|
||||||
|
// Device sync fields
|
||||||
|
DeviceUsers map[int][]string // userID -> IPs (for sync.devices)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WSStatusChange notifies the service when WS connectivity changes.
|
// WSStatusChange notifies the service when WS connectivity changes.
|
||||||
@@ -59,6 +64,20 @@ type syncUserDeltaPayload struct {
|
|||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncDevicesPayload carries global device state from panel.
|
||||||
|
type syncDevicesPayload struct {
|
||||||
|
Users map[int][]string `json:"users"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSClientConfig holds WebSocket client tuning options.
|
||||||
|
type WSClientConfig struct {
|
||||||
|
StatusInterval time.Duration
|
||||||
|
HandshakeTimeout time.Duration
|
||||||
|
BackoffInitial time.Duration
|
||||||
|
BackoffMax time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
// WSClient connects to the panel's Workerman WS server using native WebSocket.
|
// WSClient connects to the panel's Workerman WS server using native WebSocket.
|
||||||
// Authentication is done via query parameters (token + node_id) during the
|
// Authentication is done via query parameters (token + node_id) during the
|
||||||
// WebSocket handshake — no separate auth step needed.
|
// WebSocket handshake — no separate auth step needed.
|
||||||
@@ -70,17 +89,37 @@ type WSClient struct {
|
|||||||
onStatus func(WSStatusChange)
|
onStatus func(WSStatusChange)
|
||||||
onPing func() map[string]interface{}
|
onPing func() map[string]interface{}
|
||||||
|
|
||||||
|
cfg WSClientConfig
|
||||||
|
|
||||||
connected atomic.Bool
|
connected atomic.Bool
|
||||||
|
|
||||||
|
// writeCh allows sending messages from outside the connect loop.
|
||||||
|
// It is set in connect() and cleared on disconnect.
|
||||||
|
writeCh chan wsMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWSClient creates a new WebSocket client.
|
// NewWSClient creates a new WebSocket client.
|
||||||
// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076").
|
// wsURL is the base WebSocket URL (e.g. "ws://panel.example.com:8076").
|
||||||
// token and nodeID are used for authentication via query parameters.
|
// token and nodeID are used for authentication via query parameters.
|
||||||
func NewWSClient(wsURL string, token string, nodeID int, onEvent func(WSEvent), onStatus func(WSStatusChange), onPing func() map[string]interface{}) *WSClient {
|
func NewWSClient(wsURL string, token string, nodeID int, cfg WSClientConfig, onEvent func(WSEvent), onStatus func(WSStatusChange), onPing func() map[string]interface{}) *WSClient {
|
||||||
|
// Apply defaults
|
||||||
|
if cfg.StatusInterval == 0 {
|
||||||
|
cfg.StatusInterval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.HandshakeTimeout == 0 {
|
||||||
|
cfg.HandshakeTimeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
if cfg.BackoffInitial == 0 {
|
||||||
|
cfg.BackoffInitial = time.Second
|
||||||
|
}
|
||||||
|
if cfg.BackoffMax == 0 {
|
||||||
|
cfg.BackoffMax = 60 * time.Second
|
||||||
|
}
|
||||||
return &WSClient{
|
return &WSClient{
|
||||||
wsURL: wsURL,
|
wsURL: wsURL,
|
||||||
token: token,
|
token: token,
|
||||||
nodeID: nodeID,
|
nodeID: nodeID,
|
||||||
|
cfg: cfg,
|
||||||
onEvent: onEvent,
|
onEvent: onEvent,
|
||||||
onStatus: onStatus,
|
onStatus: onStatus,
|
||||||
onPing: onPing,
|
onPing: onPing,
|
||||||
@@ -97,7 +136,7 @@ func (w *WSClient) notifyStatus(connected bool) {
|
|||||||
|
|
||||||
// Run connects and reconnects until ctx is cancelled.
|
// Run connects and reconnects until ctx is cancelled.
|
||||||
func (w *WSClient) Run(ctx context.Context) {
|
func (w *WSClient) Run(ctx context.Context) {
|
||||||
backoff := time.Second
|
backoff := w.cfg.BackoffInitial
|
||||||
for {
|
for {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := w.connect(ctx)
|
err := w.connect(ctx)
|
||||||
@@ -122,7 +161,7 @@ func (w *WSClient) Run(ctx context.Context) {
|
|||||||
|
|
||||||
// Reset backoff if the connection was up for a meaningful duration.
|
// Reset backoff if the connection was up for a meaningful duration.
|
||||||
if time.Since(start) > 2*time.Minute {
|
if time.Since(start) > 2*time.Minute {
|
||||||
backoff = time.Second
|
backoff = w.cfg.BackoffInitial
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply exponential backoff with jitter to prevent thundering herd.
|
// Apply exponential backoff with jitter to prevent thundering herd.
|
||||||
@@ -136,8 +175,8 @@ func (w *WSClient) Run(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
}
|
}
|
||||||
if backoff < 60*time.Second {
|
if backoff < w.cfg.BackoffMax {
|
||||||
backoff = min(backoff*2, 60*time.Second)
|
backoff = min(backoff*2, w.cfg.BackoffMax)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +195,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
nlog.Core().Debug("ws connecting", "url", u.Host)
|
nlog.Core().Debug("ws connecting", "url", u.Host)
|
||||||
|
|
||||||
dialer := websocket.Dialer{
|
dialer := websocket.Dialer{
|
||||||
HandshakeTimeout: 15 * time.Second,
|
HandshakeTimeout: w.cfg.HandshakeTimeout,
|
||||||
}
|
}
|
||||||
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
|
conn, _, err := dialer.DialContext(ctx, u.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,6 +210,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
if err := conn.ReadJSON(&firstMsg); err != nil {
|
if err := conn.ReadJSON(&firstMsg); err != nil {
|
||||||
return fmt.Errorf("read auth response: %w", err)
|
return fmt.Errorf("read auth response: %w", err)
|
||||||
}
|
}
|
||||||
|
nlog.Core().Debug("ws recv", "event", firstMsg.Event, "data", string(firstMsg.Data))
|
||||||
|
|
||||||
if firstMsg.Event == "error" {
|
if firstMsg.Event == "error" {
|
||||||
var errData struct {
|
var errData struct {
|
||||||
@@ -194,8 +234,9 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ping interval: send pong responses to server pings.
|
// Ping interval: send pong responses to server pings.
|
||||||
// We also use this timer to trigger periodic status pushes (10s interval).
|
// We also use this timer to trigger periodic status pushes.
|
||||||
const reportInterval = 10 * time.Second
|
reportTicker := time.NewTicker(w.cfg.StatusInterval)
|
||||||
|
defer reportTicker.Stop()
|
||||||
|
|
||||||
msgCh := make(chan wsMessage, 16)
|
msgCh := make(chan wsMessage, 16)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
@@ -211,6 +252,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
nlog.Core().Debug("ws recv", "event", msg.Event, "data", string(msg.Data))
|
||||||
select {
|
select {
|
||||||
case msgCh <- msg:
|
case msgCh <- msg:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -221,11 +263,10 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
reportTicker := time.NewTicker(reportInterval)
|
|
||||||
defer reportTicker.Stop()
|
|
||||||
|
|
||||||
// writeCh decouples data collection from network I/O.
|
// writeCh decouples data collection from network I/O.
|
||||||
writeCh := make(chan wsMessage, 8)
|
writeCh := make(chan wsMessage, 16)
|
||||||
|
w.writeCh = writeCh
|
||||||
|
defer func() { w.writeCh = nil }()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -268,6 +309,7 @@ func (w *WSClient) connect(ctx context.Context) error {
|
|||||||
|
|
||||||
case msg := <-writeCh:
|
case msg := <-writeCh:
|
||||||
// Perform the actual network write asynchronously in this loop.
|
// Perform the actual network write asynchronously in this loop.
|
||||||
|
nlog.Core().Debug("ws send", "event", msg.Event, "data", string(msg.Data))
|
||||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
return fmt.Errorf("write: %w", err)
|
return fmt.Errorf("write: %w", err)
|
||||||
@@ -294,6 +336,9 @@ func (w *WSClient) handleMessage(msg wsMessage) {
|
|||||||
case WSEventSyncUserDelta:
|
case WSEventSyncUserDelta:
|
||||||
w.handleDataEvent(msg)
|
w.handleDataEvent(msg)
|
||||||
|
|
||||||
|
case WSEventSyncDevices:
|
||||||
|
w.handleDataEvent(msg)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
nlog.Core().Debug("ws unknown event", "event", msg.Event)
|
||||||
}
|
}
|
||||||
@@ -356,7 +401,59 @@ func (w *WSClient) handleDataEvent(msg wsMessage) {
|
|||||||
}
|
}
|
||||||
event.DeltaAction = p.Action
|
event.DeltaAction = p.Action
|
||||||
event.DeltaUsers = p.Users
|
event.DeltaUsers = p.Users
|
||||||
|
|
||||||
|
case WSEventSyncDevices:
|
||||||
|
nlog.Core().Debug("ws sync devices event received")
|
||||||
|
var p syncDevicesPayload
|
||||||
|
if err := decodeData(msg.Data, &p); err != nil {
|
||||||
|
nlog.Core().Warn("ws: cannot decode devices payload", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.DeviceUsers = p.Users
|
||||||
}
|
}
|
||||||
|
|
||||||
w.onEvent(event)
|
w.onEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendDeviceReport sends local device snapshot to panel via WS.
|
||||||
|
// Format: {"123": ["1.1.1.1", "2.2.2.2"], "456": ["3.3.3.3"]}
|
||||||
|
func (w *WSClient) SendDeviceReport(devices map[int][]string) {
|
||||||
|
if !w.connected.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := wsMessage{
|
||||||
|
Event: WSEventReportDevices,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
d, err := json.Marshal(devices)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.Data = d
|
||||||
|
|
||||||
|
select {
|
||||||
|
case w.writeCh <- msg:
|
||||||
|
default:
|
||||||
|
nlog.Core().Warn("ws write channel full, skipping device report")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRaw sends a raw message to the panel via WebSocket.
|
||||||
|
func (w *WSClient) SendRaw(event string, data json.RawMessage) {
|
||||||
|
if !w.connected.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := wsMessage{
|
||||||
|
Event: event,
|
||||||
|
Data: data,
|
||||||
|
Timestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case w.writeCh <- msg:
|
||||||
|
default:
|
||||||
|
nlog.Core().Warn("ws write channel full, skipping raw message", "event", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestWSClient_ConnectAndReceiveDataEvents(t *testing.T) {
|
|||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var received []WSEvent
|
var received []WSEvent
|
||||||
|
|
||||||
ws := NewWSClient("ws://"+host, "test-token", 1, func(event WSEvent) {
|
ws := NewWSClient("ws://"+host, "test-token", 1, WSClientConfig{}, func(event WSEvent) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
received = append(received, event)
|
received = append(received, event)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -187,7 +187,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
host := strings.TrimPrefix(server.URL, "http://")
|
host := strings.TrimPrefix(server.URL, "http://")
|
||||||
ws := NewWSClient("ws://"+host, "reconnect-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
ws := NewWSClient("ws://"+host, "reconnect-token", 1, WSClientConfig{}, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -218,7 +218,7 @@ func TestWSClient_ReconnectOnDisconnect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
|
func TestWSClient_FallbackWhenNoServer(t *testing.T) {
|
||||||
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
ws := NewWSClient("ws://127.0.0.1:19999", "fallback-token", 1, WSClientConfig{}, func(WSEvent) {}, nil, func() map[string]interface{} { return nil })
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -250,7 +250,7 @@ func TestWSClient_UserDeltaEvent(t *testing.T) {
|
|||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var received []WSEvent
|
var received []WSEvent
|
||||||
|
|
||||||
ws := NewWSClient("ws://"+host, "delta-token", 1, func(event WSEvent) {
|
ws := NewWSClient("ws://"+host, "delta-token", 1, WSClientConfig{}, func(event WSEvent) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
received = append(received, event)
|
received = append(received, event)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|||||||
@@ -158,20 +158,22 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
defer s.kernel.Stop()
|
defer s.kernel.Stop()
|
||||||
|
|
||||||
// Set up tickers
|
// Set up tickers
|
||||||
trackTicker := time.NewTicker(10 * time.Second)
|
trackTicker := time.NewTicker(time.Duration(s.cfg.Node.TrackInterval) * time.Second)
|
||||||
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second
|
pushInterval := time.Duration(math.Max(float64(s.pushInterval), 5)) * time.Second
|
||||||
pullInterval := time.Duration(s.pullInterval) * time.Second
|
pullInterval := time.Duration(s.pullInterval) * time.Second
|
||||||
reportTicker := time.NewTicker(pushInterval)
|
reportTicker := time.NewTicker(pushInterval)
|
||||||
pullTicker := time.NewTicker(pullInterval)
|
pullTicker := time.NewTicker(pullInterval)
|
||||||
|
deviceReportTicker := time.NewTicker(time.Duration(s.cfg.Node.DeviceReportInterval) * time.Second)
|
||||||
|
|
||||||
// WS discovery: when in REST-only mode, periodically re-handshake to check
|
// WS discovery: when in REST-only mode, periodically re-handshake to check
|
||||||
// if WS has been enabled. When WS is disconnected for too long, re-check
|
// if WS has been enabled. When WS is disconnected for too long, re-check
|
||||||
// if it's still available.
|
// if it's still available.
|
||||||
wsDiscoveryTicker := time.NewTicker(5 * time.Minute)
|
wsDiscoveryTicker := time.NewTicker(time.Duration(s.cfg.WS.DiscoveryInterval) * time.Second)
|
||||||
|
|
||||||
defer trackTicker.Stop()
|
defer trackTicker.Stop()
|
||||||
defer reportTicker.Stop()
|
defer reportTicker.Stop()
|
||||||
defer pullTicker.Stop()
|
defer pullTicker.Stop()
|
||||||
|
defer deviceReportTicker.Stop()
|
||||||
defer wsDiscoveryTicker.Stop()
|
defer wsDiscoveryTicker.Stop()
|
||||||
|
|
||||||
s.startWSClient(ctx)
|
s.startWSClient(ctx)
|
||||||
@@ -188,6 +190,9 @@ func (s *Service) Run(ctx context.Context) error {
|
|||||||
case <-reportTicker.C:
|
case <-reportTicker.C:
|
||||||
s.pushReportAsync()
|
s.pushReportAsync()
|
||||||
|
|
||||||
|
case <-deviceReportTicker.C:
|
||||||
|
s.reportDevices()
|
||||||
|
|
||||||
case <-pullTicker.C:
|
case <-pullTicker.C:
|
||||||
// When WebSocket is connected, skip REST polling entirely.
|
// When WebSocket is connected, skip REST polling entirely.
|
||||||
// Config/user updates arrive via WS push.
|
// Config/user updates arrive via WS push.
|
||||||
@@ -391,10 +396,17 @@ func (s *Service) startWSClient(ctx context.Context) {
|
|||||||
|
|
||||||
// newWSClient creates a WSClient with standard event/status callbacks.
|
// newWSClient creates a WSClient with standard event/status callbacks.
|
||||||
func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
func (s *Service) newWSClient(wsURL string) *panel.WSClient {
|
||||||
|
wsCfg := panel.WSClientConfig{
|
||||||
|
StatusInterval: time.Duration(s.cfg.WS.StatusInterval) * time.Second,
|
||||||
|
HandshakeTimeout: time.Duration(s.cfg.WS.HandshakeTimeout) * time.Second,
|
||||||
|
BackoffInitial: time.Duration(s.cfg.WS.BackoffInitial) * time.Second,
|
||||||
|
BackoffMax: time.Duration(s.cfg.WS.BackoffMax) * time.Second,
|
||||||
|
}
|
||||||
return panel.NewWSClient(
|
return panel.NewWSClient(
|
||||||
wsURL,
|
wsURL,
|
||||||
s.cfg.Panel.Token,
|
s.cfg.Panel.Token,
|
||||||
s.cfg.Panel.NodeID,
|
s.cfg.Panel.NodeID,
|
||||||
|
wsCfg,
|
||||||
func(event panel.WSEvent) {
|
func(event panel.WSEvent) {
|
||||||
select {
|
select {
|
||||||
case s.wsEvents <- event:
|
case s.wsEvents <- event:
|
||||||
@@ -446,6 +458,8 @@ func (s *Service) handleWSStatus(ctx context.Context, status panel.WSStatusChang
|
|||||||
} else {
|
} else {
|
||||||
nlog.Core().Info("ws disconnected")
|
nlog.Core().Info("ws disconnected")
|
||||||
}
|
}
|
||||||
|
// Clear global device state on disconnect
|
||||||
|
s.kernel.ClearGlobalDevices()
|
||||||
s.pullViaAPIAsync(ctx)
|
s.pullViaAPIAsync(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -551,6 +565,12 @@ func (s *Service) handleWSEvent(ctx context.Context, event panel.WSEvent) {
|
|||||||
}
|
}
|
||||||
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
|
s.applyUserDelta(ctx, event.DeltaAction, event.DeltaUsers)
|
||||||
|
|
||||||
|
case panel.WSEventSyncDevices:
|
||||||
|
// Sync global device state
|
||||||
|
if event.DeviceUsers != nil {
|
||||||
|
s.kernel.UpdateGlobalDevices(event.DeviceUsers)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
nlog.Core().Debug(fmt.Sprintf("unknown ws event: %v", event.Type))
|
nlog.Core().Debug(fmt.Sprintf("unknown ws event: %v", event.Type))
|
||||||
}
|
}
|
||||||
@@ -1068,3 +1088,26 @@ func computeUserHash(users []panel.User) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%x", h.Sum(nil))
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Device management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// sendDeviceBatch reports local device snapshot to panel via WS.
|
||||||
|
func (s *Service) sendDeviceBatch() {
|
||||||
|
if s.wsClient == nil || !s.wsClient.IsConnected() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := s.tracker.FlushAliveIPs()
|
||||||
|
// FlushAliveIPs returns nil if no changes since last flush
|
||||||
|
if devices == nil {
|
||||||
|
nlog.Core().Debug("device snapshot unchanged, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wsClient.SendDeviceReport(devices)
|
||||||
|
nlog.Core().Debug("device snapshot sent", "users", len(devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportDevices periodically reports device snapshot to panel.
|
||||||
|
func (s *Service) reportDevices() {
|
||||||
|
s.sendDeviceBatch()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package tracker
|
package tracker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -49,6 +52,9 @@ type Tracker struct {
|
|||||||
// aliveIPsBuf is a reusable buffer for FlushAliveIPs output.
|
// aliveIPsBuf is a reusable buffer for FlushAliveIPs output.
|
||||||
// Avoids allocating a new map+slice every 60s.
|
// Avoids allocating a new map+slice every 60s.
|
||||||
aliveIPsBuf map[int][]string
|
aliveIPsBuf map[int][]string
|
||||||
|
|
||||||
|
// lastAliveIPsHash detects changes to avoid duplicate reports.
|
||||||
|
lastAliveIPsHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Tracker {
|
func New() *Tracker {
|
||||||
@@ -154,13 +160,23 @@ func (t *Tracker) HasTraffic() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FlushAliveIPs returns per-user alive IPs.
|
// FlushAliveIPs returns per-user alive IPs.
|
||||||
// Reuses internal buffer to avoid allocation every 60s.
|
// Reuses internal buffer. Returns nil if unchanged.
|
||||||
func (t *Tracker) FlushAliveIPs() map[int][]string {
|
func (t *Tracker) FlushAliveIPs() map[int][]string {
|
||||||
s := t.live.Load()
|
s := t.live.Load()
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
// Calculate hash of current aliveIPs
|
||||||
|
currentHash := calcAliveIPsHash(s.aliveIPs)
|
||||||
|
|
||||||
|
// If no changes, return nil to avoid duplicate reporting
|
||||||
|
if currentHash == t.lastAliveIPsHash {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.lastAliveIPsHash = currentHash
|
||||||
|
|
||||||
// Clear old buffer entries.
|
// Clear old buffer entries.
|
||||||
for k := range t.aliveIPsBuf {
|
for k := range t.aliveIPsBuf {
|
||||||
delete(t.aliveIPsBuf, k)
|
delete(t.aliveIPsBuf, k)
|
||||||
@@ -182,6 +198,40 @@ func (t *Tracker) FlushAliveIPs() map[int][]string {
|
|||||||
return t.aliveIPsBuf
|
return t.aliveIPsBuf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calcAliveIPsHash computes a deterministic hash for change detection.
|
||||||
|
func calcAliveIPsHash(aliveIPs map[int]map[string]bool) string {
|
||||||
|
if len(aliveIPs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
|
||||||
|
// Sort user IDs for consistent hashing
|
||||||
|
userIDs := make([]int, 0, len(aliveIPs))
|
||||||
|
for uid := range aliveIPs {
|
||||||
|
userIDs = append(userIDs, uid)
|
||||||
|
}
|
||||||
|
sort.Ints(userIDs)
|
||||||
|
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
ips := aliveIPs[uid]
|
||||||
|
// Sort IPs for consistent hashing
|
||||||
|
ipList := make([]string, 0, len(ips))
|
||||||
|
for ip := range ips {
|
||||||
|
ipList = append(ipList, ip)
|
||||||
|
}
|
||||||
|
sort.Strings(ipList)
|
||||||
|
|
||||||
|
// Write user ID and IPs to hash
|
||||||
|
h.Write([]byte{byte(uid >> 24), byte(uid >> 16), byte(uid >> 8), byte(uid)})
|
||||||
|
for _, ip := range ipList {
|
||||||
|
h.Write([]byte(ip))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
// CurrentOnline returns a snapshot copy of user_id → device count (distinct IPs).
|
// CurrentOnline returns a snapshot copy of user_id → device count (distinct IPs).
|
||||||
// Lock-free: reads from live snapshot.
|
// Lock-free: reads from live snapshot.
|
||||||
func (t *Tracker) CurrentOnline() map[int]int {
|
func (t *Tracker) CurrentOnline() map[int]int {
|
||||||
|
|||||||
Reference in New Issue
Block a user