init xboard node
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// KickAction represents a connection that should be closed.
|
||||
type KickAction struct {
|
||||
ConnID string
|
||||
UserID int
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Limiter enforces per-user device limits and detects removed users.
|
||||
type Limiter struct {
|
||||
mu sync.RWMutex
|
||||
users map[int]panel.User
|
||||
|
||||
deviceLimitEvents atomic.Uint64
|
||||
}
|
||||
|
||||
func New() *Limiter {
|
||||
return &Limiter{
|
||||
users: make(map[int]panel.User),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateUsers refreshes the user limit configuration and returns
|
||||
// the IDs of users that were present before but are now removed.
|
||||
func (l *Limiter) UpdateUsers(users []panel.User) []int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
newUsers := make(map[int]panel.User, len(users))
|
||||
for _, u := range users {
|
||||
newUsers[u.ID] = u
|
||||
}
|
||||
|
||||
// Find removed users
|
||||
var removed []int
|
||||
for id := range l.users {
|
||||
if _, ok := newUsers[id]; !ok {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
}
|
||||
|
||||
l.users = newUsers
|
||||
return removed
|
||||
}
|
||||
|
||||
// Check inspects connections against device limits.
|
||||
// Returns a list of connections that should be closed.
|
||||
func (l *Limiter) Check(conns []kernel.Connection) []KickAction {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
var kicks []KickAction
|
||||
|
||||
// Device limit check
|
||||
userIPs := make(map[int]map[string]bool)
|
||||
userConnsByIP := make(map[int]map[string][]string) // user_id -> ip -> []conn_ids
|
||||
for _, conn := range conns {
|
||||
if conn.UserID == 0 || conn.SourceIP == "" {
|
||||
continue
|
||||
}
|
||||
if userIPs[conn.UserID] == nil {
|
||||
userIPs[conn.UserID] = make(map[string]bool)
|
||||
userConnsByIP[conn.UserID] = make(map[string][]string)
|
||||
}
|
||||
userIPs[conn.UserID][conn.SourceIP] = true
|
||||
userConnsByIP[conn.UserID][conn.SourceIP] = append(
|
||||
userConnsByIP[conn.UserID][conn.SourceIP], conn.ID,
|
||||
)
|
||||
}
|
||||
|
||||
for uid, ips := range userIPs {
|
||||
user, ok := l.users[uid]
|
||||
if !ok || user.DeviceLimit == 0 {
|
||||
continue
|
||||
}
|
||||
if len(ips) <= user.DeviceLimit {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count a device-limit violation event for metrics reporting.
|
||||
l.deviceLimitEvents.Add(1)
|
||||
|
||||
slog.Warn("user exceeded device limit",
|
||||
"user_id", uid,
|
||||
"devices", len(ips),
|
||||
"limit", user.DeviceLimit,
|
||||
)
|
||||
|
||||
// Sort IPs by numeric address so the "allowed" set is deterministic
|
||||
// across calls. netip.Addr comparison avoids the lexicographic ordering
|
||||
// quirks of string sort (e.g. "10.x" < "192.x", IPv6 always last).
|
||||
// Parse all addresses up front to avoid O(n log n) repeated parsing.
|
||||
type parsedIP struct {
|
||||
raw string
|
||||
addr netip.Addr
|
||||
}
|
||||
ipList := make([]parsedIP, 0, len(ips))
|
||||
for ip := range ips {
|
||||
a, _ := netip.ParseAddr(ip)
|
||||
ipList = append(ipList, parsedIP{raw: ip, addr: a})
|
||||
}
|
||||
sort.Slice(ipList, func(i, j int) bool {
|
||||
return ipList[i].addr.Compare(ipList[j].addr) < 0
|
||||
})
|
||||
|
||||
allowed := make(map[string]bool, user.DeviceLimit)
|
||||
for i := 0; i < user.DeviceLimit && i < len(ipList); i++ {
|
||||
allowed[ipList[i].raw] = true
|
||||
}
|
||||
|
||||
for ip, connIDs := range userConnsByIP[uid] {
|
||||
if allowed[ip] {
|
||||
continue
|
||||
}
|
||||
for _, connID := range connIDs {
|
||||
kicks = append(kicks, KickAction{
|
||||
ConnID: connID,
|
||||
UserID: uid,
|
||||
Reason: fmt.Sprintf("devices %d > limit %d", len(ips), user.DeviceLimit),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kicks
|
||||
}
|
||||
|
||||
// LimiterMetrics holds aggregated limiter statistics.
|
||||
type LimiterMetrics struct {
|
||||
// DeviceLimitEvents is the total number of times a user exceeded
|
||||
// the configured device limit (since process start).
|
||||
DeviceLimitEvents uint64
|
||||
}
|
||||
|
||||
// SnapshotMetrics returns a snapshot of limiter metrics.
|
||||
func (l *Limiter) SnapshotMetrics() LimiterMetrics {
|
||||
return LimiterMetrics{
|
||||
DeviceLimitEvents: l.deviceLimitEvents.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// KickUsers returns KickActions for all connections belonging to the given user IDs.
|
||||
func (l *Limiter) KickUsers(conns []kernel.Connection, userIDs []int) []KickAction {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
remove := make(map[int]bool, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
remove[id] = true
|
||||
}
|
||||
|
||||
var kicks []KickAction
|
||||
for _, conn := range conns {
|
||||
if remove[conn.UserID] {
|
||||
kicks = append(kicks, KickAction{
|
||||
ConnID: conn.ID,
|
||||
UserID: conn.UserID,
|
||||
Reason: "user removed from panel",
|
||||
})
|
||||
}
|
||||
}
|
||||
return kicks
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
l := New()
|
||||
if l == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers(t *testing.T) {
|
||||
l := New()
|
||||
users := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 2},
|
||||
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0},
|
||||
}
|
||||
removed := l.UpdateUsers(users)
|
||||
|
||||
if len(removed) != 0 {
|
||||
t.Errorf("first update should have no removed users, got %v", removed)
|
||||
}
|
||||
|
||||
// Verify by running a check
|
||||
conns := []kernel.Connection{{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"}}
|
||||
kicks := l.Check(conns)
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("expected no kicks, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_DetectsRemoved(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
// Remove user 2
|
||||
removed := l.UpdateUsers([]panel.User{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
if len(removed) != 1 || removed[0] != 2 {
|
||||
t.Errorf("expected removed=[2], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_DetectsMultipleRemoved(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
{ID: 3, UUID: "u3"},
|
||||
})
|
||||
|
||||
// Remove users 1 and 3
|
||||
removed := l.UpdateUsers([]panel.User{
|
||||
{ID: 2, UUID: "u2"},
|
||||
})
|
||||
|
||||
sort.Ints(removed)
|
||||
if len(removed) != 2 || removed[0] != 1 || removed[1] != 3 {
|
||||
t.Errorf("expected removed=[1,3], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_NoRemovals(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, UUID: "u1"},
|
||||
})
|
||||
|
||||
// Add user, keep existing
|
||||
removed := l.UpdateUsers([]panel.User{
|
||||
{ID: 1, UUID: "u1"},
|
||||
{ID: 2, UUID: "u2"},
|
||||
})
|
||||
|
||||
if len(removed) != 0 {
|
||||
t.Errorf("expected no removals, got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUsers_ReplacesAll(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{{ID: 1, DeviceLimit: 1}})
|
||||
|
||||
// Replace with different users
|
||||
removed := l.UpdateUsers([]panel.User{{ID: 2, DeviceLimit: 1}})
|
||||
|
||||
if len(removed) != 1 || removed[0] != 1 {
|
||||
t.Errorf("expected removed=[1], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_NoKick_UnderLimit(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 3},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("2 devices under limit 3, got %d kicks", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_Kick_OverLimit(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 2},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
|
||||
{ID: "c3", UserID: 1, SourceIP: "3.3.3.3"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) == 0 {
|
||||
t.Fatal("expected kicks for exceeding device limit")
|
||||
}
|
||||
if len(kicks) < 1 {
|
||||
t.Errorf("expected at least 1 kick, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_ExactlyAtLimit(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 2},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("exactly at limit should not kick, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_UnlimitedDevices(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 0}, // 0 = unlimited
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 1, SourceIP: "2.2.2.2"},
|
||||
{ID: "c3", UserID: 1, SourceIP: "3.3.3.3"},
|
||||
{ID: "c4", UserID: 1, SourceIP: "4.4.4.4"},
|
||||
{ID: "c5", UserID: 1, SourceIP: "5.5.5.5"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("unlimited devices should not be kicked, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_SameIPMultipleConnections(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 1},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c3", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("same IP multiple conns should count as 1 device, got %d kicks", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLimit_EmptySourceIP(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 1},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: ""},
|
||||
{ID: "c2", UserID: 1, SourceIP: ""},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("empty source IP should be ignored, got %d kicks", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleUsers_DeviceLimits(t *testing.T) {
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, DeviceLimit: 1},
|
||||
{ID: 2, DeviceLimit: 1},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
|
||||
{ID: "c3", UserID: 2, SourceIP: "3.3.3.3"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
|
||||
deviceKicks := 0
|
||||
for _, k := range kicks {
|
||||
if k.UserID == 2 {
|
||||
deviceKicks++
|
||||
}
|
||||
}
|
||||
if deviceKicks == 0 {
|
||||
t.Error("expected device kick for user 2")
|
||||
}
|
||||
|
||||
for _, k := range kicks {
|
||||
if k.UserID == 1 {
|
||||
t.Error("user 1 should not be kicked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSpeedKick(t *testing.T) {
|
||||
// Speed limits should NOT cause kicks — only device limits and user removal
|
||||
l := New()
|
||||
l.UpdateUsers([]panel.User{
|
||||
{ID: 1, SpeedLimit: 1, DeviceLimit: 0},
|
||||
})
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
}
|
||||
kicks := l.Check(conns)
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("speed limits should not cause kicks, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickUsers_Basic(t *testing.T) {
|
||||
l := New()
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
|
||||
{ID: "c3", UserID: 1, SourceIP: "1.1.1.2"},
|
||||
{ID: "c4", UserID: 3, SourceIP: "3.3.3.3"},
|
||||
}
|
||||
|
||||
kicks := l.KickUsers(conns, []int{1})
|
||||
|
||||
if len(kicks) != 2 {
|
||||
t.Fatalf("expected 2 kicks for user 1, got %d", len(kicks))
|
||||
}
|
||||
for _, k := range kicks {
|
||||
if k.UserID != 1 {
|
||||
t.Errorf("expected kick for user 1, got user %d", k.UserID)
|
||||
}
|
||||
if k.Reason != "user removed from panel" {
|
||||
t.Errorf("unexpected reason: %s", k.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickUsers_MultipleUsers(t *testing.T) {
|
||||
l := New()
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
{ID: "c2", UserID: 2, SourceIP: "2.2.2.2"},
|
||||
{ID: "c3", UserID: 3, SourceIP: "3.3.3.3"},
|
||||
}
|
||||
|
||||
kicks := l.KickUsers(conns, []int{1, 3})
|
||||
|
||||
if len(kicks) != 2 {
|
||||
t.Fatalf("expected 2 kicks, got %d", len(kicks))
|
||||
}
|
||||
kickedUsers := map[int]bool{}
|
||||
for _, k := range kicks {
|
||||
kickedUsers[k.UserID] = true
|
||||
}
|
||||
if !kickedUsers[1] || !kickedUsers[3] {
|
||||
t.Errorf("expected users 1 and 3 kicked, got %v", kickedUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickUsers_NoUsers(t *testing.T) {
|
||||
l := New()
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
}
|
||||
|
||||
kicks := l.KickUsers(conns, nil)
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("expected no kicks for nil user list, got %d", len(kicks))
|
||||
}
|
||||
|
||||
kicks = l.KickUsers(conns, []int{})
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("expected no kicks for empty user list, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickUsers_NoMatchingConnections(t *testing.T) {
|
||||
l := New()
|
||||
|
||||
conns := []kernel.Connection{
|
||||
{ID: "c1", UserID: 1, SourceIP: "1.1.1.1"},
|
||||
}
|
||||
|
||||
kicks := l.KickUsers(conns, []int{99})
|
||||
if len(kicks) != 0 {
|
||||
t.Errorf("expected no kicks for non-existent user, got %d", len(kicks))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// SpeedTracker manages per-user token-bucket rate limiters.
|
||||
// It does NOT wrap connections itself — instead, ConnTracker consults it
|
||||
// via GetLimiter to embed rate limiting in the same tracked connection
|
||||
// wrapper that does byte counting.
|
||||
type SpeedTracker struct {
|
||||
limiter *Limiter
|
||||
mu sync.RWMutex
|
||||
buckets map[int]*rate.Limiter // userID → shared rate limiter
|
||||
uuidMap map[string]int // UUID → userID
|
||||
|
||||
// Fast-path: when no users have a speed limit, GetLimiter returns nil
|
||||
// immediately without any map lookup.
|
||||
hasLimits atomic.Bool
|
||||
}
|
||||
|
||||
// NewSpeedTracker creates a bucket manager for per-user bandwidth throttling.
|
||||
func NewSpeedTracker(l *Limiter) *SpeedTracker {
|
||||
return &SpeedTracker{
|
||||
limiter: l,
|
||||
buckets: make(map[int]*rate.Limiter),
|
||||
uuidMap: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateBuckets rebuilds rate limiter buckets based on current user config.
|
||||
// Call this whenever the user list changes.
|
||||
func (t *SpeedTracker) UpdateBuckets() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.limiter.mu.RLock()
|
||||
defer t.limiter.mu.RUnlock()
|
||||
|
||||
newBuckets := make(map[int]*rate.Limiter, len(t.limiter.users))
|
||||
newUUIDMap := make(map[string]int, len(t.limiter.users))
|
||||
for uid, user := range t.limiter.users {
|
||||
if user.UUID != "" {
|
||||
newUUIDMap[user.UUID] = uid
|
||||
}
|
||||
if user.SpeedLimit <= 0 {
|
||||
continue
|
||||
}
|
||||
bytesPerSec := int(user.SpeedLimit) * 1_000_000 / 8 // Mbps → bytes/s
|
||||
// Burst = 1 second of data, floored at 64 KiB, capped at 4 s.
|
||||
// A generous burst ensures downloads ramp up quickly (good UX) while
|
||||
// the token bucket still converges to the target rate within seconds.
|
||||
// The 4 s cap prevents excessive burst at very high limits.
|
||||
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[uid]; ok {
|
||||
existing.SetLimit(rate.Limit(bytesPerSec))
|
||||
existing.SetBurst(burst)
|
||||
newBuckets[uid] = existing
|
||||
} else {
|
||||
newBuckets[uid] = rate.NewLimiter(rate.Limit(bytesPerSec), burst)
|
||||
}
|
||||
}
|
||||
t.buckets = newBuckets
|
||||
t.uuidMap = newUUIDMap
|
||||
t.hasLimits.Store(len(newBuckets) > 0)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Thread-safe.
|
||||
func (t *SpeedTracker) GetLimiter(user string) *rate.Limiter {
|
||||
if !t.hasLimits.Load() {
|
||||
return nil
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.buckets[t.uuidMap[user]]
|
||||
}
|
||||
|
||||
// HasLimits returns true if any user currently has a speed limit configured.
|
||||
func (t *SpeedTracker) HasLimits() bool {
|
||||
return t.hasLimits.Load()
|
||||
}
|
||||
|
||||
// LimitedUserCount returns the number of users with active speed limits.
|
||||
func (t *SpeedTracker) LimitedUserCount() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.buckets)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package limiter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
users := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0}, // 3 Mbps
|
||||
{ID: 2, UUID: "u2", SpeedLimit: 0, DeviceLimit: 0}, // unlimited
|
||||
{ID: 3, UUID: "u3", SpeedLimit: 10, DeviceLimit: 2}, // 10 Mbps
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
// User 1: 3 Mbps = 375000 bytes/sec
|
||||
b1 := st.GetLimiter("u1")
|
||||
if b1 == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
expectedRate := float64(3 * 1000000 / 8)
|
||||
if float64(b1.Limit()) != expectedRate {
|
||||
t.Errorf("user 1 rate: got %v, want %v", float64(b1.Limit()), expectedRate)
|
||||
}
|
||||
// Burst should be max(bytesPerSec, 64KB) = max(375000, 65536) = 375000
|
||||
expectedBurst := 3 * 1000000 / 8
|
||||
if b1.Burst() != expectedBurst {
|
||||
t.Errorf("user 1 burst: got %v, want %v", b1.Burst(), expectedBurst)
|
||||
}
|
||||
|
||||
// User 2: no speed limit, no bucket
|
||||
b2 := st.GetLimiter("u2")
|
||||
if b2 != nil {
|
||||
t.Fatal("expected no bucket for user 2")
|
||||
}
|
||||
|
||||
// User 3: 10 Mbps = 1250000 bytes/sec
|
||||
b3 := st.GetLimiter("u3")
|
||||
if b3 == nil {
|
||||
t.Fatal("expected bucket for user 3")
|
||||
}
|
||||
expectedRate3 := float64(10 * 1000000 / 8)
|
||||
if float64(b3.Limit()) != expectedRate3 {
|
||||
t.Errorf("user 3 rate: got %v, want %v", float64(b3.Limit()), expectedRate3)
|
||||
}
|
||||
// Burst should be max(1250000, 64KB) = 1250000
|
||||
expectedBurst3 := 10 * 1000000 / 8
|
||||
if b3.Burst() != expectedBurst3 {
|
||||
t.Errorf("user 3 burst: got %v, want %v", b3.Burst(), expectedBurst3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets_PreservesExisting(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
users := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
b1 := st.GetLimiter("u1")
|
||||
if b1 == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
|
||||
// Update same user with different speed — should reuse same *rate.Limiter pointer
|
||||
users2 := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 5, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users2)
|
||||
st.UpdateBuckets()
|
||||
|
||||
b1After := st.GetLimiter("u1")
|
||||
if b1After == nil {
|
||||
t.Fatal("expected bucket for user 1 after update")
|
||||
}
|
||||
if b1 != b1After {
|
||||
t.Error("expected same rate.Limiter instance to be reused")
|
||||
}
|
||||
|
||||
// Rate should be updated
|
||||
expectedRate := float64(5 * 1000000 / 8)
|
||||
if float64(b1After.Limit()) != expectedRate {
|
||||
t.Errorf("user 1 updated rate: got %v, want %v", float64(b1After.Limit()), expectedRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedTracker_UpdateBuckets_RemovesUsers(t *testing.T) {
|
||||
l := New()
|
||||
st := NewSpeedTracker(l)
|
||||
|
||||
users := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
{ID: 2, UUID: "u2", SpeedLimit: 5, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users)
|
||||
st.UpdateBuckets()
|
||||
|
||||
if st.GetLimiter("u1") == nil || st.GetLimiter("u2") == nil {
|
||||
t.Fatal("expected buckets for both users")
|
||||
}
|
||||
|
||||
// Remove user 2
|
||||
users2 := []panel.User{
|
||||
{ID: 1, UUID: "u1", SpeedLimit: 3, DeviceLimit: 0},
|
||||
}
|
||||
l.UpdateUsers(users2)
|
||||
st.UpdateBuckets()
|
||||
|
||||
if st.GetLimiter("u1") == nil {
|
||||
t.Fatal("expected bucket for user 1")
|
||||
}
|
||||
if st.GetLimiter("u2") != nil {
|
||||
t.Error("expected no bucket for removed user 2")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user