|
@@ -35,23 +35,6 @@ var job *CheckClientIpJob
|
|
|
|
|
|
|
|
const defaultXrayAPIPort = 62789
|
|
const defaultXrayAPIPort = 62789
|
|
|
|
|
|
|
|
-// ipStaleAfterSeconds controls how long a client IP kept in the
|
|
|
|
|
-// per-client tracking table (model.InboundClientIps.Ips) is considered
|
|
|
|
|
-// still "active" before it's evicted during the next scan.
|
|
|
|
|
-//
|
|
|
|
|
-// Without this eviction, an IP that connected once and then went away
|
|
|
|
|
-// keeps sitting in the table with its old timestamp. Because the
|
|
|
|
|
-// excess-IP selector sorts ascending ("newest wins, oldest loses") to
|
|
|
|
|
-// protect the most recent connections, that stale entry keeps
|
|
|
|
|
-// occupying a slot and the IP that is *actually* currently using the
|
|
|
|
|
-// config gets classified as "new excess" and banned by fail2ban on
|
|
|
|
|
-// every single run — producing the continuous ban loop from #4077.
|
|
|
|
|
-//
|
|
|
|
|
-// 30 minutes is chosen so an actively-streaming client (where xray
|
|
|
|
|
-// emits a fresh `accepted` log line whenever it opens a new TCP) will
|
|
|
|
|
-// always refresh its timestamp well within the window, but a client
|
|
|
|
|
-// that has really stopped using the config will drop out of the table
|
|
|
|
|
-// in a bounded time and free its slot.
|
|
|
|
|
const ipStaleAfterSeconds = int64(30 * 60)
|
|
const ipStaleAfterSeconds = int64(30 * 60)
|
|
|
|
|
|
|
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
|
// NewCheckClientIpJob creates a new client IP monitoring job instance.
|
|
@@ -67,27 +50,20 @@ func (j *CheckClientIpJob) Run() {
|
|
|
|
|
|
|
|
shouldClearAccessLog := false
|
|
shouldClearAccessLog := false
|
|
|
fail2BanEnabled := isFail2BanEnabled()
|
|
fail2BanEnabled := isFail2BanEnabled()
|
|
|
- iplimitActive := fail2BanEnabled && j.hasLimitIp()
|
|
|
|
|
|
|
+ hasLimit := fail2BanEnabled && j.hasLimitIp()
|
|
|
f2bInstalled := false
|
|
f2bInstalled := false
|
|
|
- if iplimitActive {
|
|
|
|
|
|
|
+ if hasLimit {
|
|
|
f2bInstalled = j.checkFail2BanInstalled()
|
|
f2bInstalled = j.checkFail2BanInstalled()
|
|
|
}
|
|
}
|
|
|
- isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
|
|
|
|
|
|
|
+ isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
|
|
|
|
|
|
|
|
- if isAccessLogAvailable {
|
|
|
|
|
- if runtime.GOOS == "windows" {
|
|
|
|
|
- if iplimitActive {
|
|
|
|
|
- shouldClearAccessLog = j.processLogFile()
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- if iplimitActive {
|
|
|
|
|
- if f2bInstalled {
|
|
|
|
|
- shouldClearAccessLog = j.processLogFile()
|
|
|
|
|
- } else {
|
|
|
|
|
- logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if fail2BanEnabled && isAccessLogAvailable {
|
|
|
|
|
+ enforce := hasLimit
|
|
|
|
|
+ if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
|
|
|
|
|
+ logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
|
|
|
|
|
+ enforce = false
|
|
|
}
|
|
}
|
|
|
|
|
+ shouldClearAccessLog = j.processLogFile(enforce)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
|
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
|
@@ -145,7 +121,7 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func (j *CheckClientIpJob) processLogFile() bool {
|
|
|
|
|
|
|
+func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|
|
|
|
|
|
|
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
|
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
|
|
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
|
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
|
@@ -220,18 +196,12 @@ func (j *CheckClientIpJob) processLogFile() bool {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
|
|
|
|
|
|
|
+ shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return shouldCleanLog
|
|
return shouldCleanLog
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// mergeClientIps combines the persisted (old) and freshly observed (new)
|
|
|
|
|
-// IP-with-timestamp lists for a single client into a map. An entry is
|
|
|
|
|
-// dropped if its last-seen timestamp is older than staleCutoff.
|
|
|
|
|
-//
|
|
|
|
|
-// Extracted as a helper so updateInboundClientIps can stay DB-oriented
|
|
|
|
|
-// and the merge policy can be exercised by a unit test.
|
|
|
|
|
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
|
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
|
|
|
ipMap := make(map[string]int64, len(old)+len(new))
|
|
ipMap := make(map[string]int64, len(old)+len(new))
|
|
|
for _, ipTime := range old {
|
|
for _, ipTime := range old {
|
|
@@ -251,19 +221,6 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
|
|
return ipMap
|
|
return ipMap
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// partitionLiveIps splits the merged ip map into live (seen in the
|
|
|
|
|
-// current scan) and historical (only in the db blob, still inside the
|
|
|
|
|
-// staleness window).
|
|
|
|
|
-//
|
|
|
|
|
-// only live ips count toward the per-client limit. historical ones stay
|
|
|
|
|
-// in the db so the panel keeps showing them, but they must not take a
|
|
|
|
|
-// live slot or get re-banned. the 30min cutoff alone isn't tight enough:
|
|
|
|
|
-// an ip that stopped connecting a few minutes ago still looks fresh to
|
|
|
|
|
-// mergeClientIps, and without this split it would keep triggering
|
|
|
|
|
-// fail2ban even though it isn't currently connected. see #4077 / #4091.
|
|
|
|
|
-//
|
|
|
|
|
-// live is sorted ascending by timestamp (oldest → newest), so we keep
|
|
|
|
|
-// the most recent entries at the end of the slice (last IP wins).
|
|
|
|
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
|
|
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
|
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
|
|
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
|
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
|
@@ -354,7 +311,7 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
|
|
|
|
|
|
|
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
|
|
|
// Get the inbound configuration
|
|
// Get the inbound configuration
|
|
|
inbound, err := j.getInboundByEmail(clientEmail)
|
|
inbound, err := j.getInboundByEmail(clientEmail)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
@@ -382,8 +339,9 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- if !clientFound || limitIp <= 0 || !inbound.Enable {
|
|
|
|
|
- // No limit or inbound disabled, just update and return
|
|
|
|
|
|
|
+ if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
|
|
|
|
|
+ // Nothing to enforce (collection-only run, no limit, client missing, or
|
|
|
|
|
+ // inbound disabled): record the observed IPs for the panel and return.
|
|
|
jsonIps, _ := json.Marshal(newIpsWithTime)
|
|
jsonIps, _ := json.Marshal(newIpsWithTime)
|
|
|
inboundClientIps.Ips = string(jsonIps)
|
|
inboundClientIps.Ips = string(jsonIps)
|
|
|
db := database.GetDB()
|
|
db := database.GetDB()
|
|
@@ -397,8 +355,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|
|
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
|
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Merge old and new IPs, evicting entries that haven't been
|
|
|
|
|
- // re-observed in a while. See mergeClientIps / #4077 for why.
|
|
|
|
|
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
|
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
|
|
|
|
|
|
|
|
// only ips seen in this scan count toward the limit. see
|
|
// only ips seen in this scan count toward the limit. see
|
|
@@ -422,10 +378,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|
|
keptLive = liveIps[cutoff:]
|
|
keptLive = liveIps[cutoff:]
|
|
|
bannedLive := liveIps[:cutoff]
|
|
bannedLive := liveIps[:cutoff]
|
|
|
|
|
|
|
|
- // Open log file only when a ban entry needs to be written.
|
|
|
|
|
- // Use a local logger to avoid mutating the global log.* state,
|
|
|
|
|
- // which would redirect all standard-library logging to this file
|
|
|
|
|
- // and leave a dangling closed-file handle after the defer fires.
|
|
|
|
|
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
logger.Errorf("failed to open IP limit log file: %s", err)
|
|
logger.Errorf("failed to open IP limit log file: %s", err)
|