mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 12:27:13 +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` | `check_client_ip_job` | Enforce per-client IP limits |
|
||||||
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
| `@every 10s` | `mtproto_job` | Reconcile `mtg` sidecars against enabled MTProto inbounds |
|
||||||
| `@every 5m` | `outbound_subscription_job` | Refresh outbound provider configs |
|
| `@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 |
|
| `@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 |
|
| `@weekly` / `@monthly` | `periodic_traffic_reset_job(...)` | Weekly/monthly traffic resets |
|
||||||
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
| default `@every 1m` | `ldap_sync_job` | Only if LDAP enabled; schedule configurable |
|
||||||
| default `@daily` | `stats_notify_job` | Only if TG bot 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"
|
"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.
|
// ClearLogsJob clears old log files to prevent disk space issues.
|
||||||
type ClearLogsJob struct{}
|
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.
|
// NewClearLogsJob creates a new log cleanup job instance.
|
||||||
func NewClearLogsJob() *ClearLogsJob {
|
func NewClearLogsJob() *ClearLogsJob {
|
||||||
return new(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
|
// ensureFileExists creates the necessary directories and file if they don't exist
|
||||||
func ensureFileExists(path string) error {
|
func ensureFileExists(path string) error {
|
||||||
dir := filepath.Dir(path)
|
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
|
func (j *PruneXrayLogsJob) Run() {
|
||||||
// unbounded. The IP-limit job no longer reads or rotates it, so this daily wipe
|
truncateXrayLog(xray.GetAccessLogPath, maxXrayLogBytes)
|
||||||
// is the only thing that caps it. A disabled ("none") or unset access log is
|
truncateXrayLog(xray.GetErrorLogPath, maxXrayLogBytes)
|
||||||
// left alone, and a missing file is fine — there's nothing to wipe.
|
}
|
||||||
func wipeAccessLog() {
|
|
||||||
accessLogPath, err := xray.GetAccessLogPath()
|
func wipeXrayLogs() {
|
||||||
if err != nil || accessLogPath == "none" || accessLogPath == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := os.Truncate(accessLogPath, 0); err != nil && !os.IsNotExist(err) {
|
if maxBytes > 0 {
|
||||||
logger.Warning("Failed to truncate access log:", accessLogPath, "-", err)
|
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"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeAccessLogConfig points bin/config.json at the given access log path (use
|
func writeLogConfig(t *testing.T, accessPath string, errorPath string) {
|
||||||
// "none" to disable), so GetAccessLogPath resolves it the way the job does.
|
|
||||||
func writeAccessLogConfig(t *testing.T, accessPath string) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
binDir := t.TempDir()
|
binDir := t.TempDir()
|
||||||
t.Setenv("XUI_BIN_FOLDER", binDir)
|
t.Setenv("XUI_BIN_FOLDER", binDir)
|
||||||
configData, err := json.Marshal(map[string]any{
|
configData, err := json.Marshal(map[string]any{
|
||||||
"log": map[string]any{"access": accessPath},
|
"log": map[string]any{"access": accessPath, "error": errorPath},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal xray config: %v", err)
|
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")
|
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 {
|
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)
|
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)
|
||||||
wipeAccessLog()
|
|
||||||
|
|
||||||
info, err := os.Stat(accessLog)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("access log should still exist: %v", err)
|
|
||||||
}
|
}
|
||||||
if info.Size() != 0 {
|
writeLogConfig(t, accessLog, errorLog)
|
||||||
t.Fatalf("access log should be truncated to 0, got %d bytes", info.Size())
|
|
||||||
|
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) {
|
func TestWipeXrayLogs_LeavesDisabledLogsAlone(t *testing.T) {
|
||||||
writeAccessLogConfig(t, "none")
|
writeLogConfig(t, "none", "none")
|
||||||
|
|
||||||
// Must not panic or create a file literally named "none".
|
wipeXrayLogs()
|
||||||
wipeAccessLog()
|
|
||||||
|
|
||||||
if _, err := os.Stat("none"); err == nil {
|
if _, err := os.Stat("none"); err == nil {
|
||||||
os.Remove("none")
|
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"
|
cadenceNodeHeartbeat = "@every 5s"
|
||||||
cadenceNodeTraffic = "@every 5s"
|
cadenceNodeTraffic = "@every 5s"
|
||||||
cadenceOutboundSub = "@every 5m"
|
cadenceOutboundSub = "@every 5m"
|
||||||
|
cadenceXrayLogPrune = "@every 10m"
|
||||||
cadenceCheckHash = "@every 2m"
|
cadenceCheckHash = "@every 2m"
|
||||||
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
// cpu.Percent samples over a full minute (blocking), so a finer cadence just
|
||||||
// stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway.
|
// 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
|
// check client ips from log file every day
|
||||||
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
_, _ = s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||||
|
_, _ = s.cron.AddJob(cadenceXrayLogPrune, job.NewPruneXrayLogsJob())
|
||||||
_, _ = s.cron.AddJob("@hourly", job.NewWarpIpJob())
|
_, _ = s.cron.AddJob("@hourly", job.NewWarpIpJob())
|
||||||
|
|
||||||
// Inbound traffic reset jobs
|
// Inbound traffic reset jobs
|
||||||
|
|||||||
@@ -66,8 +66,7 @@ func GetIPLimitBannedPrevLogPath() string {
|
|||||||
return config.GetLogFolder() + "/3xipl-banned.prev.log"
|
return config.GetLogFolder() + "/3xipl-banned.prev.log"
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessLogPath reads the Xray config and returns the access log file path.
|
func getLogPath(key string) (string, error) {
|
||||||
func GetAccessLogPath() (string, error) {
|
|
||||||
config, err := os.ReadFile(GetConfigPath())
|
config, err := os.ReadFile(GetConfigPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("Failed to read configuration file: %s", err)
|
logger.Warningf("Failed to read configuration file: %s", err)
|
||||||
@@ -81,16 +80,25 @@ func GetAccessLogPath() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonConfig["log"] != nil {
|
if jsonLog, ok := jsonConfig["log"].(map[string]any); ok {
|
||||||
jsonLog := jsonConfig["log"].(map[string]any)
|
if logPath, ok := jsonLog[key].(string); ok {
|
||||||
if jsonLog["access"] != nil {
|
return logPath, nil
|
||||||
accessLogPath := jsonLog["access"].(string)
|
|
||||||
return accessLogPath, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", err
|
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.
|
// stopProcess calls Stop on the given Process instance.
|
||||||
func stopProcess(p *Process) {
|
func stopProcess(p *Process) {
|
||||||
_ = p.Stop()
|
_ = p.Stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user