mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 00:26:06 +00:00
fix(online): scope online status per node instead of a global union
The inbounds page and Nodes page checked each client's email against a single deduped union of every node's online clients, so a client connected to one node showed as online on every inbound across every node. The local online set was also derived from the email-keyed client_traffics.last_online column, which remote-node syncs bump too, leaking remote-only clients onto local inbounds. Track online clients per node: the local panel's own xray clients under key 0 (derived from live traffic-poll deltas via RefreshLocalOnline, kept in memory and independent of the shared last_online column) and each remote node under its id. Add GetOnlineClientsByNode plus a /clients/onlinesByNode endpoint and onlineByNode WS field; node.go and the inbounds rollup now scope online by node. The flat GetOnlineClients union is kept for client-centric and total-count views (Clients page, dashboard, telegram). Closes #4809
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newOnlineTestProcess() *Process {
|
||||
return &Process{newProcess(nil)}
|
||||
}
|
||||
|
||||
func assertSameSet(t *testing.T, label string, got, want []string) {
|
||||
t.Helper()
|
||||
g := append([]string(nil), got...)
|
||||
w := append([]string(nil), want...)
|
||||
slices.Sort(g)
|
||||
slices.Sort(w)
|
||||
if !slices.Equal(g, w) {
|
||||
t.Errorf("%s = %v, want %v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOnlineClientsByNodeScopesPerNode pins the fix for issue #4809: a
|
||||
// client online on one node must not be reported online on any other node.
|
||||
func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
|
||||
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
|
||||
p.SetNodeOnlineClients(5, []string{"user3"})
|
||||
|
||||
byNode := p.GetOnlineClientsByNode()
|
||||
|
||||
assertSameSet(t, "local (key 0)", byNode[localNodeKey], []string{"user1"})
|
||||
assertSameSet(t, "node 3", byNode[3], []string{"user1", "user2"})
|
||||
assertSameSet(t, "node 5", byNode[5], []string{"user3"})
|
||||
|
||||
if slices.Contains(byNode[5], "user1") {
|
||||
t.Errorf("user1 leaked onto node 5: %v", byNode[5])
|
||||
}
|
||||
if slices.Contains(byNode[localNodeKey], "user3") || slices.Contains(byNode[3], "user3") {
|
||||
t.Errorf("user3 leaked off node 5: local=%v node3=%v", byNode[localNodeKey], byNode[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOnlineClientsByNodeOmitsEmptyGroups keeps the payload small: a node
|
||||
// with no online clients (e.g. just cleared) must not appear as an empty key.
|
||||
func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
p.SetNodeOnlineClients(3, []string{"user1"})
|
||||
p.SetNodeOnlineClients(7, []string{})
|
||||
|
||||
byNode := p.GetOnlineClientsByNode()
|
||||
|
||||
if _, ok := byNode[7]; ok {
|
||||
t.Errorf("node 7 has no online clients but is present: %v", byNode)
|
||||
}
|
||||
if _, ok := byNode[localNodeKey]; ok {
|
||||
t.Errorf("no local clients online but key 0 is present: %v", byNode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOnlineClientsUnionDedupes confirms the flat union (used by the
|
||||
// client-centric / total-count views) still merges every node and dedupes.
|
||||
func TestGetOnlineClientsUnionDedupes(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
|
||||
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
|
||||
|
||||
assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
|
||||
}
|
||||
|
||||
// TestRefreshLocalOnlineGraceWindow checks the in-memory local set honours the
|
||||
// grace window: idle-but-recent clients stay online, stale ones age out, and
|
||||
// the set is derived only from local activity (never the shared DB column).
|
||||
func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
const grace = 20000
|
||||
|
||||
p.RefreshLocalOnline([]string{"user1"}, 1000, grace)
|
||||
if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
|
||||
t.Fatalf("user1 should be online right after activity, got %v", got)
|
||||
}
|
||||
|
||||
p.RefreshLocalOnline([]string{"user2"}, 11000, grace)
|
||||
got := p.GetOnlineClientsByNode()[localNodeKey]
|
||||
if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
|
||||
t.Fatalf("both within grace window, got %v", got)
|
||||
}
|
||||
|
||||
p.RefreshLocalOnline(nil, 22000, grace)
|
||||
got = p.GetOnlineClientsByNode()[localNodeKey]
|
||||
if slices.Contains(got, "user1") {
|
||||
t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
|
||||
}
|
||||
if !slices.Contains(got, "user2") {
|
||||
t.Errorf("user2 (idle 11s, within grace) should still be online, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
|
||||
// clients must disappear from the per-node map immediately.
|
||||
func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
|
||||
p := newOnlineTestProcess()
|
||||
p.SetNodeOnlineClients(3, []string{"user1"})
|
||||
p.ClearNodeOnlineClients(3)
|
||||
|
||||
if _, ok := p.GetOnlineClientsByNode()[3]; ok {
|
||||
t.Errorf("node 3 should be absent after ClearNodeOnlineClients")
|
||||
}
|
||||
}
|
||||
+70
-7
@@ -129,12 +129,24 @@ type process struct {
|
||||
version string
|
||||
apiPort int
|
||||
|
||||
// onlineClients is the set of emails active on THIS panel's own xray
|
||||
// within the online grace window. It is derived only from local xray
|
||||
// traffic polls (see RefreshLocalOnline) — never from remote-node
|
||||
// snapshots — so a client connected solely to a remote node is not
|
||||
// reported online on local inbounds.
|
||||
onlineClients []string
|
||||
// localLastOnline records, per email, the last time this panel's own
|
||||
// xray reported traffic for it. RefreshLocalOnline rebuilds
|
||||
// onlineClients from this map each tick, keeping the local online set
|
||||
// independent of the shared client_traffics.last_online column — that
|
||||
// column is bumped by remote-node syncs too and would otherwise leak
|
||||
// remote-only clients into the local set.
|
||||
localLastOnline map[string]int64
|
||||
// nodeOnlineClients holds the online-emails list reported by each
|
||||
// remote node, keyed by node id. NodeTrafficSyncJob populates entries
|
||||
// per cron tick and clears them when a node's probe fails. The mutex
|
||||
// guards both this map and onlineClients above so GetOnlineClients
|
||||
// can build the union without a torn read.
|
||||
// guards this map, onlineClients, and localLastOnline above so the
|
||||
// online getters never see a torn read.
|
||||
nodeOnlineClients map[int][]string
|
||||
onlineMu sync.RWMutex
|
||||
|
||||
@@ -152,6 +164,12 @@ var (
|
||||
xrayForceStopTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// localNodeKey is the GetOnlineClientsByNode key under which this panel's
|
||||
// own (non-node-managed) inbounds report their online clients. Node ids
|
||||
// autoincrement from 1, so 0 is a safe sentinel that never collides with a
|
||||
// real node. The frontend mirrors this contract (nodeId ?? 0).
|
||||
const localNodeKey = 0
|
||||
|
||||
// newProcess creates a new internal process struct for Xray.
|
||||
func newProcess(config *Config) *process {
|
||||
return &process{
|
||||
@@ -251,12 +269,57 @@ func (p *Process) GetOnlineClients() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// SetOnlineClients sets the locally-online list. Called by the local
|
||||
// XrayTrafficJob after each xray gRPC stats poll.
|
||||
func (p *Process) SetOnlineClients(users []string) {
|
||||
// GetOnlineClientsByNode returns online emails grouped by the node that
|
||||
// reported them: this panel's own xray clients under localNodeKey (0), and
|
||||
// each remote node's clients under that node's id. Unlike GetOnlineClients
|
||||
// (which flattens everything into one deduped union), this preserves node
|
||||
// attribution so per-inbound/per-node online counts don't bleed a client
|
||||
// connected to one node onto every other node. Empty groups are omitted.
|
||||
func (p *Process) GetOnlineClientsByNode() map[int][]string {
|
||||
p.onlineMu.RLock()
|
||||
defer p.onlineMu.RUnlock()
|
||||
|
||||
out := make(map[int][]string, len(p.nodeOnlineClients)+1)
|
||||
if len(p.onlineClients) > 0 {
|
||||
local := make([]string, len(p.onlineClients))
|
||||
copy(local, p.onlineClients)
|
||||
out[localNodeKey] = local
|
||||
}
|
||||
for nodeID, list := range p.nodeOnlineClients {
|
||||
if len(list) == 0 {
|
||||
continue
|
||||
}
|
||||
cp := make([]string, len(list))
|
||||
copy(cp, list)
|
||||
out[nodeID] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RefreshLocalOnline records that each email in activeEmails had local xray
|
||||
// traffic at now, then rebuilds onlineClients from every email seen within
|
||||
// graceMs and prunes entries older than that. Called by the local
|
||||
// XrayTrafficJob after each xray gRPC stats poll. Pass a nil/empty
|
||||
// activeEmails to only prune — NodeTrafficSyncJob does this so a stopped
|
||||
// local xray's clients still age out between local traffic polls.
|
||||
func (p *Process) RefreshLocalOnline(activeEmails []string, now, graceMs int64) {
|
||||
p.onlineMu.Lock()
|
||||
p.onlineClients = users
|
||||
p.onlineMu.Unlock()
|
||||
defer p.onlineMu.Unlock()
|
||||
if p.localLastOnline == nil {
|
||||
p.localLastOnline = make(map[string]int64, len(activeEmails))
|
||||
}
|
||||
for _, email := range activeEmails {
|
||||
p.localLastOnline[email] = now
|
||||
}
|
||||
online := make([]string, 0, len(p.localLastOnline))
|
||||
for email, ts := range p.localLastOnline {
|
||||
if now-ts < graceMs {
|
||||
online = append(online, email)
|
||||
} else {
|
||||
delete(p.localLastOnline, email)
|
||||
}
|
||||
}
|
||||
p.onlineClients = online
|
||||
}
|
||||
|
||||
// SetNodeOnlineClients records the online-emails set for one remote
|
||||
|
||||
Reference in New Issue
Block a user