check_client_ip_job.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. package job
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "os"
  9. "os/exec"
  10. "runtime"
  11. "sort"
  12. "strings"
  13. "time"
  14. "github.com/mhsanaei/3x-ui/v3/internal/database"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  16. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  17. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  18. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  19. "gorm.io/gorm"
  20. )
  21. // IPWithTimestamp tracks an IP address with its last seen timestamp
  22. type IPWithTimestamp struct {
  23. IP string `json:"ip"`
  24. Timestamp int64 `json:"timestamp"`
  25. }
  26. // CheckClientIpJob monitors client IP addresses and manages IP blocking based
  27. // on configured limits. The per-client IPs come from the core's online-stats
  28. // API; no access log is involved. On a core too old to expose that API the job
  29. // simply skips the run (the bundled core always supports it).
  30. type CheckClientIpJob struct {
  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. var bans []pendingBan
  265. db := database.GetDB()
  266. tx := db.Begin()
  267. if tx.Error != nil {
  268. j.checkError(tx.Error)
  269. return false
  270. }
  271. committed := false
  272. defer func() {
  273. if !committed {
  274. tx.Rollback()
  275. }
  276. }()
  277. for _, email := range emails {
  278. ipTimestamps := observed[email]
  279. // The observations can still reference a client that was just renamed
  280. // or deleted; its email no longer matches any inbound. Skip it (and
  281. // drop any orphaned tracking row) instead of recreating a row and
  282. // logging an ERROR every run (#4963). The batch map resolves through
  283. // the clients relation; the per-email fallback keeps its settings LIKE
  284. // net for clients not yet present there.
  285. inbound, ok := inboundByEmail[email]
  286. if !ok {
  287. var err error
  288. inbound, err = j.getInboundByEmail(email)
  289. if err != nil {
  290. if errors.Is(err, gorm.ErrRecordNotFound) {
  291. logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email)
  292. j.delInboundClientIps(tx, email)
  293. } else {
  294. j.checkError(err)
  295. }
  296. continue
  297. }
  298. }
  299. // Convert to IPWithTimestamp slice
  300. ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
  301. attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
  302. for ip, timestamp := range ipTimestamps {
  303. ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
  304. // Live API observations may carry an old lastSeen (connection start),
  305. // so stamp attribution with now; otherwise the stale cutoff would evict
  306. // an IP that is connected right now.
  307. attrTs := timestamp
  308. if observedAreLive {
  309. attrTs = now
  310. }
  311. attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs})
  312. }
  313. if len(attrEntries) > 0 {
  314. attribution[email] = attrEntries
  315. }
  316. clientIpsRecord, ok := ipRowByEmail[email]
  317. if !ok {
  318. jsonIps, err := json.Marshal(ipsWithTime)
  319. if err != nil {
  320. j.checkError(err)
  321. continue
  322. }
  323. if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
  324. j.checkError(err)
  325. }
  326. continue
  327. }
  328. candidates, keptLive := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
  329. bans = append(bans, pendingBan{inbound: inbound, email: email, candidates: candidates, keptLive: keptLive})
  330. }
  331. if err := tx.Commit().Error; err != nil {
  332. j.checkError(err)
  333. return false
  334. }
  335. committed = true
  336. published := j.publishBans(bans)
  337. // Xray disconnects run after the commit so their network round-trips never
  338. // extend the scan's write transaction (node syncs upsert the same table).
  339. shouldCleanLog = shouldCleanLog || len(published) > 0
  340. clientsCache := make(map[int][]model.Client)
  341. for _, d := range published {
  342. clients, cached := clientsCache[d.inbound.Id]
  343. if !cached {
  344. clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings)
  345. clientsCache[d.inbound.Id] = clients
  346. }
  347. j.disconnectClientTemporarily(d.inbound, d.email, clients)
  348. }
  349. j.recordLocalAttribution(attribution)
  350. return shouldCleanLog
  351. }
  352. // recordLocalAttribution stores this scan's local observations under this panel's
  353. // own guid so a parent panel can attribute each IP to the node it is on.
  354. // Best-effort: attribution is advisory and must never block IP-limit enforcement.
  355. func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) {
  356. if len(attribution) == 0 {
  357. return
  358. }
  359. guid, err := (&service.SettingService{}).GetPanelGuid()
  360. if err != nil || guid == "" {
  361. return
  362. }
  363. if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil {
  364. logger.Debug("[LimitIP] record local ip attribution failed:", err)
  365. }
  366. }
  367. // mergeClientIps folds this scan's observations into the persisted set,
  368. // dropping entries older than staleCutoff. newAlwaysLive exempts the new
  369. // entries from that cutoff: an API-observed IP is a live connection by
  370. // definition, even when its lastSeen (set at dispatch time) is hours old.
  371. func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
  372. ipMap := make(map[string]int64, len(old)+len(new))
  373. for _, ipTime := range old {
  374. if ipTime.Timestamp < staleCutoff {
  375. continue
  376. }
  377. ipMap[ipTime.IP] = ipTime.Timestamp
  378. }
  379. for _, ipTime := range new {
  380. if !newAlwaysLive && ipTime.Timestamp < staleCutoff {
  381. continue
  382. }
  383. if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
  384. ipMap[ipTime.IP] = ipTime.Timestamp
  385. }
  386. }
  387. return ipMap
  388. }
  389. // selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
  390. // into the newest `limit` entries to keep and the older remainder to ban.
  391. func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
  392. if limit <= 0 || len(live) <= limit {
  393. return live, nil
  394. }
  395. cutoff := len(live) - limit
  396. return live[cutoff:], live[:cutoff]
  397. }
  398. func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
  399. live = make([]IPWithTimestamp, 0, len(observedThisScan))
  400. historical = make([]IPWithTimestamp, 0, len(ipMap))
  401. now := time.Now().Unix()
  402. for ip, ts := range ipMap {
  403. entry := IPWithTimestamp{IP: ip, Timestamp: ts}
  404. // Consider an IP "live" if it was seen locally in this scan, OR if its
  405. // timestamp from the synced database is very recent (e.g. within 2 minutes).
  406. // This ensures cluster-wide limits work even if the IP was seen on another node.
  407. if observedThisScan[ip] || now-ts < 120 {
  408. live = append(live, entry)
  409. } else {
  410. historical = append(historical, entry)
  411. }
  412. }
  413. sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
  414. sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
  415. return live, historical
  416. }
  417. func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
  418. if !isFail2BanEnabled() {
  419. return false
  420. }
  421. cmd := "fail2ban-client"
  422. args := []string{"-h"}
  423. err := exec.CommandContext(context.Background(), cmd, args...).Run()
  424. return err == nil
  425. }
  426. func isFail2BanEnabled() bool {
  427. value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
  428. return !ok || value == "true"
  429. }
  430. func (j *CheckClientIpJob) checkError(e error) {
  431. if e != nil {
  432. logger.Warning("client ip job err:", e)
  433. }
  434. }
  435. // delInboundClientIps drops the inbound_client_ips tracking row for an email
  436. // that no longer maps to any inbound (a renamed or deleted client), so stale
  437. // access-log entries don't keep a ghost row alive (#4963).
  438. func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) {
  439. if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
  440. j.checkError(err)
  441. }
  442. }
  443. // updateInboundClientIps merges one email's observed IPs into its tracking row
  444. // and applies the IP limit. Ban candidates are returned, not written: the
  445. // fail2ban log is the point of no return and must wait for the commit.
  446. func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (banCandidates []IPWithTimestamp, keptLiveCount int) {
  447. if inbound.Settings == "" {
  448. logger.Debug("wrong data:", inbound)
  449. return nil, 0
  450. }
  451. if !enforce || limitIp <= 0 || !inbound.Enable {
  452. // Nothing to enforce (collection-only run, no limit on the clients row,
  453. // or inbound disabled): record the observed IPs for the panel and return.
  454. jsonIps, _ := json.Marshal(newIpsWithTime)
  455. inboundClientIps.Ips = string(jsonIps)
  456. if err := tx.Save(inboundClientIps).Error; err != nil {
  457. logger.Error("failed to save inboundClientIps:", err)
  458. }
  459. return nil, 0
  460. }
  461. // Parse old IPs from database
  462. var oldIpsWithTime []IPWithTimestamp
  463. if inboundClientIps.Ips != "" {
  464. _ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
  465. }
  466. ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds, observedAreLive)
  467. // only ips seen in this scan count toward the limit. see
  468. // partitionLiveIps.
  469. observedThisScan := make(map[string]bool, len(newIpsWithTime))
  470. for _, ipTime := range newIpsWithTime {
  471. observedThisScan[ipTime.IP] = true
  472. }
  473. liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
  474. // historical db-only ips are excluded from this count on purpose.
  475. limitedIps, allowedIps := j.allowlist.split(liveIps)
  476. keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
  477. // Allowlisted addresses stay connected and out of the count: charging them
  478. // against the limit would still cut the shared network the entry protects.
  479. keptLive = append(keptLive, allowedIps...)
  480. // keep kept-live + historical in the blob so the panel keeps showing recently
  481. // seen ips; banned live ips reappear in the next scan if they reconnect.
  482. dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
  483. dbIps = append(dbIps, keptLive...)
  484. dbIps = append(dbIps, historicalIps...)
  485. jsonIps, _ := json.Marshal(dbIps)
  486. inboundClientIps.Ips = string(jsonIps)
  487. if err := tx.Save(inboundClientIps).Error; err != nil {
  488. logger.Error("failed to save inboundClientIps:", err)
  489. return nil, 0
  490. }
  491. return bannedLive, len(keptLive)
  492. }
  493. // pendingBan carries one client's enforcement outcome from inside the scan's
  494. // transaction to the publication that may only follow a successful commit.
  495. type pendingBan struct {
  496. inbound *model.Inbound
  497. email string
  498. candidates []IPWithTimestamp
  499. keptLive int
  500. }
  501. // publishBans returns the clients whose lines reached the log. bannedSeen
  502. // advances only for those, so a failed write leaves the address retryable.
  503. func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan {
  504. published := make([]pendingBan, 0, len(bans))
  505. var logIpFile *os.File
  506. defer func() {
  507. if logIpFile == nil {
  508. return
  509. }
  510. if err := logIpFile.Close(); err != nil {
  511. logger.Errorf("failed to close IP limit log file: %s", err)
  512. }
  513. }()
  514. for _, b := range bans {
  515. actionable := j.selectAdvancedSinceLastBan(b.email, b.candidates)
  516. if len(actionable) == 0 {
  517. j.recordBannedSeen(b.email, b.candidates, nil)
  518. continue
  519. }
  520. if logIpFile == nil {
  521. f, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
  522. if err != nil {
  523. logger.Errorf("failed to open IP limit log file: %s", err)
  524. return published
  525. }
  526. logIpFile = f
  527. }
  528. if err := writeBanLines(logIpFile, b.email, actionable); err != nil {
  529. logger.Errorf("failed to write IP limit bans for %s: %s", b.email, err)
  530. continue
  531. }
  532. j.recordBannedSeen(b.email, b.candidates, actionable)
  533. logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", b.email, b.keptLive, len(actionable))
  534. published = append(published, b)
  535. }
  536. return published
  537. }
  538. // writeBanLines emits one line per address; the wording is load-bearing, since
  539. // x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it.
  540. func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp) error {
  541. stamp := time.Now().Format("2006/01/02 15:04:05")
  542. for _, ipTime := range actionable {
  543. if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n",
  544. stamp, clientEmail, ipTime.IP, ipTime.Timestamp); err != nil {
  545. return err
  546. }
  547. }
  548. return nil
  549. }
  550. // selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core
  551. // refreshes it only on a new dispatch, so those are unreaped dead connections.
  552. func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
  553. actionable := make([]IPWithTimestamp, 0, len(banned))
  554. for _, ipTime := range banned {
  555. if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last {
  556. continue
  557. }
  558. actionable = append(actionable, ipTime)
  559. }
  560. return actionable
  561. }
  562. // recordBannedSeen marks published pairs and forgets addresses this scan no
  563. // longer bans; it runs for every enforced client, which is what prunes the map.
  564. func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []IPWithTimestamp) {
  565. if j.bannedSeen == nil {
  566. j.bannedSeen = make(map[string]int64)
  567. }
  568. for _, ipTime := range published {
  569. j.bannedSeen[email+"|"+ipTime.IP] = ipTime.Timestamp
  570. }
  571. current := make(map[string]struct{}, len(banned))
  572. for _, ipTime := range banned {
  573. current[email+"|"+ipTime.IP] = struct{}{}
  574. }
  575. prefix := email + "|"
  576. for key := range j.bannedSeen {
  577. if strings.HasPrefix(key, prefix) {
  578. if _, still := current[key]; !still {
  579. delete(j.bannedSeen, key)
  580. }
  581. }
  582. }
  583. }
  584. // disconnectClientTemporarily drops a client's credential for a moment, so new
  585. // handshakes are refused; the fail2ban ban is what ends live traffic.
  586. func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
  587. var xrayAPI xray.XrayAPI
  588. apiPort := j.resolveXrayAPIPort()
  589. err := xrayAPI.Init(apiPort)
  590. if err != nil {
  591. logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
  592. return
  593. }
  594. defer xrayAPI.Close()
  595. // Find the client config
  596. var clientConfig map[string]any
  597. var reverseClient bool
  598. for _, client := range clients {
  599. if client.Email == clientEmail {
  600. // Convert client to map for API
  601. clientBytes, _ := json.Marshal(client)
  602. _ = json.Unmarshal(clientBytes, &clientConfig)
  603. reverseClient = client.Reverse != nil
  604. break
  605. }
  606. }
  607. if clientConfig == nil {
  608. return
  609. }
  610. // Protocols XrayAPI can remove and re-add from a marshaled model.Client.
  611. // wireguard stays out: keepAlive marshals as a number, AddUser wants a string.
  612. protocol := string(inbound.Protocol)
  613. switch protocol {
  614. case "vmess", "vless", "trojan", "shadowsocks", "hysteria":
  615. // supported protocols, continue
  616. default:
  617. logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
  618. return
  619. }
  620. // RemoveUser drops a reverse client's outbound handler and the re-add below
  621. // cannot restore it, so its tunnel would stay down until Xray restarts.
  622. if reverseClient {
  623. logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
  624. return
  625. }
  626. // For Shadowsocks, ensure the required "cipher" field is present by
  627. // reading it from the inbound settings (e.g., settings["method"]).
  628. if string(inbound.Protocol) == "shadowsocks" {
  629. var inboundSettings map[string]any
  630. if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
  631. logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
  632. } else {
  633. if method, ok := inboundSettings["method"].(string); ok && method != "" {
  634. clientConfig["cipher"] = method
  635. }
  636. }
  637. }
  638. // The core's RemoveUser clears its validator: a session already up keeps
  639. // running, except a reverse vless client, which is skipped above.
  640. err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
  641. if err != nil {
  642. logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
  643. return
  644. }
  645. // Nothing is pending here: AlterInbound applies the removal inline, so this
  646. // only widens the window in which new handshakes fail.
  647. time.Sleep(100 * time.Millisecond)
  648. // Re-add user to allow new connections
  649. err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
  650. if err != nil {
  651. logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
  652. }
  653. }
  654. // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
  655. func (j *CheckClientIpJob) resolveXrayAPIPort() int {
  656. var configErr error
  657. var templateErr error
  658. if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
  659. return port
  660. } else {
  661. configErr = err
  662. }
  663. db := database.GetDB()
  664. var template model.Setting
  665. if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
  666. if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
  667. return port
  668. } else {
  669. templateErr = parseErr
  670. }
  671. } else {
  672. templateErr = err
  673. }
  674. logger.Warningf(
  675. "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
  676. defaultXrayAPIPort,
  677. configErr,
  678. templateErr,
  679. )
  680. return defaultXrayAPIPort
  681. }
  682. func getAPIPortFromConfigPath(configPath string) (int, error) {
  683. configData, err := os.ReadFile(configPath)
  684. if err != nil {
  685. return 0, err
  686. }
  687. return getAPIPortFromConfigData(configData)
  688. }
  689. func getAPIPortFromConfigData(configData []byte) (int, error) {
  690. xrayConfig := &xray.Config{}
  691. if err := json.Unmarshal(configData, xrayConfig); err != nil {
  692. return 0, err
  693. }
  694. for _, inboundConfig := range xrayConfig.InboundConfigs {
  695. if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
  696. return inboundConfig.Port, nil
  697. }
  698. }
  699. return 0, errors.New("api inbound port not found")
  700. }
  701. // getInboundByEmail resolves the inbound that owns a client email. It prefers
  702. // the exact clients/client_inbounds relation; a substring "settings LIKE
  703. // %email%" can match the wrong inbound (an email that is a substring of another,
  704. // or text that merely appears elsewhere in the settings JSON). The LIKE + JSON
  705. // scan stays only as a fallback for clients not yet present in the relation, so
  706. // nothing regresses when the join finds no row.
  707. func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
  708. db := database.GetDB()
  709. inbound := &model.Inbound{}
  710. err := db.Model(&model.Inbound{}).
  711. Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
  712. Joins("JOIN clients ON clients.id = client_inbounds.client_id").
  713. Where("clients.email = ?", clientEmail).
  714. First(inbound).Error
  715. if err == nil {
  716. return inbound, nil
  717. }
  718. var candidates []model.Inbound
  719. if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil {
  720. return nil, listErr
  721. }
  722. for i := range candidates {
  723. clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings)
  724. if jsonErr != nil {
  725. continue
  726. }
  727. for _, client := range clients {
  728. if client.Email == clientEmail {
  729. return &candidates[i], nil
  730. }
  731. }
  732. }
  733. return nil, err
  734. }
  735. // Runs before the fail2ban/apiMode gates: retention must hold for stored rows
  736. // even while nothing is being collected.
  737. func (j *CheckClientIpJob) pruneStaleIpRows() {
  738. now := time.Now().Unix()
  739. if now-j.lastIpPrune < ipPruneIntervalSeconds {
  740. return
  741. }
  742. j.lastIpPrune = now
  743. if err := (&service.InboundService{}).PruneStaleClientIps(); err != nil {
  744. logger.Warning("prune stale client ip rows failed:", err)
  745. }
  746. }