package job import ( "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "runtime" "sort" "strings" "time" "github.com/mhsanaei/3x-ui/v3/internal/amneziawgkern" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/xray" xnet "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/infra/conf" "gorm.io/gorm" "gorm.io/gorm/clause" ) // onlineUsersSource is the one service.XrayService method the scan needs, so // tests can drive Run end to end without a live xray core. type onlineUsersSource interface { GetOnlineUsers() ([]xray.OnlineUser, bool, error) } // CheckClientIpJob monitors client IP addresses and manages IP blocking based // on configured limits. The per-client IPs come from the core's online-stats // API; no access log is involved. On a core too old to expose that API the job // simply skips the run (the bundled core always supports it). type CheckClientIpJob struct { bannedSeen map[string]int64 onlineUsers onlineUsersSource allowlist ipLimitAllowlist lastIpPrune int64 } const defaultXrayAPIPort = 62789 const ipStaleAfterSeconds = int64(30 * 60) // pruneStaleIpRows cadence; the scan itself cannot prune offline clients' rows. const ipPruneIntervalSeconds = int64(5 * 60) // NewCheckClientIpJob creates a new client IP monitoring job instance. func NewCheckClientIpJob() *CheckClientIpJob { return &CheckClientIpJob{onlineUsers: &service.XrayService{}} } func (j *CheckClientIpJob) Run() { j.pruneStaleIpRows() observed, apiMode := j.collectFromOnlineAPI() // Kernel-mode AmneziaWG peers never reach Xray, so its online users cannot // see them; their connect locations come from the kernel interface instead. kernelIPs := amneziawgkern.GetCollector().ClientIPs() if len(kernelIPs) > 0 { if observed == nil { observed = make(map[string]map[string]int64, len(kernelIPs)) } for email, ips := range kernelIPs { if _, ok := observed[email]; !ok { observed[email] = make(map[string]int64, len(ips)) } for ip, ts := range ips { if prev, seen := observed[email][ip]; !seen || ts > prev { observed[email][ip] = ts } } } } if !apiMode && len(kernelIPs) == 0 { // xray is down or predates the online-stats API. There is no access-log // fallback anymore, so there is nothing to do this run. logger.Debug("[LimitIP] online-stats API unavailable this run; skipping") return } // XUI_ENABLE_FAIL2BAN=false disables enforcement, not tracking (#6722): // the scan still records IPs so the panel keeps showing them. hasLimit := j.hasLimitIp() f2bInstalled := false if hasLimit { f2bInstalled = j.checkFail2BanInstalled() } // Windows enforces through disconnects and never needs the fail2ban binary. needsBinary := runtime.GOOS != "windows" enforce := j.resolveEnforce(isFail2BanEnabled(), hasLimit, f2bInstalled, needsBinary) // Read only when the limit is actually applied: this runs every 10s and // most panels carry no IP limit at all. if enforce { j.allowlist = j.loadAllowlist() } j.processObserved(observed, enforce, true) } // resolveEnforce decides whether limits can actually be enforced this run: a // disabled fail2ban gates every platform, a missing binary the ones needing it. func (j *CheckClientIpJob) resolveEnforce(f2bEnabled, hasLimit, f2bInstalled, needsBinary bool) bool { if !f2bEnabled { return false } if hasLimit && needsBinary && !f2bInstalled { return false } return hasLimit } // collectFromOnlineAPI builds per-email IP observations (email -> ip -> // last-seen unix seconds) from the core's online-stats API. ok=false means the // API is unavailable — xray not running, an older core, or a transient gRPC // failure — and the caller skips the run (there is no access-log fallback). func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) { onlineUsers, ok, err := j.onlineUsers.GetOnlineUsers() if err != nil { logger.Debug("[LimitIP] online-stats API unavailable this run:", err) return nil, false } if !ok { return nil, false } now := time.Now().Unix() observed := make(map[string]map[string]int64, len(onlineUsers)) for _, user := range onlineUsers { for _, entry := range user.IPs { // No localhost guard needed here: the core's OnlineMap.AddIP drops // 127.0.0.1/[::1] itself, so they never reach this list. ts := entry.LastSeen if ts <= 0 { ts = now } if _, exists := observed[user.Email]; !exists { observed[user.Email] = make(map[string]int64) } if existing, seen := observed[user.Email][entry.IP]; !seen || ts > existing { observed[user.Email][entry.IP] = ts } } } return observed, true } // hasLimitIp reports whether any client carries an IP limit. It probes the // normalized clients table (limit_ip is synced there by SyncInbound and the // legacy seeder), replacing the old `settings LIKE '%limitIp%'` scan that // loaded and JSON-parsed every inbound's settings blob on each 10s run. func (j *CheckClientIpJob) hasLimitIp() bool { db := database.GetDB() var probe int64 err := db.Model(&model.ClientRecord{}).Where("limit_ip > 0").Limit(1).Count(&probe).Error return err == nil && probe > 0 } // loadAllowlist reads the operator's trusted addresses once per scan; a bad // read leaves the list empty, which enforces the limit as before rather than // silently exempting everyone. func (j *CheckClientIpJob) loadAllowlist() ipLimitAllowlist { raw, err := (&service.SettingService{}).GetIpLimitAllowlist() if err != nil { logger.Warning("[LimitIP] could not read the allowlist, enforcing without it:", err) return ipLimitAllowlist{} } return parseIpLimitAllowlist(raw) } const ipScanChunk = 400 func chunkEmails(s []string, size int) [][]string { if len(s) == 0 { return nil } chunks := make([][]string, 0, (len(s)+size-1)/size) for size < len(s) { s, chunks = s[size:], append(chunks, s[:size]) } return append(chunks, s) } // loadClientLimits maps each observed email to its clients.limit_ip in a few // chunked queries, replacing the per-email settings-JSON parse that previously // resolved the limit. func (j *CheckClientIpJob) loadClientLimits(emails []string) map[string]int { db := database.GetDB() out := make(map[string]int, len(emails)) for _, batch := range chunkEmails(emails, ipScanChunk) { var rows []struct { Email string LimitIp int } if err := db.Model(&model.ClientRecord{}). Select("email, limit_ip"). Where("email IN ?", batch). Scan(&rows).Error; err != nil { j.checkError(err) continue } for _, r := range rows { out[r.Email] = r.LimitIp } } return out } // loadInboundsByEmails resolves each email's owning inbound through the // clients/client_inbounds relation in chunked queries. Like the old per-email // First() it keeps the lowest inbound id when a client spans several inbounds. func (j *CheckClientIpJob) loadInboundsByEmails(emails []string) map[string]*model.Inbound { db := database.GetDB() minInboundByEmail := make(map[string]int, len(emails)) for _, batch := range chunkEmails(emails, ipScanChunk) { var pairs []struct { Email string InboundId int } if err := db.Table("client_inbounds"). Select("clients.email AS email, client_inbounds.inbound_id AS inbound_id"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email IN ?", batch). Scan(&pairs).Error; err != nil { j.checkError(err) return nil } for _, p := range pairs { if cur, ok := minInboundByEmail[p.Email]; !ok || p.InboundId < cur { minInboundByEmail[p.Email] = p.InboundId } } } if len(minInboundByEmail) == 0 { return nil } idSet := make(map[int]struct{}, len(minInboundByEmail)) ids := make([]int, 0, len(minInboundByEmail)) for _, id := range minInboundByEmail { if _, seen := idSet[id]; !seen { idSet[id] = struct{}{} ids = append(ids, id) } } sort.Ints(ids) inboundsById := make(map[int]*model.Inbound, len(ids)) for lo := 0; lo < len(ids); lo += ipScanChunk { hi := min(lo+ipScanChunk, len(ids)) var page []*model.Inbound if err := db.Model(&model.Inbound{}).Where("id IN ?", ids[lo:hi]).Find(&page).Error; err != nil { j.checkError(err) return nil } for _, ib := range page { inboundsById[ib.Id] = ib } } out := make(map[string]*model.Inbound, len(minInboundByEmail)) for email, id := range minInboundByEmail { if ib, ok := inboundsById[id]; ok { out[email] = ib } } return out } func (j *CheckClientIpJob) loadClientIpRows(emails []string) map[string]*model.InboundClientIps { db := database.GetDB() out := make(map[string]*model.InboundClientIps, len(emails)) for _, batch := range chunkEmails(emails, ipScanChunk) { var rows []model.InboundClientIps if err := db.Where("client_email IN ?", batch).Find(&rows).Error; err != nil { j.checkError(err) continue } for i := range rows { out[rows[i].ClientEmail] = &rows[i] } } return out } // processObserved runs collection + enforcement for one scan's observations // (email -> ip -> last-seen unix seconds). observedAreLive marks the // observations as live connections, which bypass the stale cutoff: a connection // that opened hours ago is still live even though its timestamp is old. The // online-stats API always reports live connections, so the job passes true. // Lookups are batched up front and all inbound_client_ips writes share one // transaction, so a scan costs a handful of queries and one fsync instead of // several per observed email. func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool { shouldCleanLog := false now := time.Now().Unix() emails := make([]string, 0, len(observed)) for email := range observed { emails = append(emails, email) } sort.Strings(emails) limitByEmail := j.loadClientLimits(emails) inboundByEmail := j.loadInboundsByEmails(emails) ipRowByEmail := j.loadClientIpRows(emails) // attribution accumulates this scan's local observations per email so they can // be recorded under this panel's own guid for cross-node IP attribution. attribution := make(map[string][]model.ClientIpEntry, len(observed)) var bans []pendingBan db := database.GetDB() tx := db.Begin() if tx.Error != nil { j.checkError(tx.Error) return false } committed := false defer func() { if !committed { tx.Rollback() } }() for _, email := range emails { ipTimestamps := observed[email] // The observations can still reference a client that was just renamed // or deleted; its email no longer matches any inbound. Skip it (and // drop any orphaned tracking row) instead of recreating a row and // logging an ERROR every run (#4963). The batch map resolves through // the clients relation; the per-email fallback keeps its settings LIKE // net for clients not yet present there. inbound, ok := inboundByEmail[email] if !ok { var err error inbound, err = j.getInboundByEmail(email) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { logger.Debugf("[LimitIP] skipping stale observed email %q (renamed or deleted)", email) j.delInboundClientIps(tx, email) } else { j.checkError(err) } continue } } ipsWithTime := make([]model.ClientIpEntry, 0, len(ipTimestamps)) attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps)) for ip, timestamp := range ipTimestamps { ipsWithTime = append(ipsWithTime, model.ClientIpEntry{IP: ip, Timestamp: timestamp}) // A live connection's lastSeen may be its old start time. // Stamp attribution now so retention cannot evict a connected IP. attrTs := timestamp if observedAreLive { attrTs = now } attrEntries = append(attrEntries, model.ClientIpEntry{IP: ip, Timestamp: attrTs}) } if len(attrEntries) > 0 { attribution[email] = attrEntries } clientIpsRecord, ok := ipRowByEmail[email] if !ok { jsonIps, err := model.MarshalClientIpEntries(ipsWithTime) if err != nil { j.checkError(err) continue } clientIpsRecord = &model.InboundClientIps{ClientEmail: email, Ips: jsonIps} result := tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "client_email"}}, DoNothing: true, }).Create(clientIpsRecord) if result.Error != nil { j.checkError(result.Error) continue } if result.RowsAffected > 0 { continue } if err := tx.Where("client_email = ?", email).First(clientIpsRecord).Error; err != nil { j.checkError(err) continue } } candidates, keptLive := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive) bans = append(bans, pendingBan{inbound: inbound, email: email, candidates: candidates, keptLive: keptLive}) } if err := tx.Commit().Error; err != nil { j.checkError(err) return false } committed = true published := j.publishBans(bans) // Xray disconnects run after the commit so their network round-trips never // extend the scan's write transaction (node syncs upsert the same table). shouldCleanLog = shouldCleanLog || len(published) > 0 clientsCache := make(map[int][]model.Client) for _, d := range published { clients, cached := clientsCache[d.inbound.Id] if !cached { clients, _ = service.ParseInboundSettingsClients(d.inbound.Settings) clientsCache[d.inbound.Id] = clients } j.disconnectClientTemporarily(d.inbound, d.email, clients) } j.recordLocalAttribution(attribution) return shouldCleanLog } // recordLocalAttribution stores this scan's local observations under this panel's // own guid so a parent panel can attribute each IP to the node it is on. // Best-effort: attribution is advisory and must never block IP-limit enforcement. func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model.ClientIpEntry) { if len(attribution) == 0 { return } guid, err := (&service.SettingService{}).GetPanelGuid() if err != nil || guid == "" { return } if err := (&service.InboundService{}).RecordLocalClientIps(guid, attribution); err != nil { logger.Debug("[LimitIP] record local ip attribution failed:", err) } } // mergeClientIps retains fresh entries and exempts currently observed live // connections from expiry even when their dispatch timestamps are old. func mergeClientIps(old, new []model.ClientIpEntry, staleCutoff int64, newAlwaysLive bool) map[string]int64 { ipMap := make(map[string]int64, len(old)+len(new)) for _, ipTime := range old { if ipTime.Timestamp < staleCutoff { continue } ipMap[ipTime.IP] = ipTime.Timestamp } for _, ipTime := range new { if !newAlwaysLive && ipTime.Timestamp < staleCutoff { continue } if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime { ipMap[ipTime.IP] = ipTime.Timestamp } } return ipMap } // selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps) // into the newest `limit` entries to keep and the older remainder to ban. func selectIpsToBan(live []model.ClientIpEntry, limit int) (kept, banned []model.ClientIpEntry) { if limit <= 0 || len(live) <= limit { return live, nil } cutoff := len(live) - limit return live[cutoff:], live[:cutoff] } func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []model.ClientIpEntry) { live = make([]model.ClientIpEntry, 0, len(observedThisScan)) historical = make([]model.ClientIpEntry, 0, len(ipMap)) now := time.Now().Unix() for ip, ts := range ipMap { entry := model.ClientIpEntry{IP: ip, Timestamp: ts} // Recent remote observations count toward the cluster-wide limit. if observedThisScan[ip] || now-ts < 120 { live = append(live, entry) } else { historical = append(historical, entry) } } model.SortClientIpEntries(live) model.SortClientIpEntries(historical) return live, historical } func (j *CheckClientIpJob) checkFail2BanInstalled() bool { if !isFail2BanEnabled() { return false } cmd := "fail2ban-client" args := []string{"-h"} err := exec.CommandContext(context.Background(), cmd, args...).Run() return err == nil } func isFail2BanEnabled() bool { value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN") return !ok || value == "true" } func (j *CheckClientIpJob) checkError(e error) { if e != nil { logger.Warning("client ip job err:", e) } } // delInboundClientIps drops the inbound_client_ips tracking row for an email // that no longer maps to any inbound (a renamed or deleted client), so stale // access-log entries don't keep a ghost row alive (#4963). func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string) { if err := tx.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil { j.checkError(err) } } // updateInboundClientIps returns ban candidates; publication must wait for commit. func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []model.ClientIpEntry, enforce, observedAreLive bool) (banCandidates []model.ClientIpEntry, keptLiveCount int) { if inbound.Settings == "" { logger.Debug("wrong data:", inbound) return nil, 0 } enforceLimit := enforce && limitIp > 0 && inbound.Enable // only ips seen in this scan count toward the limit. see // partitionLiveIps. observedThisScan := make(map[string]bool, len(newIpsWithTime)) for _, ipTime := range newIpsWithTime { observedThisScan[ipTime.IP] = true } staleCutoff := time.Now().Unix() - ipStaleAfterSeconds // Node sync merges into the same blob (#6587): compare-and-set it, and on a // miss re-read and re-merge so neither writer drops the other's IPs. for range service.ClientIpCasRetries { var oldIpsWithTime []model.ClientIpEntry if inboundClientIps.Ips != "" { _ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime) } ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive) dbIps := make([]model.ClientIpEntry, 0, len(ipMap)) var bannedLive []model.ClientIpEntry keptLive := 0 if enforceLimit { liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan) // historical db-only ips are excluded from this count on purpose. limitedIps, allowedIps := j.allowlist.split(liveIps) kept, banned := selectIpsToBan(limitedIps, limitIp) // Allowlisted addresses stay connected and out of the count: charging them // against the limit would still cut the shared network the entry protects. kept = append(kept, allowedIps...) bannedLive = banned keptLive = len(kept) // keep kept-live + historical in the blob so the panel keeps showing recently // seen ips; banned live ips reappear in the next scan if they reconnect. dbIps = append(dbIps, kept...) dbIps = append(dbIps, historicalIps...) } else { // Collection-only run (#6722): no ban selection, keep every IP in the window. for ip, timestamp := range ipMap { dbIps = append(dbIps, model.ClientIpEntry{IP: ip, Timestamp: timestamp}) } } newIps, err := model.MarshalClientIpEntries(dbIps) if err != nil { j.checkError(err) return nil, 0 } if newIps == inboundClientIps.Ips { return bannedLive, keptLive } updated, err := service.CasUpdateInboundClientIps(tx, inboundClientIps.Id, inboundClientIps.Ips, newIps) if err != nil { logger.Error("failed to save inboundClientIps:", err) return nil, 0 } if updated { inboundClientIps.Ips = newIps return bannedLive, keptLive } if err := tx.Where("id = ?", inboundClientIps.Id).First(inboundClientIps).Error; err != nil { logger.Error("failed to re-read inboundClientIps after a concurrent write:", err) return nil, 0 } } logger.Error("failed to save inboundClientIps: exhausted CAS retries") return nil, 0 } // pendingBan carries one client's enforcement outcome from inside the scan's // transaction to the publication that may only follow a successful commit. type pendingBan struct { inbound *model.Inbound email string candidates []model.ClientIpEntry keptLive int } // publishBans returns the clients whose lines reached the log or were withheld as // unproven. bannedSeen advances only for those, so a failed write stays retryable. func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan { published := make([]pendingBan, 0, len(bans)) var logIpFile *os.File defer func() { if logIpFile == nil { return } if err := logIpFile.Close(); err != nil { logger.Errorf("failed to close IP limit log file: %s", err) } }() for _, b := range bans { actionable := j.selectAdvancedSinceLastBan(b.email, b.candidates) if len(actionable) == 0 { j.recordBannedSeen(b.email, b.candidates, nil) continue } // The caller still disconnects the client; only a firewall ban needs an // address the sender has proven to own. if j.sourceCanBeForged(b.email, b.inbound) { j.recordBannedSeen(b.email, b.candidates, actionable) logger.Warningf("[LIMIT_IP] Client %s: %d old IPs not queued for fail2ban, an inbound of the client accepts datagrams whose source can be forged", b.email, len(actionable)) published = append(published, b) continue } if logIpFile == nil { f, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { logger.Errorf("failed to open IP limit log file: %s", err) return published } logIpFile = f } if err := writeBanLines(logIpFile, b.email, actionable); err != nil { logger.Errorf("failed to write IP limit bans for %s: %s", b.email, err) continue } j.recordBannedSeen(b.email, b.candidates, actionable) logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", b.email, b.keptLive, len(actionable)) published = append(published, b) } return published } // sourceCanBeForged checks every enabled inbound the client is attached to: the // online list is per email, and node syncs merge node observations into the row. func (j *CheckClientIpJob) sourceCanBeForged(email string, resolved *model.Inbound) bool { var inbounds []*model.Inbound err := database.GetDB().Model(&model.Inbound{}). Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email = ? AND inbounds.enable = ?", email, true). Find(&inbounds).Error if err != nil { j.checkError(err) return true } // A client not yet in the relation was resolved by getInboundByEmail's fallback. if len(inbounds) == 0 && resolved != nil { inbounds = append(inbounds, resolved) } for _, inbound := range inbounds { if acceptsUnverifiedSources(inbound) { return true } } return false } // acceptsUnverifiedSources: Shadowsocks authenticates each UDP datagram on its own // and mKCP takes the request with the first ones, so the core never answers first. func acceptsUnverifiedSources(inbound *model.Inbound) bool { var stream struct { Method string `json:"method"` Network string `json:"network"` } _ = json.Unmarshal([]byte(inbound.StreamSettings), &stream) // The core reads method as the newer name of network and prefers it. if stream.Method != "" { stream.Network = stream.Method } switch strings.ToLower(stream.Network) { case "kcp", "mkcp": return true } if inbound.Protocol != model.Shadowsocks { return false } var settings struct { Network *conf.NetworkList `json:"network"` } _ = json.Unmarshal([]byte(inbound.Settings), &settings) return xnet.HasNetwork(settings.Network.Build(), xnet.Network_UDP) } // writeBanLines emits one line per address; the wording is load-bearing, since // x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it. func writeBanLines(w io.Writer, clientEmail string, actionable []model.ClientIpEntry) error { stamp := time.Now().Format("2006/01/02 15:04:05") for _, ipTime := range actionable { if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n", stamp, clientEmail, ipTime.IP, ipTime.Timestamp); err != nil { return err } } return nil } // selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core // refreshes it only on a new dispatch, so those are unreaped dead connections. func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []model.ClientIpEntry) []model.ClientIpEntry { actionable := make([]model.ClientIpEntry, 0, len(banned)) for _, ipTime := range banned { if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last { continue } actionable = append(actionable, ipTime) } return actionable } // recordBannedSeen marks published pairs and forgets addresses this scan no // longer bans; it runs for every enforced client, which is what prunes the map. func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []model.ClientIpEntry) { if j.bannedSeen == nil { j.bannedSeen = make(map[string]int64) } for _, ipTime := range published { j.bannedSeen[email+"|"+ipTime.IP] = ipTime.Timestamp } current := make(map[string]struct{}, len(banned)) for _, ipTime := range banned { current[email+"|"+ipTime.IP] = struct{}{} } prefix := email + "|" for key := range j.bannedSeen { if strings.HasPrefix(key, prefix) { if _, still := current[key]; !still { delete(j.bannedSeen, key) } } } } // disconnectClientTemporarily drops a client's credential for a moment, so new // handshakes are refused; the fail2ban ban is what ends live traffic. func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) { var xrayAPI xray.XrayAPI apiPort := j.resolveXrayAPIPort() err := xrayAPI.Init(apiPort) if err != nil { logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err) return } defer xrayAPI.Close() // Find the client config var clientConfig map[string]any var reverseClient bool for _, client := range clients { if client.Email == clientEmail { // Convert client to map for API clientBytes, _ := json.Marshal(client) _ = json.Unmarshal(clientBytes, &clientConfig) reverseClient = client.Reverse != nil break } } if clientConfig == nil { return } // Protocols XrayAPI can remove and re-add from a marshaled model.Client. // wireguard stays out: keepAlive marshals as a number, AddUser wants a string. protocol := string(inbound.Protocol) switch protocol { case "vmess", "vless", "trojan", "shadowsocks", "hysteria", "masque": // supported protocols, continue default: logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag) return } // RemoveUser drops a reverse client's outbound handler and the re-add below // cannot restore it, so its tunnel would stay down until Xray restarts. if reverseClient { logger.Warningf("[LIMIT_IP] Not disconnecting %s: its reverse proxy config does not survive a temporary removal", clientEmail) return } // For Shadowsocks, ensure the required "cipher" field is present by // reading it from the inbound settings (e.g., settings["method"]). if string(inbound.Protocol) == "shadowsocks" { var inboundSettings map[string]any if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil { logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err) } else { if method, ok := inboundSettings["method"].(string); ok && method != "" { clientConfig["cipher"] = method } } } // The core's RemoveUser clears its validator: a session already up keeps // running, except a reverse vless client, which is skipped above. err = xrayAPI.RemoveUser(inbound.Tag, clientEmail) if err != nil { logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err) return } // Nothing is pending here: AlterInbound applies the removal inline, so this // only widens the window in which new handshakes fail. time.Sleep(100 * time.Millisecond) // clientConfig is the scan's snapshot: a disable or delete committed since then // found no user to remove (or restarted the core) and will not come back for it. if !j.clientStillServed(inbound.Id, clientEmail) { logger.Infof("[LIMIT_IP] Not restoring %s: disabled or removed during its temporary disconnect", clientEmail) return } // Re-add user to allow new connections err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig) if err != nil { logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err) return } // Asked again: a commit can land between the check and the re-add. Writers touch // the core only after they commit, so one this check misses removes the user itself. if !j.clientStillServed(inbound.Id, clientEmail) { logger.Infof("[LIMIT_IP] Removing %s again: disabled or removed during its temporary disconnect", clientEmail) if err := xrayAPI.RemoveUser(inbound.Tag, clientEmail); err != nil { logger.Warningf("[LIMIT_IP] Failed to remove user %s again: %v", clientEmail, err) } } } // clientStillServed is GetXrayConfig's rule for handing a client to the core: linked to // the enabled inbound, enabled, not depleted per client_traffics. A failed read is "no". func (j *CheckClientIpJob) clientStillServed(inboundId int, clientEmail string) bool { var served int64 err := database.GetDB().Table("client_inbounds"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Joins("JOIN inbounds ON inbounds.id = client_inbounds.inbound_id"). Where("client_inbounds.inbound_id = ? AND inbounds.enable = ?", inboundId, true). Where("clients.email = ? AND clients.enable = ?", clientEmail, true). Where("NOT EXISTS (SELECT 1 FROM client_traffics WHERE client_traffics.email = clients.email AND client_traffics.enable = ?)", false). Count(&served).Error j.checkError(err) return err == nil && served > 0 } // resolveXrayAPIPort returns the API inbound port from running config, then template config, then default. func (j *CheckClientIpJob) resolveXrayAPIPort() int { var configErr error var templateErr error if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil { return port } else { configErr = err } db := database.GetDB() var template model.Setting if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil { if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil { return port } else { templateErr = parseErr } } else { templateErr = err } logger.Warningf( "[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)", defaultXrayAPIPort, configErr, templateErr, ) return defaultXrayAPIPort } func getAPIPortFromConfigPath(configPath string) (int, error) { configData, err := os.ReadFile(configPath) if err != nil { return 0, err } return getAPIPortFromConfigData(configData) } func getAPIPortFromConfigData(configData []byte) (int, error) { xrayConfig := &xray.Config{} if err := json.Unmarshal(configData, xrayConfig); err != nil { return 0, err } for _, inboundConfig := range xrayConfig.InboundConfigs { if inboundConfig.Tag == "api" && inboundConfig.Port > 0 { return inboundConfig.Port, nil } } return 0, errors.New("api inbound port not found") } // getInboundByEmail resolves the inbound that owns a client email. It prefers // the exact clients/client_inbounds relation; a substring "settings LIKE // %email%" can match the wrong inbound (an email that is a substring of another, // or text that merely appears elsewhere in the settings JSON). The LIKE + JSON // scan stays only as a fallback for clients not yet present in the relation, so // nothing regresses when the join finds no row. func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) { db := database.GetDB() inbound := &model.Inbound{} err := db.Model(&model.Inbound{}). Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id"). Joins("JOIN clients ON clients.id = client_inbounds.client_id"). Where("clients.email = ?", clientEmail). First(inbound).Error if err == nil { return inbound, nil } var candidates []model.Inbound if listErr := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").Find(&candidates).Error; listErr != nil { return nil, listErr } for i := range candidates { clients, jsonErr := service.ParseInboundSettingsClients(candidates[i].Settings) if jsonErr != nil { continue } for _, client := range clients { if client.Email == clientEmail { return &candidates[i], nil } } } return nil, err } // Runs before the apiMode gate: retention must hold for stored rows even // while nothing is being collected. func (j *CheckClientIpJob) pruneStaleIpRows() { now := time.Now().Unix() if now-j.lastIpPrune < ipPruneIntervalSeconds { return } j.lastIpPrune = now if err := (&service.InboundService{}).PruneStaleClientIps(); err != nil { logger.Warning("prune stale client ip rows failed:", err) } }