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()) 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 { 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) + } +}