1
0

check_client_ip_job.go 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  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. // only ips seen in this scan count toward the limit. see
  462. // partitionLiveIps.
  463. observedThisScan := make(map[string]bool, len(newIpsWithTime))
  464. for _, ipTime := range newIpsWithTime {
  465. observedThisScan[ipTime.IP] = true
  466. }
  467. staleCutoff := time.Now().Unix() - ipStaleAfterSeconds
  468. // Node sync merges into the same blob (#6587): compare-and-set it, and on a
  469. // miss re-read and re-merge so neither writer drops the other's IPs.
  470. for range service.ClientIpCasRetries {
  471. var oldIpsWithTime []IPWithTimestamp
  472. if inboundClientIps.Ips != "" {
  473. _ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
  474. }
  475. ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive)
  476. liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
  477. // historical db-only ips are excluded from this count on purpose.
  478. limitedIps, allowedIps := j.allowlist.split(liveIps)
  479. keptLive, bannedLive := selectIpsToBan(limitedIps, limitIp)
  480. // Allowlisted addresses stay connected and out of the count: charging them
  481. // against the limit would still cut the shared network the entry protects.
  482. keptLive = append(keptLive, allowedIps...)
  483. // keep kept-live + historical in the blob so the panel keeps showing recently
  484. // seen ips; banned live ips reappear in the next scan if they reconnect.
  485. dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
  486. dbIps = append(dbIps, keptLive...)
  487. dbIps = append(dbIps, historicalIps...)
  488. jsonIps, _ := json.Marshal(dbIps)
  489. newIps := string(jsonIps)
  490. if newIps == inboundClientIps.Ips {
  491. return bannedLive, len(keptLive)
  492. }
  493. updated, err := service.CasUpdateInboundClientIps(tx, inboundClientIps.Id, inboundClientIps.Ips, newIps)
  494. if err != nil {
  495. logger.Error("failed to save inboundClientIps:", err)
  496. return nil, 0
  497. }
  498. if updated {
  499. inboundClientIps.Ips = newIps
  500. return bannedLive, len(keptLive)
  501. }
  502. if err := tx.Where("id = ?", inboundClientIps.Id).First(inboundClientIps).Error; err != nil {
  503. logger.Error("failed to re-read inboundClientIps after a concurrent write:", err)
  504. return nil, 0
  505. }
  506. }
  507. logger.Error("failed to save inboundClientIps: exhausted CAS retries")
  508. return nil, 0
  509. }
  510. // pendingBan carries one client's enforcement outcome from inside the scan's
  511. // transaction to the publication that may only follow a successful commit.
  512. type pendingBan struct {
  513. inbound *model.Inbound
  514. email string
  515. candidates []IPWithTimestamp
  516. keptLive int
  517. }
  518. // publishBans returns the clients whose lines reached the log. bannedSeen
  519. // advances only for those, so a failed write leaves the address retryable.
  520. func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan {
  521. published := make([]pendingBan, 0, len(bans))
  522. var logIpFile *os.File
  523. defer func() {
  524. if logIpFile == nil {
  525. return
  526. }
  527. if err := logIpFile.Close(); err != nil {
  528. logger.Errorf("failed to close IP limit log file: %s", err)
  529. }
  530. }()
  531. for _, b := range bans {
  532. actionable := j.selectAdvancedSinceLastBan(b.email, b.candidates)
  533. if len(actionable) == 0 {
  534. j.recordBannedSeen(b.email, b.candidates, nil)
  535. continue
  536. }
  537. if logIpFile == nil {
  538. f, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
  539. if err != nil {
  540. logger.Errorf("failed to open IP limit log file: %s", err)
  541. return published
  542. }
  543. logIpFile = f
  544. }
  545. if err := writeBanLines(logIpFile, b.email, actionable); err != nil {
  546. logger.Errorf("failed to write IP limit bans for %s: %s", b.email, err)
  547. continue
  548. }
  549. j.recordBannedSeen(b.email, b.candidates, actionable)
  550. logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", b.email, b.keptLive, len(actionable))
  551. published = append(published, b)
  552. }
  553. return published
  554. }
  555. // writeBanLines emits one line per address; the wording is load-bearing, since
  556. // x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it.
  557. func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp) error {
  558. stamp := time.Now().Format("2006/01/02 15:04:05")
  559. for _, ipTime := range actionable {
  560. if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n",
  561. stamp, clientEmail, ipTime.IP, ipTime.Timestamp); err != nil {
  562. return err
  563. }
  564. }
  565. return nil
  566. }
  567. // selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core
  568. // refreshes it only on a new dispatch, so those are unreaped dead connections.
  569. func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
  570. actionable := make([]IPWithTimestamp, 0, len(banned))
  571. for _, ipTime := range banned {
  572. if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last {
  573. continue
  574. }
  575. actionable = append(actionable, ipTime)
  576. }
  577. return actionable
  578. }
  579. // recordBannedSeen marks published pairs and forgets addresses this scan no
  580. // longer bans; it runs for every enforced client, which is what prunes the map.
  581. func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []IPWithTimestamp) {
  582. if j.bannedSeen == nil {
  583. j.bannedSeen = make(map[string]int64)
  584. }
  585. for _, ipTime := range published {
  586. j.bannedSeen[email+"|"+ipTime.IP] = ipTime.Timestamp
  587. }
  588. current := make(map[string]struct{}, len(banned))
  589. for _, ipTime := range banned {
  590. current[email+"|"+ipTime.IP] = struct{}{}
  591. }
  592. prefix := email + "|"
  593. for key := range j.bannedSeen {
  594. if strings.HasPrefix(key, prefix) {
  595. if _, still := current[key]; !still {
  596. delete(j.bannedSeen, key)
  597. }
  598. }
  599. }
  600. }
  601. // disconnectClientTemporarily drops a client's credential for a moment, so new
  602. // handshakes are refused; the fail2ban ban is what ends live traffic.
  603. func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
  604. var xrayAPI xray.XrayAPI
  605. apiPort := j.resolveXrayAPIPort()
  606. err := xrayAPI.Init(apiPort)
  607. if err != nil {
  608. logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
  609. return
  610. }
  611. defer xrayAPI.Close()
  612. // Find the client config
  613. var clientConfig map[string]any
  614. var reverseClient bool
  615. for _, client := range clients {
  616. if client.Email == clientEmail {
  617. // Convert client to map for API
  618. clientBytes, _ := json.Marshal(client)
  619. _ = json.Unmarshal(clientBytes, &clientConfig)
  620. reverseClient = client.Reverse != nil
  621. break
  622. }
  623. }
  624. if clientConfig == nil {
  625. return
  626. }
  627. // Protocols XrayAPI can remove and re-add from a marshaled model.Client.
  628. // wireguard stays out: keepAlive marshals as a number, AddUser wants a string.
  629. protocol := string(inbound.Protocol)
  630. switch protocol {
  631. case "vmess", "vless", "trojan", "shadowsocks", "hysteria":
  632. // supported protocols, continue
  633. default:
  634. logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
  635. return
  636. }
  637. // RemoveUser drops a reverse client's outbound handler and the re-add below
  638. // cannot restore it, so its tunnel would stay down until Xray restarts.
  639. if reverseClient {
  640. logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail)
  641. return
  642. }
  643. // For Shadowsocks, ensure the required "cipher" field is present by
  644. // reading it from the inbound settings (e.g., settings["method"]).
  645. if string(inbound.Protocol) == "shadowsocks" {
  646. var inboundSettings map[string]any
  647. if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
  648. logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
  649. } else {
  650. if method, ok := inboundSettings["method"].(string); ok && method != "" {
  651. clientConfig["cipher"] = method
  652. }
  653. }
  654. }
  655. // The core's RemoveUser clears its validator: a session already up keeps
  656. // running, except a reverse vless client, which is skipped above.
  657. err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
  658. if err != nil {
  659. logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
  660. return
  661. }
  662. // Nothing is pending here: AlterInbound applies the removal inline, so this
  663. // only widens the window in which new handshakes fail.
  664. time.Sleep(100 * time.Millisecond)
  665. // Re-add user to allow new connections
  666. err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
  667. if err != nil {
  668. logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
  669. }
  670. }
  671. // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
  672. func (j *CheckClientIpJob) resolveXrayAPIPort() int {
  673. var configErr error
  674. var templateErr error
  675. if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
  676. return port
  677. } else {
  678. configErr = err
  679. }
  680. db := database.GetDB()
  681. var template model.Setting
  682. if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
  683. if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
  684. return port
  685. } else {
  686. templateErr = parseErr
  687. }
  688. } else {
  689. templateErr = err
  690. }
  691. logger.Warningf(
  692. "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
  693. defaultXrayAPIPort,
  694. configErr,
  695. templateErr,
  696. )
  697. return defaultXrayAPIPort
  698. }
  699. func getAPIPortFromConfigPath(configPath string) (int, error) {
  700. configData, err := os.ReadFile(configPath)
  701. if err != nil {
  702. return 0, err
  703. }
  704. return getAPIPortFromConfigData(configData)
  705. }
  706. func getAPIPortFromConfigData(configData []byte) (int, error) {
  707. xrayConfig := &xray.Config{}
  708. if err := json.Unmarshal(configData, xrayConfig); err != nil {
  709. return 0, err
  710. }
  711. for _, inboundConfig := range xrayConfig.InboundConfigs {
  712. if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
  713. return inboundConfig.Port, nil
  714. }
  715. }
  716. return 0, errors.New("api inbound port not found")
  717. }
  718. // getInboundByEmail resolves the inbound that owns a client email. It prefers
  719. // the exact clients/client_inbounds relation; a substring "settings LIKE
  720. // %email%" can match the wrong inbound (an email that is a substring of another,
  721. // or text that merely appears elsewhere in the settings JSON). The LIKE + JSON
  722. // scan stays only as a fallback for clients not yet present in the relation, so
  723. // nothing regresses when the join finds no row.
  724. func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
  725. db := database.GetDB()
  726. inbound := &model.Inbound{}
  727. err := db.Model(&model.Inbound{}).
  728. Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
  729. Joins("JOIN clients ON clients.id = client_inbounds.client_id").
  730. Where("clients.email = ?", clientEmail).
  731. First(inbound).Error
  732. if err == nil {
  733. return inbound, nil
  734. }
  735. var candidates []model.Inbound
  736. if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil {
  737. return nil, listErr
  738. }
  739. for i := range candidates {
  740. clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings)
  741. if jsonErr != nil {
  742. continue
  743. }
  744. for _, client := range clients {
  745. if client.Email == clientEmail {
  746. return &candidates[i], nil
  747. }
  748. }
  749. }
  750. return nil, err
  751. }
  752. // Runs before the fail2ban/apiMode gates: retention must hold for stored rows
  753. // even while nothing is being collected.
  754. func (j *CheckClientIpJob) pruneStaleIpRows() {
  755. now := time.Now().Unix()
  756. if now-j.lastIpPrune < ipPruneIntervalSeconds {
  757. return
  758. }
  759. j.lastIpPrune = now
  760. if err := (&service.InboundService{}).PruneStaleClientIps(); err != nil {
  761. logger.Warning("prune stale client ip rows failed:", err)
  762. }
  763. }