Prechádzať zdrojové kódy

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).
MHSanaei 1 deň pred
rodič
commit
42cd351e4e

+ 48 - 143
internal/web/job/check_client_ip_job.go

@@ -1,14 +1,11 @@
 package job
 
 import (
-	"bufio"
 	"encoding/json"
 	"errors"
-	"io"
 	"log"
 	"os"
 	"os/exec"
-	"regexp"
 	"runtime"
 	"sort"
 	"time"
@@ -30,10 +27,9 @@ type IPWithTimestamp struct {
 
 // 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 when the running core supports it (no access log needed), falling back
-// to access-log parsing on older cores.
+// 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 {
-	lastClear     int64
 	disAllowedIps []string
 	xrayService   service.XrayService
 }
@@ -51,41 +47,24 @@ func NewCheckClientIpJob() *CheckClientIpJob {
 }
 
 func (j *CheckClientIpJob) Run() {
-	if j.lastClear == 0 {
-		j.lastClear = time.Now().Unix()
-	}
-
-	fail2BanEnabled := isFail2BanEnabled()
-	hasLimit := fail2BanEnabled && j.hasLimitIp()
-	f2bInstalled := false
-	if hasLimit {
-		f2bInstalled = j.checkFail2BanInstalled()
-	}
-
-	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()
-		}
+	observed, apiMode := j.collectFromOnlineAPI()
+	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
 	}
 
-	shouldClearAccessLog := false
-	isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
-
-	if fail2BanEnabled && isAccessLogAvailable {
-		shouldClearAccessLog = j.processLogFile(j.resolveEnforce(hasLimit, f2bInstalled))
+	if !isFail2BanEnabled() {
+		return
 	}
 
-	if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
-		j.clearAccessLog()
+	hasLimit := j.hasLimitIp()
+	f2bInstalled := false
+	if hasLimit {
+		f2bInstalled = j.checkFail2BanInstalled()
 	}
+	j.processObserved(observed, j.resolveEnforce(hasLimit, f2bInstalled), true)
 }
 
 // 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 ->
 // 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 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) {
 	onlineUsers, ok, err := j.xrayService.GetOnlineUsers()
 	if err != nil {
@@ -133,27 +112,6 @@ func (j *CheckClientIpJob) collectFromOnlineAPI() (map[string]map[string]int64,
 	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 {
 	db := database.GetDB()
 	var inbounds []*model.Inbound
@@ -183,74 +141,11 @@ func (j *CheckClientIpJob) hasLimitIp() bool {
 	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
 // (email -> ip -> last-seen unix seconds). observedAreLive marks the
-// observations as live connections (online-stats API) rather than recent log
-// lines: live entries bypass the stale cutoff, since a connection that opened
-// hours ago is still live even though its timestamp is old.
+// 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.
 func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64, enforce, observedAreLive bool) bool {
 	shouldCleanLog := false
 	now := time.Now().Unix()
@@ -391,22 +286,6 @@ func isFail2BanEnabled() bool {
 	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) {
 	if e != nil {
 		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")
 }
 
+// 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{}).Where("settings LIKE ?", "%"+clientEmail+"%").First(inbound).Error
-	if err != nil {
-		return nil, err
+	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 {
+		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 inbound, nil
+	return nil, err
 }

+ 79 - 67
internal/web/job/check_client_ip_job_integration_test.go

@@ -59,6 +59,11 @@ func setupIntegrationDB(t *testing.T) {
 // seed an inbound whose settings json has a single client with the
 // given email and ip limit.
 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()
 	settings := 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 {
 		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.
@@ -128,46 +148,32 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
 	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)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
+	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
 	marker := fakeFail2BanClient(t)
 
-	const email = "disabled-fail2ban"
-	seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
+	const email = "no-api-user"
+	seedInboundWithClient(t, "inbound-no-api", email, 1)
 
-	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)
-	}
-	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()
+	NewCheckClientIpJob().Run()
 
 	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 {
 		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
 	if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
 		t.Fatalf("count InboundClientIps: %v", err)
 	}
 	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
-// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
-// for the given client, so Run() has something to scrape.
-func writeXrayAccessLog(t *testing.T, email, ip string) {
-	t.Helper()
-	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) {
+// #4800: per-client IP tracking must populate even when no client has an IP
+// limit. processObserved records observed IPs for the panel regardless of any
+// limit; only enforcement is gated, so a limit-free install still shows IPs. No
+// ban may be written since there's no limit.
+func TestProcessObserved_CollectsIpsWithoutLimit(t *testing.T) {
 	setupIntegrationDB(t)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
-	fakeFail2BanClient(t)
 
 	const email = "no-limit-user"
 	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)
 	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 {
@@ -329,22 +312,21 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
 	}
 }
 
-// #4963: a stale access-log entry for a renamed/deleted client (its email no
-// longer maps to any inbound) must not create or resurrect an
-// inbound_client_ips row, and must drop any orphan left behind — instead of
-// spamming "failed to fetch inbound settings" every run.
-func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
+// #4963: an observed IP for a renamed/deleted client (its email no longer maps
+// to any inbound) must not create or resurrect an inbound_client_ips row, and
+// must drop any orphan left behind — instead of erroring every run.
+func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
 	setupIntegrationDB(t)
-	t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
-	fakeFail2BanClient(t)
 
 	const staleEmail = "renamed-away"
 	// No inbound references staleEmail. Pre-seed an orphan tracking row to
 	// 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()}})
-	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
 	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
 }
+
+// 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", "[email protected]", 1)
+	seedInboundOnlyWithClient(t, "other-inbound", "[email protected]", 1)
+
+	got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]")
+	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
+// "[email protected]" does not match an inbound holding "[email protected]".
+func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
+	setupIntegrationDB(t)
+
+	seedInboundOnlyWithClient(t, "substring-only", "[email protected]", 1)
+
+	if got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]"); err == nil {
+		t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
+	}
+}

+ 18 - 2
internal/web/job/clear_logs_job.go

@@ -34,8 +34,8 @@ func ensureFileExists(path string) error {
 
 // Here Run is an interface method of the Job interface
 func (j *ClearLogsJob) Run() {
-	logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath(), xray.GetAccessPersistentLogPath()}
-	logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath(), xray.GetAccessPersistentPrevLogPath()}
+	logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath()}
+	logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath()}
 
 	// Ensure all log files and their paths exist
 	for _, path := range append(logFiles, logFilesPrev...) {
@@ -75,4 +75,20 @@ func (j *ClearLogsJob) Run() {
 			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)
+	}
 }

+ 55 - 0
internal/web/job/clear_logs_job_test.go

@@ -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"`)
+	}
+}

+ 0 - 10
internal/xray/process.go

@@ -65,16 +65,6 @@ func GetIPLimitBannedPrevLogPath() string {
 	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.
 func GetAccessLogPath() (string, error) {
 	config, err := os.ReadFile(GetConfigPath())