mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user