check_client_ip_job.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. package job
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "log"
  7. "os"
  8. "os/exec"
  9. "runtime"
  10. "sort"
  11. "strings"
  12. "time"
  13. "github.com/mhsanaei/3x-ui/v3/internal/database"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  15. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  16. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  17. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  18. "gorm.io/gorm"
  19. )
  20. // IPWithTimestamp tracks an IP address with its last seen timestamp
  21. type IPWithTimestamp struct {
  22. IP string `json:"ip"`
  23. Timestamp int64 `json:"timestamp"`
  24. }
  25. // CheckClientIpJob monitors client IP addresses and manages IP blocking based
  26. // on configured limits. The per-client IPs come from the core's online-stats
  27. // API; no access log is involved. On a core too old to expose that API the job
  28. // simply skips the run (the bundled core always supports it).
  29. type CheckClientIpJob struct {
  30. disAllowedIps []string
  31. bannedSeen map[string]int64
  32. xrayService service.XrayService
  33. allowlist ipLimitAllowlist
  34. lastIpPrune int64
  35. }
  36. var job *CheckClientIpJob
  37. const defaultXrayAPIPort = 62789
  38. const ipStaleAfterSeconds = int64(30 * 60)
  39. // pruneStaleIpRows cadence; the scan itself cannot prune offline clients' rows.
  40. const ipPruneIntervalSeconds = int64(5 * 60)
  41. // NewCheckClientIpJob creates a new client IP monitoring job instance.
  42. func NewCheckClientIpJob() *CheckClientIpJob {
  43. job = new(CheckClientIpJob)
  44. return job
  45. }
  46. func (j *CheckClientIpJob) Run() {
  47. j.pruneStaleIpRows()
  48. observed, apiMode := j.collectFromOnlineAPI()
  49. if !apiMode {
  50. // xray is down or predates the online-stats API. There is no access-log
  51. // fallback anymore, so there is nothing to do this run.
  52. logger.Debug("[LimitIP] online-stats API unavailable this run; skipping")
  53. return
  54. }
  55. if !isFail2BanEnabled() {
  56. return
  57. }
  58. hasLimit := j.hasLimitIp()
  59. f2bInstalled := false
  60. if hasLimit {
  61. f2bInstalled = j.checkFail2BanInstalled()
  62. }
  63. // Read only when the limit is actually applied: this runs every 10s and
  64. // most panels carry no IP limit at all.
  65. enforce := j.resolveEnforce(hasLimit, f2bInstalled)
  66. if enforce {
  67. j.allowlist = j.loadAllowlist()
  68. }
  69. j.processObserved(observed, enforce, true)
  70. }
  71. // resolveEnforce decides whether limits can actually be enforced this run.
  72. // Without fail2ban on a platform that needs it the limit can't be applied, so
  73. // enforcement is skipped (the panel resets these limits to 0 on upgrade and
  74. // disables the field, so this is normally a no-op).
  75. func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
  76. if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
  77. return false
  78. }
  79. return hasLimit
  80. }
  81. // collectFromOnlineAPI builds per-email IP observations (email -> ip ->
  82. // last-seen unix seconds) from the core's online-stats API. ok=false means the
  83. // API is unavailable — xray not running, an older core, or a transient gRPC
  84. // failure — and the caller skips the run (there is no access-log fallback).
  85. func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
  86. onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
  87. if err != nil {
  88. logger.Debug("[LimitIP] online-stats API unavailable this run:", err)
  89. return nil, false
  90. }
  91. if !ok {
  92. return nil, false
  93. }
  94. now := time.Now().Unix()
  95. observed := make(map[string]map[string]int64, len(onlineUsers))
  96. for _, user := range onlineUsers {
  97. for _, entry := range user.IPs {
  98. // No localhost guard needed here: the core's OnlineMap.AddIP drops
  99. // 127.0.0.1/[::1] itself, so they never reach this list.
  100. ts := entry.LastSeen
  101. if ts <= 0 {
  102. ts = now
  103. }
  104. if _, exists := observed[user.Email]; !exists {
  105. observed[user.Email] = make(map[string]int64)
  106. }
  107. if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing {
  108. observed[user.Email][entry.IP] = ts
  109. }
  110. }
  111. }
  112. return observed, true
  113. }
  114. // hasLimitIp reports whether any client carries an IP limit. It probes the
  115. // normalized clients table (limit_ip is synced there by SyncInbound and the
  116. // legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that
  117. // loaded and JSON-parsed every inbound's settings blob on each 10s run.
  118. func (j *CheckClientIpJob) hasLimitIp() bool {
  119. db := database.GetDB()
  120. var probe int64
  121. err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error
  122. return err == nil && probe > 0
  123. }
  124. // loadAllowlist reads the operator's trusted addresses once per scan; a bad
  125. // read leaves the list empty, which enforces the limit as before rather than
  126. // silently exempting everyone.
  127. func (j *CheckClientIpJob) loadAllowlist() ipLimitAllowlist {
  128. raw, err := (&service.SettingService{}).GetIpLimitAllowlist()
  129. if err != nil {
  130. logger.Warning("[LimitIP] could not read the allowlist, enforcing without it:", err)
  131. return ipLimitAllowlist{}
  132. }
  133. return parseIpLimitAllowlist(raw)
  134. }
  135. const ipScanChunk = 400
  136. func chunkEmails(s []string, size int) [][]string {
  137. if len(s) == 0 {
  138. return nil
  139. }
  140. chunks := make([][]string, 0, (len(s)+size-1)/size)
  141. for size < len(s) {
  142. s, chunks = s[size:], append(chunks, s[:size])
  143. }
  144. return append(chunks, s)
  145. }
  146. // loadClientLimits maps each observed email to its clients.limit_ip in a few
  147. // chunked queries, replacing the per-email settings-JSON parse that previously
  148. // resolved the limit.
  149. func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int {
  150. db := database.GetDB()
  151. out := make(map[string]int, len(emails))
  152. for _, batch := range chunkEmails(emails, ipScanChunk) {
  153. var rows []struct {
  154. Email string
  155. LimitIp int
  156. }
  157. if err := db.Model(&model.ClientRecord{}).
  158. Select("email, limit_ip").
  159. Where("email IN ?", batch).
  160. Scan(&rows).Error; err != nil {
  161. j.checkError(err)
  162. continue
  163. }
  164. for _, r := range rows {
  165. out[r.Email] = r.LimitIp
  166. }
  167. }
  168. return out
  169. }
  170. // loadInboundsByEmails resolves each email's owning inbound through the
  171. // clients/client_inbounds relation in chunked queries. Like the old per-email
  172. // First() it keeps the lowest inbound id when a client spans several inbounds.
  173. func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound {
  174. db := database.GetDB()
  175. minInboundByEmail := make(map[string]int, len(emails))
  176. for _, batch := range chunkEmails(emails, ipScanChunk) {
  177. var pairs []struct {
  178. Email string
  179. InboundId int
  180. }
  181. if err := db.Table("client_inbounds").
  182. Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id").
  183. Joins("JOIN clients ON clients.id = client_inbounds.client_id").
  184. Where("clients.email IN ?", batch).
  185. Scan(&pairs).Error; err != nil {
  186. j.checkError(err)
  187. return nil
  188. }
  189. for _, p := range pairs {
  190. if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur {
  191. minInboundByEmail[p.Email] = p.InboundId
  192. }
  193. }
  194. }
  195. if len(minInboundByEmail) == 0 {
  196. return nil
  197. }
  198. idSet := make(map[int]struct{}, len(minInboundByEmail))
  199. ids := make([]int, 0, len(minInboundByEmail))
  200. for _, id := range minInboundByEmail {
  201. if _, seen := idSet[id]; !seen {
  202. idSet[id] = struct{}{}
  203. ids = append(ids, id)
  204. }
  205. }
  206. sort.Ints(ids)
  207. inboundsById := make(map[int]*model.Inbound, len(ids))
  208. for lo := 0; lo < len(ids); lo += ipScanChunk {
  209. hi := min(lo+ipScanChunk, len(ids))
  210. var page []*model.Inbound
  211. if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil {
  212. j.checkError(err)
  213. return nil
  214. }
  215. for _, ib := range page {
  216. inboundsById[ib.Id] = ib
  217. }
  218. }
  219. out := make(map[string]*model.Inbound, len(minInboundByEmail))
  220. for email, id := range minInboundByEmail {
  221. if ib, ok := inboundsById[id]; ok {
  222. out[email] = ib
  223. }
  224. }
  225. return out
  226. }
  227. func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps {
  228. db := database.GetDB()
  229. out := make(map[string]*model.InboundClientIps, len(emails))
  230. for _, batch := range chunkEmails(emails, ipScanChunk) {
  231. var rows []model.InboundClientIps
  232. if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil {
  233. j.checkError(err)
  234. continue
  235. }
  236. for i := range rows {
  237. out[rows[i].ClientEmail] = &rows[i]
  238. }
  239. }
  240. return out
  241. }
  242. // processObserved runs collection + enforcement for one scan's observations
  243. // (email -> ip -> last-seen unix seconds). observedAreLive marks the
  244. // observations as live connections, which bypass the stale cutoff: a connection
  245. // that opened hours ago is still live even though its timestamp is old. The
  246. // online-stats API always reports live connections, so the job passes true.
  247. // Lookups are batched up front and all inbound_client_ips writes share one
  248. // transaction, so a scan costs a handful of queries and one fsync instead of
  249. // several per observed email.
  250. func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
  251. shouldCleanLog := false
  252. now := time.Now().Unix()
  253. emails := make([]string, 0, len(observed))
  254. for email := range observed {
  255. emails = append(emails, email)
  256. }
  257. sort.Strings(emails)
  258. limitByEmail := j.loadClientLimits(emails)
  259. inboundByEmail := j.loadInboundsByEmails(emails)
  260. ipRowByEmail := j.loadClientIpRows(emails)
  261. // attribution accumulates this scan's local observations per email so they can
  262. // be recorded under this panel's own guid for cross-node IP attribution.
  263. attribution := make(map[string][]model.ClientIpEntry, len(observed))
  264. type pendingDisconnect struct {
  265. inbound *model.Inbound
  266. email string
  267. }
  268. var disconnects []pendingDisconnect
  269. db := database.GetDB()
  270. tx := db.Begin()
  271. if tx.Error != nil {
  272. j.checkError(tx.Error)
  273. return false
  274. }
  275. committed := false
  276. defer func() {
  277. if !committed {
  278. tx.Rollback()
  279. }
  280. }()
  281. for _, email := range emails {
  282. ipTimestamps := observed[email]
  283. // The observations can still reference a client that was just renamed
  284. // or deleted; its email no longer matches any inbound. Skip it (and
  285. // drop any orphaned tracking row) instead of recreating a row and
  286. // logging an ERROR every run (#4963). The batch map resolves through
  287. // the clients relation; the per-email fallback keeps its settings LIKE
  288. // net for clients not yet present there.
  289. inbound, ok := inboundByEmail[email]
  290. if !ok {
  291. var err error
  292. inbound, err = j.getInboundByEmail(email)
  293. if err != nil {
  294. if errors.Is(err, gorm.ErrRecordNotFound) {
  295. logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
  296. j.delInboundClientIps(tx, email)
  297. } else {
  298. j.checkError(err)
  299. }
  300. continue
  301. }
  302. }
  303. // Convert to IPWithTimestamp slice
  304. ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
  305. attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
  306. for ip, timestamp := range ipTimestamps {
  307. ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
  308. // Live API observations may carry an old lastSeen (connection start),
  309. // so stamp attribution with now; otherwise the stale cutoff would evict
  310. // an IP that is connected right now.
  311. attrTs := timestamp
  312. if observedAreLive {
  313. attrTs = now
  314. }
  315. attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs})
  316. }
  317. if len(attrEntries) > 0 {
  318. attribution[email] = attrEntries
  319. }
  320. clientIpsRecord, ok := ipRowByEmail[email]
  321. if !ok {
  322. jsonIps, err := json.Marshal(ipsWithTime)
  323. if err != nil {
  324. j.checkError(err)
  325. continue
  326. }
  327. if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
  328. j.checkError(err)
  329. }
  330. continue
  331. }
  332. cleaned, banned := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
  333. shouldCleanLog = cleaned || shouldCleanLog
  334. if banned {
  335. disconnects = append(disconnects, pendingDisconnect{inbound: inbound, email: email})
  336. }
  337. }
  338. if err := tx.Commit().Error; err != nil {
  339. j.checkError(err)
  340. return shouldCleanLog
  341. }
  342. committed = true
  343. // Xray disconnects run after the commit so their network round-trips never
  344. // extend the scan's write transaction (node syncs upsert the same table).
  345. clientsCache := make(map[int][]model.Client)
  346. for _, d := range disconnects {
  347. clients, cached := clientsCache[d.inbound.Id]
  348. if !cached {
  349. clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings)
  350. clientsCache[d.inbound.Id] = clients
  351. }
  352. j.disconnectClientTemporarily(d.inbound, d.email, clients)
  353. }
  354. j.recordLocalAttribution(attribution)
  355. return shouldCleanLog
  356. }
  357. // recordLocalAttribution stores this scan's local observations under this panel's
  358. // own guid so a parent panel can attribute each IP to the node it is on.
  359. // Best-effort: attribution is advisory and must never block IP-limit enforcement.
  360. func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) {
  361. if len(attribution) == 0 {
  362. return
  363. }
  364. guid, err := (&service.SettingService{}).GetPanelGuid()
  365. if err != nil || guid == "" {
  366. return
  367. }
  368. if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil {
  369. logger.Debug("[LimitIP] record local ip attribution failed:", err)
  370. }
  371. }
  372. // mergeClientIps folds this scan's observations into the persisted set,
  373. // dropping entries older than staleCutoff. newAlwaysLive exempts the new
  374. // entries from that cutoff: an API-observed IP is a live connection by
  375. // definition, even when its lastSeen (set at dispatch time) is hours old.
  376. func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
  377. ipMap := make(map[string]int64, len(old)+len(new))
  378. for _, ipTime := range old {
  379. if ipTime.Timestamp < staleCutoff {
  380. continue
  381. }
  382. ipMap[ipTime.IP] = ipTime.Timestamp
  383. }
  384. for _, ipTime := range new {
  385. if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
  386. continue
  387. }
  388. if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
  389. ipMap[ipTime.IP] = ipTime.Timestamp
  390. }
  391. }
  392. return ipMap
  393. }
  394. // selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
  395. // into the newest `limit` entries to keep and the older remainder to ban.
  396. func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
  397. if limit <= 0 || len(live) <= limit {
  398. return live, nil
  399. }
  400. cutoff := len(live) - limit
  401. return live[cutoff:], live[:cutoff]
  402. }
  403. func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
  404. live = make([]IPWithTimestamp, 0, len(observedThisScan))
  405. historical = make([]IPWithTimestamp, 0, len(ipMap))
  406. now := time.Now().Unix()
  407. for ip, ts := range ipMap {
  408. entry := IPWithTimestamp{IP: ip, Timestamp: ts}
  409. // Consider an IP "live" if it was seen locally in this scan, OR if its
  410. // timestamp from the synced database is very recent (e.g. within 2 minutes).
  411. // This ensures cluster-wide limits work even if the IP was seen on another node.
  412. if observedThisScan[ip] || now-ts < 120 {
  413. live = append(live, entry)
  414. } else {
  415. historical = append(historical, entry)
  416. }
  417. }
  418. sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
  419. sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
  420. return live, historical
  421. }
  422. func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
  423. if !isFail2BanEnabled() {
  424. return false
  425. }
  426. cmd := "fail2ban-client"
  427. args := []string{"-h"}
  428. err := exec.CommandContext(context.Background(), cmd, args...).Run()
  429. return err == nil
  430. }
  431. func isFail2BanEnabled() bool {
  432. value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
  433. return !ok || value == "true"
  434. }
  435. func (j *CheckClientIpJob) checkError(e error) {
  436. if e != nil {
  437. logger.Warning("client ip job err:", e)
  438. }
  439. }
  440. // delInboundClientIps drops the inbound_client_ips tracking row for an email
  441. // that no longer maps to any inbound (a renamed or deleted client), so stale
  442. // access-log entries don't keep a ghost row alive (#4963).
  443. func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
  444. if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
  445. j.checkError(err)
  446. }
  447. }
  448. // updateInboundClientIps merges one email's observed IPs into its tracking row
  449. // and applies the IP limit. limitIp comes from the caller (the clients table);
  450. // writes go through the caller's transaction. banned=true asks the caller to
  451. // disconnect the client after the transaction commits.
  452. func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (shouldCleanLog, banned bool) {
  453. if inbound.Settings == "" {
  454. logger.Debug("wrong data:", inbound)
  455. return false, false
  456. }
  457. if !enforce || limitIp <= 0 || !inbound.Enable {
  458. // Nothing to enforce (collection-only run, no limit on the clients row,
  459. // or inbound disabled): record the observed IPs for the panel and return.
  460. jsonIps, _ := json.Marshal(newIpsWithTime)
  461. inboundClientIps.Ips = string(jsonIps)
  462. if err := tx.Save(inboundClientIps).Error; err != nil {
  463. logger.Error("failed to save inboundClientIps:", err)
  464. }
  465. return false, false
  466. }
  467. // Parse old IPs from database
  468. var oldIpsWithTime []IPWithTimestamp
  469. if inboundClientIps.Ips != "" {
  470. _ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
  471. }
  472. ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
  473. // only ips seen in this scan count toward the limit. see
  474. // partitionLiveIps.
  475. observedThisScan := make(map[string]bool, len(newIpsWithTime))
  476. for _, ipTime := range newIpsWithTime {
  477. observedThisScan[ipTime.IP] = true
  478. }
  479. liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
  480. j.disAllowedIps = []string{}
  481. // historical db-only ips are excluded from this count on purpose.
  482. limitedIps, allowedIps := j.allowlist.split(liveIps)
  483. keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
  484. // Allowlisted addresses stay connected and out of the count: charging them
  485. // against the limit would still cut the shared network the entry protects.
  486. keptLive = append(keptLive, allowedIps...)
  487. actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
  488. if len(actionable) > 0 {
  489. shouldCleanLog = true
  490. banned = true
  491. logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
  492. if err != nil {
  493. logger.Errorf("failed to open IP limit log file: %s", err)
  494. return false, false
  495. }
  496. defer logIpFile.Close()
  497. ipLogger := log.New(logIpFile, "", log.LstdFlags)
  498. // log format is load-bearing: x-ui.sh create_iplimit_jails builds
  499. // filter.d/3x-ipl.conf with
  500. // failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
  501. // don't change the wording.
  502. for _, ipTime := range actionable {
  503. j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
  504. ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
  505. }
  506. }
  507. // keep kept-live + historical in the blob so the panel keeps showing
  508. // recently seen ips. banned live ips are already in the fail2ban log
  509. // and will reappear in the next scan if they reconnect.
  510. dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
  511. dbIps = append(dbIps, keptLive...)
  512. dbIps = append(dbIps, historicalIps...)
  513. jsonIps, _ := json.Marshal(dbIps)
  514. inboundClientIps.Ips = string(jsonIps)
  515. if err := tx.Save(inboundClientIps).Error; err != nil {
  516. logger.Error("failed to save inboundClientIps:", err)
  517. return false, banned
  518. }
  519. if len(j.disAllowedIps) > 0 {
  520. logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
  521. }
  522. return shouldCleanLog, banned
  523. }
  524. // filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since
  525. // the previous ban: the core refreshes lastSeen solely on a new dispatch, so a
  526. // frozen value is a dead connection it hasn't reaped yet, not a reconnect.
  527. func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
  528. if j.bannedSeen == nil {
  529. j.bannedSeen = make(map[string]int64)
  530. }
  531. current := make(map[string]struct{}, len(banned))
  532. actionable := make([]IPWithTimestamp, 0, len(banned))
  533. for _, ipTime := range banned {
  534. key := email + "|" + ipTime.IP
  535. current[key] = struct{}{}
  536. if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last {
  537. continue
  538. }
  539. j.bannedSeen[key] = ipTime.Timestamp
  540. actionable = append(actionable, ipTime)
  541. }
  542. prefix := email + "|"
  543. for key := range j.bannedSeen {
  544. if strings.HasPrefix(key, prefix) {
  545. if _, still := current[key]; !still {
  546. delete(j.bannedSeen, key)
  547. }
  548. }
  549. }
  550. return actionable
  551. }
  552. // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
  553. func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
  554. var xrayAPI xray.XrayAPI
  555. apiPort := j.resolveXrayAPIPort()
  556. err := xrayAPI.Init(apiPort)
  557. if err != nil {
  558. logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
  559. return
  560. }
  561. defer xrayAPI.Close()
  562. // Find the client config
  563. var clientConfig map[string]any
  564. for _, client := range clients {
  565. if client.Email == clientEmail {
  566. // Convert client to map for API
  567. clientBytes, _ := json.Marshal(client)
  568. _ = json.Unmarshal(clientBytes, &clientConfig)
  569. break
  570. }
  571. }
  572. if clientConfig == nil {
  573. return
  574. }
  575. // Protocols XrayAPI can remove and re-add from a marshaled model.Client.
  576. // wireguard stays out: keepAlive marshals as a number, AddUser wants a string.
  577. protocol := string(inbound.Protocol)
  578. switch protocol {
  579. case "vmess", "vless", "trojan", "shadowsocks", "hysteria":
  580. // supported protocols, continue
  581. default:
  582. logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
  583. return
  584. }
  585. // For Shadowsocks, ensure the required "cipher" field is present by
  586. // reading it from the inbound settings (e.g., settings["method"]).
  587. if string(inbound.Protocol) == "shadowsocks" {
  588. var inboundSettings map[string]any
  589. if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
  590. logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
  591. } else {
  592. if method, ok := inboundSettings["method"].(string); ok && method != "" {
  593. clientConfig["cipher"] = method
  594. }
  595. }
  596. }
  597. // Remove user to disconnect all connections
  598. err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
  599. if err != nil {
  600. logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
  601. return
  602. }
  603. // Wait a moment for disconnection to take effect
  604. time.Sleep(100 * time.Millisecond)
  605. // Re-add user to allow new connections
  606. err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
  607. if err != nil {
  608. logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
  609. }
  610. }
  611. // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
  612. func (j *CheckClientIpJob) resolveXrayAPIPort() int {
  613. var configErr error
  614. var templateErr error
  615. if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
  616. return port
  617. } else {
  618. configErr = err
  619. }
  620. db := database.GetDB()
  621. var template model.Setting
  622. if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
  623. if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
  624. return port
  625. } else {
  626. templateErr = parseErr
  627. }
  628. } else {
  629. templateErr = err
  630. }
  631. logger.Warningf(
  632. "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
  633. defaultXrayAPIPort,
  634. configErr,
  635. templateErr,
  636. )
  637. return defaultXrayAPIPort
  638. }
  639. func getAPIPortFromConfigPath(configPath string) (int, error) {
  640. configData, err := os.ReadFile(configPath)
  641. if err != nil {
  642. return 0, err
  643. }
  644. return getAPIPortFromConfigData(configData)
  645. }
  646. func getAPIPortFromConfigData(configData []byte) (int, error) {
  647. xrayConfig := &xray.Config{}
  648. if err := json.Unmarshal(configData, xrayConfig); err != nil {
  649. return 0, err
  650. }
  651. for _, inboundConfig := range xrayConfig.InboundConfigs {
  652. if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
  653. return inboundConfig.Port, nil
  654. }
  655. }
  656. return 0, errors.New("api inbound port not found")
  657. }
  658. // getInboundByEmail resolves the inbound that owns a client email. It prefers
  659. // the exact clients/client_inbounds relation; a substring "settings LIKE
  660. // %email%" can match the wrong inbound (an email that is a substring of another,
  661. // or text that merely appears elsewhere in the settings JSON). The LIKE + JSON
  662. // scan stays only as a fallback for clients not yet present in the relation, so
  663. // nothing regresses when the join finds no row.
  664. func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
  665. db := database.GetDB()
  666. inbound := &model.Inbound{}
  667. err := db.Model(&model.Inbound{}).
  668. Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
  669. Joins("JOIN clients ON clients.id = client_inbounds.client_id").
  670. Where("clients.email = ?", clientEmail).
  671. First(inbound).Error
  672. if err == nil {
  673. return inbound, nil
  674. }
  675. var candidates []model.Inbound
  676. if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil {
  677. return nil, listErr
  678. }
  679. for i := range candidates {
  680. clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings)
  681. if jsonErr != nil {
  682. continue
  683. }
  684. for _, client := range clients {
  685. if client.Email == clientEmail {
  686. return &candidates[i], nil
  687. }
  688. }
  689. }
  690. return nil, err
  691. }
  692. // Runs before the fail2ban/apiMode gates: retention must hold for stored rows
  693. // even while nothing is being collected.
  694. func (j *CheckClientIpJob) pruneStaleIpRows() {
  695. now := time.Now().Unix()
  696. if now-j.lastIpPrune < ipPruneIntervalSeconds {
  697. return
  698. }
  699. j.lastIpPrune = now
  700. if err := (&service.InboundService{}).PruneStaleClientIps(); err != nil {
  701. logger.Warning("prune stale client ip rows failed:", err)
  702. }
  703. }