diff --git a/docs/architecture.md b/docs/architecture.md index 985db50d7..fec0c880a 100644 --- a/docs/architecture.md +++ b/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 | diff --git a/internal/web/job/clear_logs_job.go b/internal/web/job/clear_logs_job.go index 426d6a965..af8ef30b4 100644 --- a/internal/web/job/clear_logs_job.go +++ b/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() } -// 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 (j *PruneXrayLogsJob) Run() { + truncateXrayLog(xray.GetAccessLogPath, maxXrayLogBytes) + truncateXrayLog(xray.GetErrorLogPath, maxXrayLogBytes) +} + +func wipeXrayLogs() { + truncateXrayLog(xray.GetAccessLogPath, 0) + truncateXrayLog(xray.GetErrorLogPath, 0) +} + +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" +} diff --git a/internal/web/job/clear_logs_job_test.go b/internal/web/job/clear_logs_job_test.go index dd2422446..2e7a07db0 100644 --- a/internal/web/job/clear_logs_job_test.go +++ b/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) - - wipeAccessLog() - - info, err := os.Stat(accessLog) - if err != nil { - t.Fatalf("access log should still exist: %v", err) + if err := os.WriteFile(errorLog, []byte("xray warning\n"), 0o644); err != nil { + t.Fatalf("seed error log: %v", err) } - if info.Size() != 0 { - t.Fatalf("access log should be truncated to 0, got %d bytes", info.Size()) + writeLogConfig(t, accessLog, errorLog) + + wipeXrayLogs() + + 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()) } } diff --git a/internal/web/web.go b/internal/web/web.go index 33c400eb1..d19d28238 100644 --- a/internal/web/web.go +++ b/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 diff --git a/internal/xray/process.go b/internal/xray/process.go index 4735a2e99..222ae37ce 100644 --- a/internal/xray/process.go +++ b/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()