Files
l-ui/internal/web/service/inbound_node.go
T

304 lines
9.6 KiB
Go

package service
import (
"errors"
"fmt"
"sort"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// nodeBulkPushThreshold caps how many per-client runtime applies a
// single operation will stream to a node. Above it, the panel marks the
// node dirty instead and lets the node's next config pull converge the
// whole inbound. Small ops stay on the live per-client path.
const nodeBulkPushThreshold = 32
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
mgr := runtime.GetManager()
if mgr == nil {
return nil, fmt.Errorf("runtime manager not initialised")
}
return mgr.RuntimeFor(ib.NodeID)
}
func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
if ib.NodeID == nil {
rt, err := s.runtimeFor(ib)
if err != nil {
return nil, false, false, nil
}
return rt, true, false, nil
}
nodeSvc := NodeService{}
enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
if err != nil {
return nil, false, false, err
}
if !enabled || status == "offline" {
return nil, false, true, nil
}
rt, err := s.runtimeFor(ib)
if err != nil {
return nil, false, true, nil
}
return rt, true, false, nil
}
func (s *InboundService) NodeIsPending(nodeID *int) bool {
if nodeID == nil {
return false
}
return (&NodeService{}).IsNodePending(*nodeID)
}
func (s *InboundService) AnyNodePending(inboundIds []int) bool {
if len(inboundIds) == 0 {
return false
}
nodeSvc := NodeService{}
for _, id := range inboundIds {
ib, err := s.GetInbound(id)
if err != nil || ib.NodeID == nil {
continue
}
if nodeSvc.IsNodePending(*ib.NodeID) {
return true
}
}
return false
}
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
// Xray's stats counters often report a zero delta for an active session across
// a single poll, so a 5s grace would still drop the client on the next tick.
// ~4 polls of slack keeps idle-but-connected clients visible without lingering
// long after a real disconnect.
const onlineGracePeriodMs int64 = 20000
// mergeActivationExpiry: master absolute wins; node may only activate when
// master is unset/duration.
func mergeActivationExpiry(existing, node int64) int64 {
if existing > 0 {
return existing
}
return node
}
func clampTrafficCounter(v int64) int64 {
if v > database.TrafficMax {
return database.TrafficMax
}
if v < 0 {
return 0
}
return v
}
// GetNodeInboundTrafficTotals returns the current cumulative up/down for every
// node-hosted inbound, keyed by tag. The node sync diffs successive snapshots of
// this to derive per-inbound speed for the dashboard — node inbounds have no
// local Xray poll to produce live deltas the way local inbounds do.
func (s *InboundService) GetNodeInboundTrafficTotals() (map[string][2]int64, error) {
var rows []struct {
Tag string
Up int64
Down int64
}
if err := database.GetDB().Table("inbounds").
Select("tag, up, down").
Where("node_id IS NOT NULL").
Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string][2]int64, len(rows))
for _, r := range rows {
out[r.Tag] = [2]int64{r.Up, r.Down}
}
return out, nil
}
func (s *InboundService) GetOnlineClients() []string {
// Lite master: no local core aggregates online clients; the online
// store (see online_store.go) is fed by agent reports and node polling.
if out := sharedOnlineStore.GetOnlineClients(); out != nil {
return out
}
return []string{}
}
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
// node that physically hosts each set: this panel's own clients under its own
// GUID, plus every node in the tree under its GUID (#4983). Replaces the old
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
out := sharedOnlineStore.GetMergedNodeTrees()
if local := sharedOnlineStore.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
}
return out
}
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
// the grace window, keyed by the panelGuid of the node that physically hosts
// each inbound. A GUID missing from the map means "don't gate" for that node's
// inbounds (old-build node or no active-inbound signal).
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
out := sharedOnlineStore.GetMergedActiveInboundTrees()
active := sharedOnlineStore.GetLocalActiveInbounds()
if len(active) == 0 {
return out
}
guid := s.panelGuid()
if guid == "" {
return out
}
out[guid] = mergeEmails(out[guid], active)
return out
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
sharedOnlineStore.SetNodeOnlineTree(nodeID, tree)
}
// SetAgentOnlineClients records the online clients an agent node reports in
// its traffic payload. An agent has no GUID tree of its own, so its clients
// are keyed under the node's effective attribution key (effectiveNodeKey —
// the same key setRemoteTrafficLocked computes for panel nodes), which is
// what the inbounds page looks up via the inbound's originNodeGuid /
// node:{id} fallback. Storing them under a literal "agent" key (the old
// controller behaviour) left agent inbounds permanently dark on that page.
// An empty list is the agent's disconnect signal and clears the node's
// whole online contribution.
func (s *InboundService) SetAgentOnlineClients(nodeID int, onlines []string) {
if len(onlines) == 0 {
s.ClearNodeOnlineClients(nodeID)
return
}
key := synthNodeGuid(nodeID)
var nodeRow model.Node
if err := database.GetDB().Select("guid").Where("id = ?", nodeID).First(&nodeRow).Error; err == nil {
nodeRow.Id = nodeID
if k := effectiveNodeKey(&nodeRow); k != "" {
key = k
}
}
s.SetNodeOnlineTree(nodeID, map[string][]string{key: onlines})
}
func (s *InboundService) SetNodeActiveInboundTree(nodeID int, tree map[string][]string) {
sharedOnlineStore.SetNodeActiveInboundTree(nodeID, tree)
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
sharedOnlineStore.ClearNodeOnlineClients(nodeID)
}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
func (s *InboundService) panelGuid() string {
guid, _ := (&SettingService{}).GetPanelGuid()
return guid
}
// synthNodeGuid is the stable per-node fallback identity for a directly-attached
// node whose panel hasn't reported a panelGuid yet (old build). Node ids are
// master-local, so this only composes for direct nodes — exactly the pre-#4983
// flat-topology case where an old-build node appears.
func synthNodeGuid(nodeID int) string {
return fmt.Sprintf("node:%d", nodeID)
}
// mergeEmails returns the deduped union of two email slices.
func mergeEmails(a, b []string) []string {
if len(a) == 0 {
return b
}
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, e := range a {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
for _, e := range b {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
return out
}
func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
db := database.GetDB()
var rows []xray.ClientTraffic
err := db.Model(&xray.ClientTraffic{}).Select("email, last_online").Find(&rows).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
result := make(map[string]int64, len(rows))
for _, r := range rows {
result[r.Email] = r.LastOnline
}
return result, nil
}
// RefreshLocalOnlineClients folds the emails and inbound tags active on this
// panel's own inbounds this poll into the local online/active sets, applying
// the grace window and pruning stale entries. Pass nil to only prune. See
// onlineStore for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
sharedOnlineStore.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()
// Step 1: Get ClientTraffic records for emails in the input list.
// Chunked to stay under SQLite's bind-variable limit on huge inputs.
uniqEmails := uniqueNonEmptyStrings(emails)
clients := make([]xray.ClientTraffic, 0, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []xray.ClientTraffic
if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, err
}
clients = append(clients, page...)
}
// Step 2: Sort clients by (Up + Down) descending
sort.Slice(clients, func(i, j int) bool {
return (clients[i].Up + clients[i].Down) > (clients[j].Up + clients[j].Down)
})
// Step 3: Extract sorted valid emails and track found ones
validEmails := make([]string, 0, len(clients))
found := make(map[string]bool)
for _, client := range clients {
validEmails = append(validEmails, client.Email)
found[client.Email] = true
}
// Step 4: Identify emails that were not found in the database
extraEmails := make([]string, 0)
for _, email := range emails {
if !found[email] {
extraEmails = append(extraEmails, email)
}
}
return validEmails, extraEmails, nil
}