From d550e91677427235ff516e1604db35532b5848dc Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:01:10 +0000 Subject: [PATCH 1/3] fix(logger): treat "none" as a disabled log, not a file name Xray accepts "none" for log.access and log.error to turn that log off. The node passes the configured values to its own logger, which opened "none" as a relative path: with "access": "none" every access entry was appended to /app/none inside the container, with no rotation. openLogFile now skips "none" the same way it skips an empty path. Co-Authored-By: Claude Opus 5.5 --- logger/logger.go | 6 +++++- logger/logger_test.go | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 logger/logger_test.go diff --git a/logger/logger.go b/logger/logger.go index 0bfe3947..9ad626d7 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -18,6 +18,10 @@ const ( LogCritical LogLevel = "Critical" ) +// DisabledLog is the value Xray accepts for "access" or "error" to turn that log off. +// It is not a file name. +const DisabledLog = "none" + type Logger struct { outputLogs bool accessLogFile *os.File @@ -34,7 +38,7 @@ func New(outputLogs bool) *Logger { } func openLogFile(path string) (*os.File, error) { - if path == "" { + if path == "" || path == DisabledLog { return nil, nil } diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 00000000..2b370ab4 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,23 @@ +package logger + +import ( + "os" + "testing" +) + +func TestSetLogFileTreatsNoneAsDisabled(t *testing.T) { + t.Chdir(t.TempDir()) + + l := New(false) + if err := l.SetLogFile(DisabledLog, DisabledLog); err != nil { + t.Fatal(err) + } + defer l.Close() + + l.Log(LogInfo, "from 203.0.113.7:51000 accepted tcp:example.com:443 [in >> direct] email: user1") + l.Log(LogError, "[Warning] core: Xray started") + + if _, err := os.Stat(DisabledLog); !os.IsNotExist(err) { + t.Fatalf("SetLogFile(%q, %q) created a file named %q (stat error: %v)", DisabledLog, DisabledLog, DisabledLog, err) + } +} From 6f86f9706c7222c6c33e39786c09b229151d0ff3 Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:01:10 +0000 Subject: [PATCH 2/3] fix(xray): pass "none" access/error logs through to Xray ToBytes clears the log paths because the node reads Xray's stdout and writes the files itself. It also cleared "none", which Xray then reads as "log to stdout", so "access": "none" never turned the access log off: every connection was still logged and streamed to the panel. Keep "none" for both logs; real paths are still cleared. Co-Authored-By: Claude Opus 5.5 --- backend/xray/config.go | 11 +++++++-- backend/xray/config_tobytes_test.go | 36 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/xray/config.go b/backend/xray/config.go index 4f5bbb19..e8d0b11c 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -16,6 +16,7 @@ import ( "github.com/pasarguard/node/backend/xray/api" "github.com/pasarguard/node/common" + nodeLogger "github.com/pasarguard/node/logger" "github.com/xtls/xray-core/infra/conf" ) @@ -556,8 +557,14 @@ func (c *Config) ToBytes() ([]byte, error) { if c.LogConfig != nil { snapLog := *c.LogConfig - snapLog.AccessLog = "" - snapLog.ErrorLog = "" + // The node reads Xray's stdout and writes the log files itself, so Xray gets no file + // paths. "none" is not a path: Xray uses it to turn that log off, so pass it through. + if snapLog.AccessLog != nodeLogger.DisabledLog { + snapLog.AccessLog = "" + } + if snapLog.ErrorLog != nodeLogger.DisabledLog { + snapLog.ErrorLog = "" + } snapConfig.LogConfig = &snapLog } diff --git a/backend/xray/config_tobytes_test.go b/backend/xray/config_tobytes_test.go index d6057e79..5a295d4b 100644 --- a/backend/xray/config_tobytes_test.go +++ b/backend/xray/config_tobytes_test.go @@ -3,6 +3,7 @@ package xray import ( "encoding/json" "reflect" + "strings" "sync" "testing" @@ -153,6 +154,41 @@ func TestToBytesDropsLogFilesWithoutChangingTheConfig(t *testing.T) { } } +// "none" turns a log off in Xray; clearing it like a file path would switch that log to stdout. +func TestToBytesKeepsDisabledLogs(t *testing.T) { + const fileLogs = `"access":"/var/log/xray/access.log","error":"/var/log/xray/error.log"` + tests := []struct { + name string + logs string + wantAccess string + wantError string + }{ + {name: "both off", logs: `"access":"none","error":"none"`, wantAccess: "none", wantError: "none"}, + {name: "access off, error to a file", logs: `"access":"none","error":"/var/log/xray/error.log"`, wantAccess: "none", wantError: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw := strings.Replace(toBytesConfig, fileLogs, tt.logs, 1) + if raw == toBytesConfig { + t.Fatal("toBytesConfig no longer contains the log files this test replaces") + } + cfg, err := NewConfig(raw, []string{"static"}) + if err != nil { + t.Fatal(err) + } + out := serializeConfig(t, cfg) + + if access, _ := out.Log["access"].(string); access != tt.wantAccess { + t.Fatalf("serialized access log = %q, want %q", access, tt.wantAccess) + } + if errorLog, _ := out.Log["error"].(string); errorLog != tt.wantError { + t.Fatalf("serialized error log = %q, want %q", errorLog, tt.wantError) + } + }) + } +} + func TestToBytesDoesNotWriteInboundSettings(t *testing.T) { cfg := newToBytesConfig(t) cfg.syncUsers(toBytesUsers()) From 526fb347f405f6374e221fe7257526a6c54697d5 Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:01:10 +0000 Subject: [PATCH 3/3] fix(xray): ignore access entries and Info/Debug lines in startup checks isStartupFailureLog looks for keywords such as "fatal", "panic" and "failed to open" in every line Xray prints before its API answers. Clients reconnect during that window, so ordinary traffic lines failed healthy starts and the node stopped Xray, e.g.: from accepted tcp:fatal-error.example:443 [in >> direct] email: [Info] [id] ... proxy/freedom: failed to open connection to tcp::443 > ... connection refused Skip access entries, accepted and rejected (matched on fields, because Xray writes two spaces after "rejected"), and Info/Debug lines. A failed start still matches through "Failed to start:", panics and Warning/Error lines. Co-Authored-By: Claude Opus 5.5 --- backend/xray/log.go | 13 +++++++++++++ backend/xray/log_test.go | 22 ++++++++++++++++++++++ backend/xray/startup_diagnostics.go | 12 ++++++++++++ backend/xray/startup_diagnostics_test.go | 8 ++++++++ 4 files changed, 55 insertions(+) diff --git a/backend/xray/log.go b/backend/xray/log.go index d428853a..b89c8d29 100644 --- a/backend/xray/log.go +++ b/backend/xray/log.go @@ -24,6 +24,19 @@ func isAccessLog(log string) bool { return strings.Contains(log, " accepted tcp:") || strings.Contains(log, " accepted udp:") } +// isAccessLogEntry reports whether line is an Xray access-log entry, accepted or rejected: +// "[date time] from accepted|rejected ...". It compares fields because Xray writes +// two spaces after "rejected" when the entry has no destination. +func isAccessLogEntry(line string) bool { + fields := strings.Fields(line) + for i := 0; i < len(fields) && i < 3; i++ { + if fields[i] == "from" { + return i+2 < len(fields) && (fields[i+2] == "accepted" || fields[i+2] == "rejected") + } + } + return false +} + func (c *Core) detectLogType(log string) { if c.logger == nil { return diff --git a/backend/xray/log_test.go b/backend/xray/log_test.go index aa4ac34e..17de53e5 100644 --- a/backend/xray/log_test.go +++ b/backend/xray/log_test.go @@ -111,3 +111,25 @@ func TestDetectLogType(t *testing.T) { }) } } + +func TestIsAccessLogEntry(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {line: "2026/11/04 07:27:52.690940 from 203.0.113.7:60253 accepted tcp:example.com:443 [in -> out] email: user1", want: true}, + {line: "2026/11/04 07:27:52.692433 from tcp:203.0.113.8:8436 accepted udp:1.1.1.1:53 [in >> direct] email: user2", want: true}, + // Rejected entries have no destination, so Xray writes two spaces after "rejected". + {line: "2026/11/04 07:30:41.120004 from 198.51.100.4:54596 rejected proxy/vless/encoding: invalid request user id: 00000000-0000-0000-0000-000000000000", want: true}, + {line: "from 198.51.100.4:54596 rejected proxy/vless/encoding: invalid request user id", want: true}, + {line: "2026/11/04 07:27:53.000001 [Warning] rejected request from 10.0.0.1:443: connection not accepted by destination", want: false}, + {line: "Failed to start: app/proxyman/inbound: failed to listen TCP on 443", want: false}, + {line: "", want: false}, + } + + for _, tt := range tests { + if got := isAccessLogEntry(tt.line); got != tt.want { + t.Fatalf("isAccessLogEntry(%q) = %v, want %v", tt.line, got, tt.want) + } + } +} diff --git a/backend/xray/startup_diagnostics.go b/backend/xray/startup_diagnostics.go index 3f964b04..cc08ab23 100644 --- a/backend/xray/startup_diagnostics.go +++ b/backend/xray/startup_diagnostics.go @@ -182,6 +182,18 @@ func (c *Core) RuntimeLogTail(n int) []string { } func isStartupFailureLog(line string) bool { + // Access entries describe client connections. A destination such as + // "tcp:fatal-error.example:443" says nothing about whether Xray started. + if isAccessLogEntry(line) { + return false + } + // Info and Debug lines are per-connection details, e.g. "proxy/freedom: failed to open + // connection to ...", which appear as soon as clients reconnect. A failed start shows up + // as Xray's "Failed to start:" message, a panic, or a Warning/Error line. + if strings.Contains(line, "[Info]") || strings.Contains(line, "[Debug]") { + return false + } + lower := strings.ToLower(line) // Client authentication/rejection logs can include "invalid" and are not startup-fatal. diff --git a/backend/xray/startup_diagnostics_test.go b/backend/xray/startup_diagnostics_test.go index 769da117..f9de3052 100644 --- a/backend/xray/startup_diagnostics_test.go +++ b/backend/xray/startup_diagnostics_test.go @@ -104,6 +104,14 @@ func TestIsStartupFailureLog(t *testing.T) { {line: "2026/02/23 07:13:42.313765 from 5.250.114.189:0 rejected proxy/vless/encoding: invalid request user id: 1ffca7fc-4bb6-4701-9cfc-b046a28c569a", want: false}, {line: "accepted tcp:www.gstatic.com:443 [TAG -> DIRECT] email: a@b.com", want: false}, {line: "2026/02/21 [Info] app/stats: create new counter", want: false}, + {line: "Failed to start: main: failed to load config files: [stdin:] > infra/conf: failed to build inbound config with tag in", want: true}, + {line: "Failed to start: app/proxyman/inbound: failed to listen TCP on 443 > listen tcp 0.0.0.0:443: bind: address already in use", want: true}, + // Clients reconnect while Xray starts. Their access entries and Info lines may contain + // any keyword; they must not fail the start. + {line: "2026/11/04 07:27:53.032627 from 203.0.113.7:57056 accepted tcp:fatal-error.example:443 [in >> direct] email: user1", want: false}, + {line: "2026/11/04 07:27:53.040211 from tcp:203.0.113.8:8436 accepted udp:panic.example:53 [in >> direct] email: user2", want: false}, + {line: "2026/11/04 07:27:53.051118 from 198.51.100.4:54596 rejected proxy/vless/encoding: invalid request user id: 00000000-0000-0000-0000-000000000000", want: false}, + {line: "2026/11/04 04:48:07.229696 [Info] [3000105832] app/proxyman/outbound: app/proxyman/outbound: failed to process outbound traffic > proxy/freedom: failed to open connection to tcp:203.0.113.10:443 > dial tcp 203.0.113.10:443: connect: connection refused", want: false}, } for _, tt := range tests {