瀏覽代碼

fix(logs): limit Xray log growth (#5840)

* Limit Xray log growth

* Apply suggestion from @github-actions[bot]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Apply suggestion from @github-actions[bot]

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

---------

Co-authored-by: Mahyar Dana <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Rouzbeh† 12 小時之前
父節點
當前提交
15faec6258
共有 5 個文件被更改,包括 119 次插入36 次删除
  1. 2 1
      docs/architecture.md
  2. 45 10
      internal/web/job/clear_logs_job.go
  3. 55 18
      internal/web/job/clear_logs_job_test.go
  4. 2 0
      internal/web/web.go
  5. 15 7
      internal/xray/process.go

+ 2 - 1
docs/architecture.md

@@ -367,8 +367,9 @@ All registered in `web.go` → `startTask()`. Each is a struct with a `Run()` me
 | `@every 10s` | `check_client_ip_job` | Enforce per-client IP limits |
 | `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
 | `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
+| `@every 10m` | `clear_logs_job` (`PruneXrayLogsJob`) | Truncate Xray access/error logs once either exceeds 64 MiB |
 | `@hourly` | `warp_ip_job`, `periodic_traffic_reset_job("hourly")` | WARP IP rotation; traffic resets |
-| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | Log cleanup; resets |
+| `@daily` | `clear_logs_job`, `periodic_traffic_reset_job("daily")` | IP-limit and Xray access/error log cleanup; traffic resets |
 | `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
 | default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
 | default `@daily` | `stats_notify_job` | Only if TG bot enabled; schedule configurable |

+ 45 - 10
internal/web/job/clear_logs_job.go

@@ -9,14 +9,27 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
+const defaultMaxXrayLogBytes int64 = 64 << 20
+
+var maxXrayLogBytes = defaultMaxXrayLogBytes
+
 // ClearLogsJob clears old log files to prevent disk space issues.
 type ClearLogsJob struct{}
 
+// PruneXrayLogsJob truncates oversized Xray access and error logs.
+// PruneXrayLogsJob truncates the Xray access and error logs once either exceeds maxXrayLogBytes.
+type PruneXrayLogsJob struct{}
+
 // NewClearLogsJob creates a new log cleanup job instance.
 func NewClearLogsJob() *ClearLogsJob {
 	return new(ClearLogsJob)
 }
 
+// NewPruneXrayLogsJob creates a new Xray log pruning job instance.
+func NewPruneXrayLogsJob() *PruneXrayLogsJob {
+	return new(PruneXrayLogsJob)
+}
+
 // ensureFileExists creates the necessary directories and file if they don't exist
 func ensureFileExists(path string) error {
 	dir := filepath.Dir(path)
@@ -76,19 +89,41 @@ func (j *ClearLogsJob) Run() {
 		}
 	}
 
-	wipeAccessLog()
+	wipeXrayLogs()
+}
+
+func (j *PruneXrayLogsJob) Run() {
+	truncateXrayLog(xray.GetAccessLogPath, maxXrayLogBytes)
+	truncateXrayLog(xray.GetErrorLogPath, maxXrayLogBytes)
+}
+
+func wipeXrayLogs() {
+	truncateXrayLog(xray.GetAccessLogPath, 0)
+	truncateXrayLog(xray.GetErrorLogPath, 0)
 }
 
-// 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 == "" {
+func truncateXrayLog(pathFn func() (string, error), maxBytes int64) {
+	logPath, err := pathFn()
+	if err != nil || disabledLogPath(logPath) {
 		return
 	}
-	if err := os.Truncate(accessLogPath, 0); err != nil && !os.IsNotExist(err) {
-		logger.Warning("Failed to truncate access log:", accessLogPath, "-", err)
+	if maxBytes > 0 {
+		info, err := os.Stat(logPath)
+		if err != nil {
+			if !os.IsNotExist(err) {
+				logger.Warning("Failed to stat Xray log:", logPath, "-", err)
+			}
+			return
+		}
+		if info.Size() <= maxBytes {
+			return
+		}
+	}
+	if err := os.Truncate(logPath, 0); err != nil && !os.IsNotExist(err) {
+		logger.Warning("Failed to truncate Xray log:", logPath, "-", err)
 	}
 }
+
+func disabledLogPath(path string) bool {
+	return path == "" || path == "none"
+}

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

@@ -7,14 +7,12 @@ import (
 	"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) {
+func writeLogConfig(t *testing.T, accessPath string, errorPath 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},
+		"log": map[string]any{"access": accessPath, "error": errorPath},
 	})
 	if err != nil {
 		t.Fatalf("marshal xray config: %v", err)
@@ -24,32 +22,71 @@ func writeAccessLogConfig(t *testing.T, accessPath string) {
 	}
 }
 
-func TestWipeAccessLog_TruncatesEnabledLog(t *testing.T) {
+func TestWipeXrayLogs_TruncatesEnabledLogs(t *testing.T) {
 	accessLog := filepath.Join(t.TempDir(), "access.log")
+	errorLog := filepath.Join(t.TempDir(), "error.log")
 	if err := os.WriteFile(accessLog, []byte("2026/06/23 12:00:00 from tcp:203.0.113.10:443 accepted\n"), 0o644); err != nil {
 		t.Fatalf("seed access log: %v", err)
 	}
-	writeAccessLogConfig(t, accessLog)
+	if err := os.WriteFile(errorLog, []byte("xray warning\n"), 0o644); err != nil {
+		t.Fatalf("seed error log: %v", err)
+	}
+	writeLogConfig(t, accessLog, errorLog)
 
-	wipeAccessLog()
+	wipeXrayLogs()
 
-	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())
+	for _, logPath := range []string{accessLog, errorLog} {
+		info, err := os.Stat(logPath)
+		if err != nil {
+			t.Fatalf("%s should still exist: %v", logPath, err)
+		}
+		if info.Size() != 0 {
+			t.Fatalf("%s should be truncated to 0, got %d bytes", logPath, info.Size())
+		}
 	}
 }
 
-func TestWipeAccessLog_LeavesDisabledLogAlone(t *testing.T) {
-	writeAccessLogConfig(t, "none")
+func TestWipeXrayLogs_LeavesDisabledLogsAlone(t *testing.T) {
+	writeLogConfig(t, "none", "none")
 
-	// Must not panic or create a file literally named "none".
-	wipeAccessLog()
+	wipeXrayLogs()
 
 	if _, err := os.Stat("none"); err == nil {
 		os.Remove("none")
-		t.Fatal(`wipeAccessLog must not create a file named "none"`)
+		t.Fatal(`wipeXrayLogs must not create a file named "none"`)
+	}
+}
+
+func TestPruneXrayLogs_TruncatesOnlyOversizedLogs(t *testing.T) {
+	oldMax := maxXrayLogBytes
+	maxXrayLogBytes = 8
+	defer func() { maxXrayLogBytes = oldMax }()
+
+	dir := t.TempDir()
+	accessLog := filepath.Join(dir, "access.log")
+	errorLog := filepath.Join(dir, "error.log")
+	if err := os.WriteFile(accessLog, []byte("small"), 0o644); err != nil {
+		t.Fatalf("seed access log: %v", err)
+	}
+	if err := os.WriteFile(errorLog, []byte("large log line"), 0o644); err != nil {
+		t.Fatalf("seed error log: %v", err)
+	}
+	writeLogConfig(t, accessLog, errorLog)
+
+	NewPruneXrayLogsJob().Run()
+
+	accessInfo, err := os.Stat(accessLog)
+	if err != nil {
+		t.Fatalf("access log should still exist: %v", err)
+	}
+	if accessInfo.Size() != 5 {
+		t.Fatalf("small access log should be left alone, got %d bytes", accessInfo.Size())
+	}
+	errorInfo, err := os.Stat(errorLog)
+	if err != nil {
+		t.Fatalf("error log should still exist: %v", err)
+	}
+	if errorInfo.Size() != 0 {
+		t.Fatalf("oversized error log should be truncated, got %d bytes", errorInfo.Size())
 	}
 }

+ 2 - 0
internal/web/web.go

@@ -292,6 +292,7 @@ const (
 	cadenceNodeHeartbeat = "@every 5s"
 	cadenceNodeTraffic   = "@every 5s"
 	cadenceOutboundSub   = "@every 5m"
+	cadenceXrayLogPrune  = "@every 10m"
 	cadenceCheckHash     = "@every 2m"
 	// cpu.Percent samples over a full minute (blocking), so a finer cadence just
 	// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
@@ -343,6 +344,7 @@ func (s *Server) startTask(restartXray bool) {
 
 	// check client ips from log file every day
 	_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
+	_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
 	_, _ = s.cron.AddJob("@hourly", job.NewWarpIpJob())
 
 	// Inbound traffic reset jobs

+ 15 - 7
internal/xray/process.go

@@ -66,8 +66,7 @@ func GetIPLimitBannedPrevLogPath() string {
 	return config.GetLogFolder() + "/3xipl-banned.prev.log"
 }
 
-// GetAccessLogPath reads the Xray config and returns the access log file path.
-func GetAccessLogPath() (string, error) {
+func getLogPath(key string) (string, error) {
 	config, err := os.ReadFile(GetConfigPath())
 	if err != nil {
 		logger.Warningf("Failed to read configuration file: %s", err)
@@ -81,16 +80,25 @@ func GetAccessLogPath() (string, error) {
 		return "", err
 	}
 
-	if jsonConfig["log"] != nil {
-		jsonLog := jsonConfig["log"].(map[string]any)
-		if jsonLog["access"] != nil {
-			accessLogPath := jsonLog["access"].(string)
-			return accessLogPath, nil
+	if jsonLog, ok := jsonConfig["log"].(map[string]any); ok {
+		if logPath, ok := jsonLog[key].(string); ok {
+			return logPath, nil
 		}
 	}
 	return "", err
 }
 
+// GetAccessLogPath reads the Xray config and returns the access log file path.
+func GetAccessLogPath() (string, error) {
+	return getLogPath("access")
+}
+
+// GetErrorLogPath reads the Xray config and returns the error log file path.
+// GetErrorLogPath reads the Xray config and returns the error log file path.
+func GetErrorLogPath() (string, error) {
+    return getLogPath("error")
+}
+
 // stopProcess calls Stop on the given Process instance.
 func stopProcess(p *Process) {
 	_ = p.Stop()