node_traffic_sync_job.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. package job
  2. import (
  3. "context"
  4. "strings"
  5. "sync"
  6. "time"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  9. "github.com/mhsanaei/3x-ui/v3/internal/util/common"
  10. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  11. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  12. "github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
  13. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  14. )
  15. const (
  16. // The heartbeat's bound: at 8, 300 nodes 80ms away took 25-30s per 5s tick on SQLite
  17. // and 6-9s at 32; neither SQLite nor Postgres raised lock or pool errors.
  18. nodeTrafficSyncConcurrency = 32
  19. nodeTrafficSyncRequestTimeout = 4 * time.Second
  20. nodeReconcileTimeout = 30 * time.Second
  21. nodeClientIpSyncInterval = 10 * time.Second
  22. nodeClientIpSyncTimeout = 6 * time.Second
  23. nodeGlobalPushInterval = 30 * time.Second
  24. // nodeInboundSpeedWindowMs is the poll window node-inbound speed deltas are
  25. // normalized to; it MUST match the dashboard's TRAFFIC_POLL_INTERVAL_S (5s),
  26. // the fixed divisor the frontend applies to turn a delta into a rate.
  27. nodeInboundSpeedWindowMs int64 = 5000
  28. )
  29. // inboundSample is a node inbound's last-seen cumulative up/down and the time
  30. // (unix millis) its counter last changed, used to derive a normalized speed.
  31. type inboundSample struct {
  32. up, down, at int64
  33. }
  34. type NodeTrafficSyncJob struct {
  35. nodeService service.NodeService
  36. inboundService service.InboundService
  37. settingService service.SettingService
  38. xrayService service.XrayService
  39. running sync.Mutex
  40. structural atomicBool
  41. ipSyncMu sync.Mutex
  42. lastIpSync int64
  43. globalPushMu sync.Mutex
  44. lastGlobalPush int64
  45. // noGuidIpEndpoint tracks nodes (by id) whose client-IP attribution endpoint
  46. // returned 404, so an old-build node is noted once instead of every cycle.
  47. noGuidIpEndpoint sync.Map
  48. // prevInboundTotals holds the previous poll's cumulative up/down (and the time
  49. // the counter last changed) per node inbound tag, so the next poll can derive
  50. // a per-inbound speed delta — node inbounds have no local Xray poll. Touched
  51. // only from Run (serialized).
  52. prevInboundTotals map[string]inboundSample
  53. }
  54. type atomicBool struct {
  55. mu sync.Mutex
  56. v bool
  57. }
  58. func (a *atomicBool) set() {
  59. a.mu.Lock()
  60. a.v = true
  61. a.mu.Unlock()
  62. }
  63. func (a *atomicBool) takeAndReset() bool {
  64. a.mu.Lock()
  65. v := a.v
  66. a.v = false
  67. a.mu.Unlock()
  68. return v
  69. }
  70. func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
  71. return &NodeTrafficSyncJob{}
  72. }
  73. func (j *NodeTrafficSyncJob) Run() {
  74. if !j.running.TryLock() {
  75. return
  76. }
  77. defer j.running.Unlock()
  78. mgr := runtime.GetManager()
  79. if mgr == nil {
  80. return
  81. }
  82. nodes, err := j.nodeService.GetAll()
  83. if err != nil {
  84. logger.Warning("node traffic sync: load nodes failed:", err)
  85. return
  86. }
  87. j.inboundService.RetainSyncedNodeOnlineClients(nodes)
  88. if len(nodes) == 0 {
  89. return
  90. }
  91. // Decide once per tick whether this run also syncs client IPs, and stamp the
  92. // clock before the loop so two back-to-back 5s ticks can't both qualify.
  93. doIpSync := false
  94. j.ipSyncMu.Lock()
  95. if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
  96. doIpSync = true
  97. j.lastIpSync = now
  98. }
  99. j.ipSyncMu.Unlock()
  100. sem := make(chan struct{}, nodeTrafficSyncConcurrency)
  101. var wg sync.WaitGroup
  102. var activeMu sync.Mutex
  103. var activeEmails []string
  104. for _, n := range nodes {
  105. if !n.Enable || n.Status != "online" {
  106. continue
  107. }
  108. wg.Add(1)
  109. sem <- struct{}{}
  110. n := n
  111. common.GoRecover("node-traffic-sync:"+n.Name, func() {
  112. defer wg.Done()
  113. defer func() { <-sem }()
  114. if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 {
  115. activeMu.Lock()
  116. activeEmails = append(activeEmails, emails...)
  117. activeMu.Unlock()
  118. }
  119. })
  120. }
  121. wg.Wait()
  122. _, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
  123. if err != nil {
  124. logger.Warning("node traffic sync: depletion check failed:", err)
  125. }
  126. if clientsDisabled {
  127. if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
  128. if err := j.xrayService.RestartXray(true); err != nil {
  129. logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
  130. j.xrayService.SetToNeedRestart()
  131. }
  132. } else if settingErr != nil {
  133. logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
  134. }
  135. j.structural.set()
  136. }
  137. j.maybePushGlobals(mgr, nodes)
  138. // Prune stale local-online entries (no local active emails or inbound tags
  139. // to add here — only the local xray poll feeds those) so a stopped local
  140. // xray's clients and inbounds still age out between traffic polls.
  141. j.inboundService.RefreshLocalOnlineClients(nil, nil)
  142. // Derive per-node-inbound speed every tick (keeps the baseline fresh even
  143. // with no dashboard open); only broadcast it when someone is watching.
  144. inboundSpeed := j.nodeInboundSpeed()
  145. if !websocket.HasClients() {
  146. return
  147. }
  148. // Same snapshot-vs-delta split as the local traffic job: above the
  149. // threshold a full snapshot would be dropped by the hub's payload cap, so
  150. // send only the rows for clients online on the synced nodes this tick.
  151. snapshot := true
  152. if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil {
  153. logger.Warning("node traffic sync: count client traffics failed:", countErr)
  154. } else if total > clientStatsSnapshotMaxClients {
  155. snapshot = false
  156. }
  157. var stats []*xray.ClientTraffic
  158. var statsErr error
  159. if snapshot {
  160. stats, statsErr = j.inboundService.GetAllClientTraffics()
  161. } else {
  162. stats, statsErr = j.inboundService.GetActiveClientTraffics(activeEmails)
  163. }
  164. if statsErr != nil {
  165. logger.Warning("node traffic sync: get client traffics for websocket failed:", statsErr)
  166. }
  167. var lastOnline map[string]int64
  168. if snapshot {
  169. var loErr error
  170. if lastOnline, loErr = j.inboundService.GetClientsLastOnline(); loErr != nil {
  171. logger.Warning("node traffic sync: get last-online failed:", loErr)
  172. }
  173. } else {
  174. lastOnline = make(map[string]int64, len(stats))
  175. for _, ct := range stats {
  176. if ct != nil {
  177. lastOnline[ct.Email] = ct.LastOnline
  178. }
  179. }
  180. }
  181. if lastOnline == nil {
  182. lastOnline = map[string]int64{}
  183. }
  184. online := j.inboundService.GetOnlineClients()
  185. if online == nil {
  186. online = []string{}
  187. }
  188. trafficPayload := map[string]any{
  189. "onlineClients": online,
  190. "onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
  191. "activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
  192. "lastOnlineMap": lastOnline,
  193. }
  194. // Always send the key so the dashboard clears node inbounds that went idle
  195. // this tick. A nil result (query error) marshals to null and is skipped
  196. // client-side, leaving the last shown value untouched; an empty (non-nil)
  197. // slice marshals to [] and clears stale speeds.
  198. trafficPayload["nodeTraffics"] = inboundSpeed
  199. websocket.BroadcastTraffic(trafficPayload)
  200. clientStats := map[string]any{"snapshot": snapshot}
  201. if len(stats) > 0 {
  202. clientStats["clients"] = stats
  203. }
  204. if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
  205. logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
  206. } else if len(summary) > 0 {
  207. clientStats["inbounds"] = summary
  208. }
  209. if len(clientStats) > 1 {
  210. websocket.BroadcastClientStats(clientStats)
  211. }
  212. if j.structural.takeAndReset() {
  213. websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
  214. websocket.BroadcastInvalidate(websocket.MessageTypeClients)
  215. }
  216. }
  217. // nodeInboundSpeed derives a per-node-inbound speed delta by diffing the current
  218. // cumulative up/down against the previous poll's, keyed by the central tag the
  219. // dashboard matches. The node's counter keeps climbing while the master can't
  220. // reach it, so the first delta after a gap (node outage, skipped poll, slow
  221. // node) spans more than one poll window; it is normalized to the fixed
  222. // nodeInboundSpeedWindowMs using the real elapsed time so the dashboard's fixed
  223. // divisor yields the true average rate over the gap instead of an impossible
  224. // one-tick spike. The change timestamp only advances when the value actually
  225. // moves, so an idle stretch is averaged correctly when traffic resumes. A reset
  226. // rebaselines to the lower value; a first-seen tag yields no delta until the
  227. // next poll.
  228. func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic {
  229. totals, err := j.inboundService.GetNodeInboundTrafficTotals()
  230. if err != nil {
  231. return nil
  232. }
  233. now := time.Now().UnixMilli()
  234. deltas := make([]*xray.Traffic, 0, len(totals))
  235. next := make(map[string]inboundSample, len(totals))
  236. for tag, cur := range totals {
  237. prev, ok := j.prevInboundTotals[tag]
  238. if !ok {
  239. next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
  240. continue
  241. }
  242. dUp := cur[0] - prev.up
  243. dDown := cur[1] - prev.down
  244. if dUp <= 0 && dDown <= 0 {
  245. // No movement, or a counter reset: hold the change timestamp so a
  246. // later jump is averaged over the real elapsed window, not shown as a
  247. // spike. Adopt the lower value on a reset.
  248. if cur[0] < prev.up || cur[1] < prev.down {
  249. next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
  250. } else {
  251. next[tag] = prev
  252. }
  253. continue
  254. }
  255. if dUp < 0 {
  256. dUp = 0
  257. }
  258. if dDown < 0 {
  259. dDown = 0
  260. }
  261. elapsed := max(now-prev.at, nodeInboundSpeedWindowMs)
  262. up := dUp * nodeInboundSpeedWindowMs / elapsed
  263. down := dDown * nodeInboundSpeedWindowMs / elapsed
  264. if up > 0 || down > 0 {
  265. deltas = append(deltas, &xray.Traffic{Tag: tag, IsInbound: true, Up: up, Down: down})
  266. }
  267. next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
  268. }
  269. j.prevInboundTotals = next
  270. return deltas
  271. }
  272. // maybePushGlobals broadcasts this panel's aggregated per-client usage to its
  273. // online nodes so each node can display the client's cross-panel total and
  274. // enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped
  275. // per node to the clients that node actually hosts, and throttled — the
  276. // aggregates only need to reach nodes on a human timescale, not every poll.
  277. func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*model.Node) {
  278. j.globalPushMu.Lock()
  279. now := time.Now().Unix()
  280. if now-j.lastGlobalPush < int64(nodeGlobalPushInterval/time.Second) {
  281. j.globalPushMu.Unlock()
  282. return
  283. }
  284. j.lastGlobalPush = now
  285. j.globalPushMu.Unlock()
  286. masterGuid, err := j.settingService.GetPanelGuid()
  287. if err != nil || masterGuid == "" {
  288. return
  289. }
  290. sem := make(chan struct{}, nodeTrafficSyncConcurrency)
  291. var wg sync.WaitGroup
  292. for _, n := range nodes {
  293. if !n.Enable || n.Status != "online" {
  294. continue
  295. }
  296. remote, err := mgr.RemoteFor(n)
  297. if err != nil {
  298. continue
  299. }
  300. traffics, err := j.inboundService.GetNodeClientTraffics(n.Id)
  301. if err != nil {
  302. logger.Warningf("node traffic sync: load globals for %s failed: %v", n.Name, err)
  303. continue
  304. }
  305. if len(traffics) == 0 {
  306. continue
  307. }
  308. wg.Add(1)
  309. sem <- struct{}{}
  310. n, remote, traffics := n, remote, traffics
  311. common.GoRecover("node-global-push:"+n.Name, func() {
  312. defer wg.Done()
  313. defer func() { <-sem }()
  314. ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
  315. defer cancel()
  316. if err := remote.PushGlobalClientTraffics(ctx, masterGuid, traffics); err != nil {
  317. // An old-build node without the endpoint answers 404 — not worth a
  318. // warning every cycle.
  319. if strings.Contains(err.Error(), "HTTP 404") {
  320. logger.Debugf("node traffic sync: node %s has no global-traffic endpoint (old build)", n.Name)
  321. } else {
  322. logger.Warningf("node traffic sync: push globals to %s failed: %v", n.Name, err)
  323. }
  324. }
  325. })
  326. }
  327. wg.Wait()
  328. }
  329. // syncOne pulls one node's traffic snapshot and merges it. It returns the
  330. // emails online on that node this tick, feeding the delta broadcast above the
  331. // snapshot threshold; nil on any failure path.
  332. func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string {
  333. rt, err := mgr.RemoteFor(n)
  334. if err != nil {
  335. logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err)
  336. return nil
  337. }
  338. justPushed := false
  339. if n.ConfigDirty {
  340. reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
  341. reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n)
  342. reconcileCancel()
  343. if reconcileErr != nil {
  344. // The dirty flag stays set so reconcile retries next tick, but traffic
  345. // accounting must keep flowing: one rejected inbound used to starve the
  346. // whole node's traffic/online sync forever (#5685).
  347. logger.Warningf("node traffic sync: reconcile for %s failed, continuing with traffic pull: %v", n.Name, reconcileErr)
  348. } else {
  349. if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
  350. logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr)
  351. }
  352. j.structural.set()
  353. // The snapshot below may still predate the push we just made, so its
  354. // lagging lifecycle values must not merge back this tick (#6228).
  355. justPushed = true
  356. }
  357. }
  358. ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
  359. defer cancel()
  360. snap, err := rt.FetchTrafficSnapshot(ctx)
  361. if err != nil {
  362. logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err)
  363. j.inboundService.ClearNodeOnlineClients(n.Id)
  364. return nil
  365. }
  366. snap.ManagedAliases = rt.AdoptedInboundAliases()
  367. syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases)
  368. service.FilterNodeSnapshot(n, snap)
  369. _, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
  370. if !dirty {
  371. if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
  372. logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
  373. } else if pending {
  374. hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
  375. groups, hgErr := rt.FetchHostGroups(hostCtx)
  376. hostCancel()
  377. if hgErr != nil {
  378. logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
  379. } else {
  380. snap.HostGroups = groups
  381. }
  382. }
  383. }
  384. changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty, justPushed)
  385. if err != nil {
  386. logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
  387. return nil
  388. }
  389. if changed {
  390. j.structural.set()
  391. }
  392. if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt {
  393. if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
  394. logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
  395. }
  396. }
  397. active := make([]string, 0, len(snap.OnlineEmails))
  398. active = append(active, snap.OnlineEmails...)
  399. for _, emails := range snap.OnlineTree {
  400. active = append(active, emails...)
  401. }
  402. if !doIpSync {
  403. return active
  404. }
  405. ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout)
  406. defer ipCancel()
  407. nodeIps, err := rt.FetchAllClientIps(ipCtx)
  408. if err == nil && len(nodeIps) > 0 {
  409. if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
  410. logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err)
  411. }
  412. } else if err != nil {
  413. logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err)
  414. }
  415. masterIps, err := j.inboundService.GetNodeInboundClientIps(n.Id)
  416. if err != nil {
  417. logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err)
  418. return active
  419. }
  420. if len(masterIps) > 0 {
  421. if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil {
  422. logger.Warningf("node traffic sync: push client ips to %s failed: %v", n.Name, err)
  423. }
  424. }
  425. // Per-node IP attribution: pull the node's guid-keyed subtree (its own
  426. // observations plus any descendants) so the master can tell which node each
  427. // IP is on. Old nodes without the endpoint return HTTP 404 every cycle — note
  428. // it once per node (re-armed on recovery) instead of flooding the log.
  429. if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil {
  430. if strings.Contains(err.Error(), "HTTP 404") {
  431. if _, seen := j.noGuidIpEndpoint.LoadOrStore(n.Id, true); !seen {
  432. logger.Debugf("node traffic sync: node %s has no client-IP attribution endpoint (old build)", n.Name)
  433. }
  434. } else {
  435. logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
  436. }
  437. } else {
  438. j.noGuidIpEndpoint.Delete(n.Id)
  439. if len(guidTrees) > 0 {
  440. if err := j.inboundService.MergeClientIpsByGuid(n, guidTrees); err != nil {
  441. logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
  442. }
  443. }
  444. }
  445. return active
  446. }
  447. // Whether this sync can perform the "first clean adoption" that
  448. // InboundsAdoptedAt records (#6283).
  449. func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool {
  450. if n == nil || n.InboundSyncMode != "selected" {
  451. return true
  452. }
  453. return len(n.InboundTags) > 0 || len(adoptedAliases) > 0
  454. }