mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +00:00
refactor(job): drop access log from IP limiting, wipe it daily instead
The IP-limit job tracks per-client IPs via the core's online-stats API; the access-log parser only ran as a fallback for cores predating that API (which the panel never bundles). Remove the parser, the availability check, and the hourly rotation that truncated a log the job no longer reads.
Move the user-enabled access-log wipe to the daily clear-logs job, guarded so a disabled ('none') or missing log is left alone. Retire the now-unwritten 3xipl-ap persistent-log machinery.
Also resolve IP-limit clients via the exact clients/client_inbounds relation instead of a fragile settings LIKE '%email%' substring, keeping the JSON scan only as a fallback (carried from #5496).
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,10 +27,9 @@ type IPWithTimestamp struct {
|
|||||||
|
|
||||||
// CheckClientIpJob monitors client IP addresses and manages IP blocking based
|
// CheckClientIpJob monitors client IP addresses and manages IP blocking based
|
||||||
// on configured limits. The per-client IPs come from the core's online-stats
|
// on configured limits. The per-client IPs come from the core's online-stats
|
||||||
// API when the running core supports it (no access log needed), falling back
|
// API; no access log is involved. On a core too old to expose that API the job
|
||||||
// to access-log parsing on older cores.
|
// simply skips the run (the bundled core always supports it).
|
||||||
type CheckClientIpJob struct {
|
type CheckClientIpJob struct {
|
||||||
lastClear int64
|
|
||||||
disAllowedIps []string
|
disAllowedIps []string
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
}
|
}
|
||||||
@@ -51,41 +47,24 @@ func NewCheckClientIpJob() *CheckClientIpJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) Run() {
|
func (j *CheckClientIpJob) Run() {
|
||||||
if j.lastClear == 0 {
|
observed, apiMode := j.collectFromOnlineAPI()
|
||||||
j.lastClear = time.Now().Unix()
|
if !apiMode {
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
fail2BanEnabled := isFail2BanEnabled()
|
if !isFail2BanEnabled() {
|
||||||
hasLimit := fail2BanEnabled && j.hasLimitIp()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLimit := j.hasLimitIp()
|
||||||
f2bInstalled := false
|
f2bInstalled := false
|
||||||
if hasLimit {
|
if hasLimit {
|
||||||
f2bInstalled = j.checkFail2BanInstalled()
|
f2bInstalled = j.checkFail2BanInstalled()
|
||||||
}
|
}
|
||||||
|
j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
|
||||||
if observed, apiMode := j.collectFromOnlineAPI(); apiMode {
|
|
||||||
if fail2BanEnabled {
|
|
||||||
j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
|
|
||||||
}
|
|
||||||
// The core tracks online IPs itself, so no access log is needed in this
|
|
||||||
// mode; still rotate a user-configured access log hourly so it doesn't
|
|
||||||
// grow unboundedly. The enforcement-triggered rotation is skipped —
|
|
||||||
// nothing here reads the log.
|
|
||||||
if j.checkAccessLogAvailable(false) && time.Now().Unix()-j.lastClear > 3600 {
|
|
||||||
j.clearAccessLog()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldClearAccessLog := false
|
|
||||||
isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
|
|
||||||
|
|
||||||
if fail2BanEnabled && isAccessLogAvailable {
|
|
||||||
shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
|
|
||||||
j.clearAccessLog()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveEnforce decides whether limits can actually be enforced this run.
|
// resolveEnforce decides whether limits can actually be enforced this run.
|
||||||
@@ -102,7 +81,7 @@ func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
|
|||||||
// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
|
// collectFromOnlineAPI builds per-email IP observations (email -> ip ->
|
||||||
// last-seen unix seconds) from the core's online-stats API. ok=false means the
|
// 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
|
// API is unavailable — xray not running, an older core, or a transient gRPC
|
||||||
// failure — and the caller must fall back to access-log parsing.
|
// failure — and the caller skips the run (there is no access-log fallback).
|
||||||
func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
|
func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64, bool) {
|
||||||
onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
|
onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,27 +112,6 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
|
|||||||
return observed, true
|
return observed, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) clearAccessLog() {
|
|
||||||
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
||||||
j.checkError(err)
|
|
||||||
defer logAccessP.Close()
|
|
||||||
|
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
|
||||||
j.checkError(err)
|
|
||||||
|
|
||||||
file, err := os.Open(accessLogPath)
|
|
||||||
j.checkError(err)
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(logAccessP, file)
|
|
||||||
j.checkError(err)
|
|
||||||
|
|
||||||
err = os.Truncate(accessLogPath, 0)
|
|
||||||
j.checkError(err)
|
|
||||||
|
|
||||||
j.lastClear = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) hasLimitIp() bool {
|
func (j *CheckClientIpJob) hasLimitIp() bool {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var inbounds []*model.Inbound
|
var inbounds []*model.Inbound
|
||||||
@@ -183,74 +141,11 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
|
|
||||||
|
|
||||||
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
|
|
||||||
emailRegex := regexp.MustCompile(`email: (.+)$`)
|
|
||||||
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
|
|
||||||
|
|
||||||
accessLogPath, _ := xray.GetAccessLogPath()
|
|
||||||
file, _ := os.Open(accessLogPath)
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
// Track IPs with their last seen timestamp
|
|
||||||
inboundClientIps := make(map[string]map[string]int64, 100)
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
|
|
||||||
ipMatches := ipRegex.FindStringSubmatch(line)
|
|
||||||
if len(ipMatches) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := ipMatches[1]
|
|
||||||
|
|
||||||
if ip == "127.0.0.1" || ip == "::1" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
emailMatches := emailRegex.FindStringSubmatch(line)
|
|
||||||
if len(emailMatches) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
email := emailMatches[1]
|
|
||||||
|
|
||||||
// Extract timestamp from log line
|
|
||||||
var timestamp int64
|
|
||||||
timestampMatches := timestampRegex.FindStringSubmatch(line)
|
|
||||||
if len(timestampMatches) >= 2 {
|
|
||||||
t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
|
|
||||||
if err == nil {
|
|
||||||
timestamp = t.Unix()
|
|
||||||
} else {
|
|
||||||
timestamp = time.Now().Unix()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
timestamp = time.Now().Unix()
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := inboundClientIps[email]; !exists {
|
|
||||||
inboundClientIps[email] = make(map[string]int64)
|
|
||||||
}
|
|
||||||
// Update timestamp - keep the latest
|
|
||||||
if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
|
|
||||||
inboundClientIps[email][ip] = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
j.checkError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return j.processObserved(inboundClientIps, enforce, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// processObserved runs collection + enforcement for one scan's observations
|
// processObserved runs collection + enforcement for one scan's observations
|
||||||
// (email -> ip -> last-seen unix seconds). observedAreLive marks the
|
// (email -> ip -> last-seen unix seconds). observedAreLive marks the
|
||||||
// observations as live connections (online-stats API) rather than recent log
|
// observations as live connections, which bypass the stale cutoff: a connection
|
||||||
// lines: live entries bypass the stale cutoff, since a connection that opened
|
// that opened hours ago is still live even though its timestamp is old. The
|
||||||
// hours ago is still live even though its timestamp is old.
|
// online-stats API always reports live connections, so the job passes true.
|
||||||
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
@@ -391,22 +286,6 @@ func isFail2BanEnabled() bool {
|
|||||||
return !ok || value == "true"
|
return !ok || value == "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
|
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if accessLogPath == "none" || accessLogPath == "" {
|
|
||||||
if iplimitActive {
|
|
||||||
logger.Warning("[LimitIP] Access log path is not set, Please configure the access log path in Xray configs.")
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) checkError(e error) {
|
func (j *CheckClientIpJob) checkError(e error) {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
logger.Warning("client ip job err:", e)
|
logger.Warning("client ip job err:", e)
|
||||||
@@ -682,14 +561,40 @@ func getAPIPortFromConfigData(configData []byte) (int, error) {
|
|||||||
return 0, errors.New("api inbound port not found")
|
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) {
|
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|
||||||
err := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").First(inbound).Error
|
err := db.Model(&model.Inbound{}).
|
||||||
if err != nil {
|
Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
|
||||||
return nil, err
|
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
|
||||||
|
Where("clients.email = ?", clientEmail).
|
||||||
|
First(inbound).Error
|
||||||
|
if err == nil {
|
||||||
|
return inbound, 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 {
|
||||||
|
settings := map[string][]model.Client{}
|
||||||
|
if jsonErr := json.Unmarshal([]byte(candidates[i].Settings), &settings); jsonErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, client := range settings["clients"] {
|
||||||
|
if client.Email == clientEmail {
|
||||||
|
return &candidates[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ func setupIntegrationDB(t *testing.T) {
|
|||||||
// seed an inbound whose settings json has a single client with the
|
// seed an inbound whose settings json has a single client with the
|
||||||
// given email and ip limit.
|
// given email and ip limit.
|
||||||
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
||||||
|
t.Helper()
|
||||||
|
seedInboundOnlyWithClient(t, tag, email, limitIp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
settings := map[string]any{
|
settings := map[string]any{
|
||||||
"clients": []map[string]any{
|
"clients": []map[string]any{
|
||||||
@@ -83,6 +88,21 @@ func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
|||||||
if err := database.GetDB().Create(inbound).Error; err != nil {
|
if err := database.GetDB().Create(inbound).Error; err != nil {
|
||||||
t.Fatalf("seed inbound: %v", err)
|
t.Fatalf("seed inbound: %v", err)
|
||||||
}
|
}
|
||||||
|
return inbound
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
||||||
|
t.Helper()
|
||||||
|
inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
|
||||||
|
client := &model.ClientRecord{Email: email}
|
||||||
|
if err := database.GetDB().Create(client).Error; err != nil {
|
||||||
|
t.Fatalf("seed client record: %v", err)
|
||||||
|
}
|
||||||
|
link := &model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}
|
||||||
|
if err := database.GetDB().Create(link).Error; err != nil {
|
||||||
|
t.Fatalf("seed client inbound link: %v", err)
|
||||||
|
}
|
||||||
|
return inbound
|
||||||
}
|
}
|
||||||
|
|
||||||
// seed an InboundClientIps row with the given blob.
|
// seed an InboundClientIps row with the given blob.
|
||||||
@@ -128,46 +148,32 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
|
// With the access-log fallback removed, an unavailable online-stats API (xray
|
||||||
|
// down, as in this unit test) must make Run a clean no-op: no fail2ban probe, no
|
||||||
|
// ban log, and no inbound_client_ips rows — never a crash or partial work.
|
||||||
|
func TestRun_NoOpWhenOnlineApiUnavailable(t *testing.T) {
|
||||||
setupIntegrationDB(t)
|
setupIntegrationDB(t)
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
||||||
marker := fakeFail2BanClient(t)
|
marker := fakeFail2BanClient(t)
|
||||||
|
|
||||||
const email = "disabled-fail2ban"
|
const email = "no-api-user"
|
||||||
seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
|
seedInboundWithClient(t, "inbound-no-api", email, 1)
|
||||||
|
|
||||||
binDir := t.TempDir()
|
NewCheckClientIpJob().Run()
|
||||||
accessLog := filepath.Join(t.TempDir(), "access.log")
|
|
||||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
|
||||||
configData, err := json.Marshal(map[string]any{
|
|
||||||
"log": map[string]any{"access": accessLog},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal xray config: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
|
||||||
t.Fatalf("write xray config: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
|
|
||||||
t.Fatalf("write access log: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
|
||||||
j.Run()
|
|
||||||
|
|
||||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||||
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
t.Fatalf("fail2ban-client should not have been probed when the online API is unavailable, stat error: %v", err)
|
||||||
}
|
}
|
||||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
||||||
body, _ := os.ReadFile(readIpLimitLogPath())
|
body, _ := os.ReadFile(readIpLimitLogPath())
|
||||||
t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
|
t.Fatalf("3xipl.log should be empty when Run no-ops, got:\n%s", body)
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
|
||||||
t.Fatalf("count InboundClientIps: %v", err)
|
t.Fatalf("count InboundClientIps: %v", err)
|
||||||
}
|
}
|
||||||
if count != 0 {
|
if count != 0 {
|
||||||
t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
|
t.Fatalf("no IP-limit rows should be persisted when Run no-ops, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,47 +286,24 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeXrayAccessLog points bin/config.json at a fresh access.log holding a
|
// #4800: per-client IP tracking must populate even when no client has an IP
|
||||||
// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
|
// limit. processObserved records observed IPs for the panel regardless of any
|
||||||
// for the given client, so Run() has something to scrape.
|
// limit; only enforcement is gated, so a limit-free install still shows IPs. No
|
||||||
func writeXrayAccessLog(t *testing.T, email, ip string) {
|
// ban may be written since there's no limit.
|
||||||
t.Helper()
|
func TestProcessObserved_CollectsIpsWithoutLimit(t *testing.T) {
|
||||||
binDir := t.TempDir()
|
|
||||||
accessLog := filepath.Join(t.TempDir(), "access.log")
|
|
||||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
|
||||||
configData, err := json.Marshal(map[string]any{
|
|
||||||
"log": map[string]any{"access": accessLog},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal xray config: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
|
||||||
t.Fatalf("write xray config: %v", err)
|
|
||||||
}
|
|
||||||
line := "2026/06/02 13:35:53 from tcp:" + ip + ":2387 accepted tcp:example.com:443 email: " + email + "\n"
|
|
||||||
if err := os.WriteFile(accessLog, []byte(line), 0644); err != nil {
|
|
||||||
t.Fatalf("write access log: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// #4800: the per-client IP log must populate even when no client has an IP
|
|
||||||
// limit. Before the fix, Run() only scraped the access log when an IP limit
|
|
||||||
// was active, so a limit-free install always showed an empty IP log despite
|
|
||||||
// valid access-log lines. No ban may be written since there's no limit.
|
|
||||||
func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
setupIntegrationDB(t)
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
||||||
fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
const email = "no-limit-user"
|
const email = "no-limit-user"
|
||||||
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
|
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
|
||||||
writeXrayAccessLog(t, email, "203.0.113.10")
|
|
||||||
|
|
||||||
NewCheckClientIpJob().Run()
|
observed := map[string]map[string]int64{
|
||||||
|
email: {"203.0.113.10": time.Now().Unix()},
|
||||||
|
}
|
||||||
|
NewCheckClientIpJob().processObserved(observed, true, true)
|
||||||
|
|
||||||
ips := readClientIps(t, email)
|
ips := readClientIps(t, email)
|
||||||
if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
|
if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
|
||||||
t.Fatalf("expected the access-log IP to be collected without a limit, got %v", ips)
|
t.Fatalf("expected the observed IP to be collected without a limit, got %v", ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
||||||
@@ -329,22 +312,21 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #4963: a stale access-log entry for a renamed/deleted client (its email no
|
// #4963: an observed IP for a renamed/deleted client (its email no longer maps
|
||||||
// longer maps to any inbound) must not create or resurrect an
|
// to any inbound) must not create or resurrect an inbound_client_ips row, and
|
||||||
// inbound_client_ips row, and must drop any orphan left behind — instead of
|
// must drop any orphan left behind — instead of erroring every run.
|
||||||
// spamming "failed to fetch inbound settings" every run.
|
func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
||||||
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
|
||||||
setupIntegrationDB(t)
|
setupIntegrationDB(t)
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
||||||
fakeFail2BanClient(t)
|
|
||||||
|
|
||||||
const staleEmail = "renamed-away"
|
const staleEmail = "renamed-away"
|
||||||
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
||||||
// confirm the job removes it rather than leaving it to error forever.
|
// confirm the job removes it rather than leaving it to error forever.
|
||||||
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||||
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
|
|
||||||
|
|
||||||
NewCheckClientIpJob().Run()
|
observed := map[string]map[string]int64{
|
||||||
|
staleEmail: {"203.0.113.5": time.Now().Unix()},
|
||||||
|
}
|
||||||
|
NewCheckClientIpJob().processObserved(observed, true, true)
|
||||||
|
|
||||||
var count int64
|
var count int64
|
||||||
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
||||||
@@ -375,3 +357,33 @@ func contains(haystack, needle string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the exact clients/client_inbounds relation must win over the substring scan,
|
||||||
|
// so a client is resolved to its own inbound even when another inbound holds a
|
||||||
|
// superstring email.
|
||||||
|
func TestGetInboundByEmailUsesClientInboundLink(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
|
want := seedLinkedInboundWithClient(t, "linked-inbound", "exact@example.com", 1)
|
||||||
|
seedInboundOnlyWithClient(t, "other-inbound", "not-exact@example.com", 1)
|
||||||
|
|
||||||
|
got, err := (&CheckClientIpJob{}).getInboundByEmail("exact@example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getInboundByEmail returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.Id != want.Id {
|
||||||
|
t.Fatalf("getInboundByEmail returned inbound %d, want %d", got.Id, want.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the substring fallback must still verify the exact email inside settings, so
|
||||||
|
// "ann@example.com" does not match an inbound holding "joann@example.com".
|
||||||
|
func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
|
seedInboundOnlyWithClient(t, "substring-only", "joann@example.com", 1)
|
||||||
|
|
||||||
|
if got, err := (&CheckClientIpJob{}).getInboundByEmail("ann@example.com"); err == nil {
|
||||||
|
t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ func ensureFileExists(path string) error {
|
|||||||
|
|
||||||
// Here Run is an interface method of the Job interface
|
// Here Run is an interface method of the Job interface
|
||||||
func (j *ClearLogsJob) Run() {
|
func (j *ClearLogsJob) Run() {
|
||||||
logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath(), xray.GetAccessPersistentLogPath()}
|
logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath()}
|
||||||
logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath(), xray.GetAccessPersistentPrevLogPath()}
|
logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath()}
|
||||||
|
|
||||||
// Ensure all log files and their paths exist
|
// Ensure all log files and their paths exist
|
||||||
for _, path := range append(logFiles, logFilesPrev...) {
|
for _, path := range append(logFiles, logFilesPrev...) {
|
||||||
@@ -75,4 +75,20 @@ func (j *ClearLogsJob) Run() {
|
|||||||
logger.Warning("Failed to truncate log file:", logFiles[i], "-", err)
|
logger.Warning("Failed to truncate log file:", logFiles[i], "-", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wipeAccessLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// wipeAccessLog truncates the user-configured Xray access log so it can't grow
|
||||||
|
// unbounded. The IP-limit job no longer reads or rotates it, so this daily wipe
|
||||||
|
// is the only thing that caps it. A disabled ("none") or unset access log is
|
||||||
|
// left alone, and a missing file is fine — there's nothing to wipe.
|
||||||
|
func wipeAccessLog() {
|
||||||
|
accessLogPath, err := xray.GetAccessLogPath()
|
||||||
|
if err != nil || accessLogPath == "none" || accessLogPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Truncate(accessLogPath, 0); err != nil && !os.IsNotExist(err) {
|
||||||
|
logger.Warning("Failed to truncate access log:", accessLogPath, "-", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeAccessLogConfig points bin/config.json at the given access log path (use
|
||||||
|
// "none" to disable), so GetAccessLogPath resolves it the way the job does.
|
||||||
|
func writeAccessLogConfig(t *testing.T, accessPath string) {
|
||||||
|
t.Helper()
|
||||||
|
binDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||||
|
configData, err := json.Marshal(map[string]any{
|
||||||
|
"log": map[string]any{"access": accessPath},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal xray config: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
||||||
|
t.Fatalf("write xray config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWipeAccessLog_TruncatesEnabledLog(t *testing.T) {
|
||||||
|
accessLog := filepath.Join(t.TempDir(), "access.log")
|
||||||
|
if err := os.WriteFile(accessLog, []byte("2026/06/23 12:00:00 from tcp:203.0.113.10:443 accepted\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("seed access log: %v", err)
|
||||||
|
}
|
||||||
|
writeAccessLogConfig(t, accessLog)
|
||||||
|
|
||||||
|
wipeAccessLog()
|
||||||
|
|
||||||
|
info, err := os.Stat(accessLog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("access log should still exist: %v", err)
|
||||||
|
}
|
||||||
|
if info.Size() != 0 {
|
||||||
|
t.Fatalf("access log should be truncated to 0, got %d bytes", info.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWipeAccessLog_LeavesDisabledLogAlone(t *testing.T) {
|
||||||
|
writeAccessLogConfig(t, "none")
|
||||||
|
|
||||||
|
// Must not panic or create a file literally named "none".
|
||||||
|
wipeAccessLog()
|
||||||
|
|
||||||
|
if _, err := os.Stat("none"); err == nil {
|
||||||
|
os.Remove("none")
|
||||||
|
t.Fatal(`wipeAccessLog must not create a file named "none"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,16 +65,6 @@ func GetIPLimitBannedPrevLogPath() string {
|
|||||||
return config.GetLogFolder() + "/3xipl-banned.prev.log"
|
return config.GetLogFolder() + "/3xipl-banned.prev.log"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessPersistentLogPath returns the path to the persistent access log file.
|
|
||||||
func GetAccessPersistentLogPath() string {
|
|
||||||
return config.GetLogFolder() + "/3xipl-ap.log"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAccessPersistentPrevLogPath returns the path to the previous persistent access log file.
|
|
||||||
func GetAccessPersistentPrevLogPath() string {
|
|
||||||
return config.GetLogFolder() + "/3xipl-ap.prev.log"
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
||||||
func GetAccessLogPath() (string, error) {
|
func GetAccessLogPath() (string, error) {
|
||||||
config, err := os.ReadFile(GetConfigPath())
|
config, err := os.ReadFile(GetConfigPath())
|
||||||
|
|||||||
Reference in New Issue
Block a user