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:
MHSanaei
2026-06-02 18:33:21 +02:00
parent 6f6c7fc17a
commit 3af2da0142
12 changed files with 334 additions and 31 deletions
+110
View File
@@ -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
View File
@@ -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