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 <dana.mahyar76@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Rouzbeh†
2026-07-08 01:03:34 +03:30
committed by GitHub
parent 9b91f0f42e
commit 15faec6258
5 changed files with 120 additions and 37 deletions
+2 -1
View File
@@ -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
View File
@@ -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"
}
+56 -19
View File
@@ -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())
}
}
+2
View File
@@ -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
View File
@@ -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()