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/6] 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/6] 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/6] 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 { From d6e06a3ad090a4ef5f755a3bf1d3aef05863d801 Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:47:42 +0000 Subject: [PATCH 4/6] test(xray): cover the Debug exclusion in startup failure detection Without a case for it, removing the [Debug] check from isStartupFailureLog would still pass every test, and Debug lines such as a dial to a domain containing "fatal" would fail healthy starts. Co-Authored-By: Claude Opus 5.5 --- backend/xray/startup_diagnostics_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/xray/startup_diagnostics_test.go b/backend/xray/startup_diagnostics_test.go index f9de3052..492c6167 100644 --- a/backend/xray/startup_diagnostics_test.go +++ b/backend/xray/startup_diagnostics_test.go @@ -106,12 +106,13 @@ func TestIsStartupFailureLog(t *testing.T) { {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. + // Clients reconnect while Xray starts. Their access entries and Info/Debug 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}, + {line: "2026/11/04 04:48:07.300512 [Debug] [3000105832] transport/internet/tcp: dialing TCP to tcp:fatal-error.example:443", want: false}, } for _, tt := range tests { From e7cc4e3dba62d99333a7dbf0afd2ff9db7235689 Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:09:20 +0000 Subject: [PATCH 5/6] docs: document the log helpers whose behavior this PR changes Add doc comments to openLogFile, Config.ToBytes and isStartupFailureLog describing what "none" and the startup check now mean. Co-Authored-By: Claude Opus 5.5 --- backend/xray/config.go | 1 + backend/xray/startup_diagnostics.go | 2 ++ logger/logger.go | 2 ++ 3 files changed, 5 insertions(+) diff --git a/backend/xray/config.go b/backend/xray/config.go index e8d0b11c..fc258176 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -546,6 +546,7 @@ func snapshotInboundForSerialization(inbound *Inbound) *Inbound { return snap } +// ToBytes returns the config as the JSON Xray reads on start. It does not modify c. func (c *Config) ToBytes() ([]byte, error) { snapInbounds := make([]*Inbound, len(c.InboundConfigs)) for idx, inbound := range c.InboundConfigs { diff --git a/backend/xray/startup_diagnostics.go b/backend/xray/startup_diagnostics.go index cc08ab23..bdbd6078 100644 --- a/backend/xray/startup_diagnostics.go +++ b/backend/xray/startup_diagnostics.go @@ -181,6 +181,8 @@ func (c *Core) RuntimeLogTail(n int) []string { return c.runtimeLogs.tail(n) } +// isStartupFailureLog reports whether line, printed before Xray's API answers, means that +// Xray failed to start. Access entries and Info/Debug lines never do. 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. diff --git a/logger/logger.go b/logger/logger.go index 9ad626d7..b9ea8f81 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -37,6 +37,8 @@ func New(outputLogs bool) *Logger { } } +// openLogFile opens path for appending, creating the file and its directory if needed. +// An empty path or DisabledLog means no file: it returns nil, nil. func openLogFile(path string) (*os.File, error) { if path == "" || path == DisabledLog { return nil, nil From 6ca6caf16e622a1242d1e774faae3654b140fd62 Mon Sep 17 00:00:00 2001 From: xmohammad1 <117955130+xmohammad1@users.noreply.github.com> Date: Wed, 4 Nov 2026 09:35:16 +0000 Subject: [PATCH 6/6] docs: document the tests this PR adds or extends Each test now says which regression it guards against. Co-Authored-By: Claude Opus 5.5 --- backend/xray/config_tobytes_test.go | 3 ++- backend/xray/log_test.go | 2 ++ backend/xray/startup_diagnostics_test.go | 2 ++ logger/logger_test.go | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/xray/config_tobytes_test.go b/backend/xray/config_tobytes_test.go index 5a295d4b..f55362d6 100644 --- a/backend/xray/config_tobytes_test.go +++ b/backend/xray/config_tobytes_test.go @@ -154,7 +154,8 @@ func TestToBytesDropsLogFilesWithoutChangingTheConfig(t *testing.T) { } } -// "none" turns a log off in Xray; clearing it like a file path would switch that log to stdout. +// TestToBytesKeepsDisabledLogs checks that "none" reaches Xray unchanged. Clearing it like a file +// path would make Xray print 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 { diff --git a/backend/xray/log_test.go b/backend/xray/log_test.go index 17de53e5..6a041c2b 100644 --- a/backend/xray/log_test.go +++ b/backend/xray/log_test.go @@ -112,6 +112,8 @@ func TestDetectLogType(t *testing.T) { } } +// TestIsAccessLogEntry checks accepted and rejected entries, including the two spaces Xray writes +// after "rejected", and that error lines mentioning "from" or "rejected" are not access entries. func TestIsAccessLogEntry(t *testing.T) { tests := []struct { line string diff --git a/backend/xray/startup_diagnostics_test.go b/backend/xray/startup_diagnostics_test.go index 492c6167..f74420a6 100644 --- a/backend/xray/startup_diagnostics_test.go +++ b/backend/xray/startup_diagnostics_test.go @@ -94,6 +94,8 @@ func TestStartupErrorWithTailPrefersMatchedFailure(t *testing.T) { } } +// TestIsStartupFailureLog checks which startup lines fail the start: Xray's failure messages and +// panics do; access entries and Info/Debug lines do not, whatever words they contain. func TestIsStartupFailureLog(t *testing.T) { tests := []struct { line string diff --git a/logger/logger_test.go b/logger/logger_test.go index 2b370ab4..09a1a479 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -5,6 +5,8 @@ import ( "testing" ) +// TestSetLogFileTreatsNoneAsDisabled checks that "none" opens no file. The node used to create +// ./none and append every access entry to it. func TestSetLogFileTreatsNoneAsDisabled(t *testing.T) { t.Chdir(t.TempDir())