From 54339661a62d9c77acaa1558e5abb3ae03992045 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:42:12 +0100 Subject: [PATCH] fix(server): keep the access log view when one line is over 64 KiB GetXrayLogs read access.log with a default bufio.Scanner, whose token limit is 64 KiB, and returned nothing at all when it stopped with ErrTooLong. Xray logs an HTTP request's URL whole, so one proxied request with a long URL blanked the Access Logs view for every client. Lines are now read with a bounded reader: a line that fits is kept whole, and a longer one keeps its first 64 KiB and its last 1 KiB, where the route and email are, so it still shows as a correctly attributed row. --- internal/web/service/access_log_parse_test.go | 60 +++++++++++++++++++ internal/web/service/server.go | 48 ++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/internal/web/service/access_log_parse_test.go b/internal/web/service/access_log_parse_test.go index 15830d75a..623393db1 100644 --- a/internal/web/service/access_log_parse_test.go +++ b/internal/web/service/access_log_parse_test.go @@ -2,7 +2,9 @@ package service import ( "bufio" + "errors" "fmt" + "io" "os" "path/filepath" "runtime" @@ -148,3 +150,61 @@ func TestGetXrayLogsMemoryDoesNotGrowWithTheFile(t *testing.T) { } } } + +// One proxied request with a URL past 64 KiB stopped the scanner and blanked the whole view. +func TestGetXrayLogsSurvivesOverlongLine(t *testing.T) { + dir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", dir) + accessLog := filepath.Join(dir, "access.log") + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"log":{"access":"`+filepath.ToSlash(accessLog)+`"}}`), 0o600); err != nil { + t.Fatal(err) + } + lines := []string{ + "2024/01/02 15:04:05.000000 from 1.2.3.4:1 accepted tcp:one.example:443 [in-1 >> direct] email: alice", + "2024/01/02 15:04:06.000000 from 1.2.3.4:2 accepted http://two.example/?" + strings.Repeat("a", 300000) + " [in-2 >> blocked] email: mallory", + "2024/01/02 15:04:07.000000 from 1.2.3.4:3 accepted tcp:three.example:443 [in-1 >> direct] email: alice", + } + if err := os.WriteFile(accessLog, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + get := func(filter string) []LogEntry { + return (&ServerService{}).GetXrayLogs("100", filter, "true", "true", "true", []string{"direct"}, []string{"blocked"}) + } + entries := get("") + if len(entries) != len(lines) { + t.Fatalf("got %d entries for %d log lines", len(entries), len(lines)) + } + if entries[0].ToAddress != "tcp:one.example:443" || entries[2].ToAddress != "tcp:three.example:443" { + t.Errorf("neighbours of the long line were not parsed: %q, %q", entries[0].ToAddress, entries[2].ToAddress) + } + + long := entries[1] + if !strings.HasPrefix(long.ToAddress, "http://two.example/?a") || len(long.ToAddress) > maxAccessLogLineBytes+accessLogLineEndBytes { + t.Errorf("long ToAddress: %d bytes starting %.30q", len(long.ToAddress), long.ToAddress) + } + if long.FromAddress != "1.2.3.4:2" || long.Inbound != "in-2" || long.Outbound != "blocked" || long.Email != "mallory" || long.Event != 1 { + t.Errorf("long line lost its attribution: from %q, [%q >> %q], email %q, event %d", long.FromAddress, long.Inbound, long.Outbound, long.Email, long.Event) + } + if got := get("mallory"); len(got) != 1 { + t.Errorf("filter by the long line's email matched %d entries, want 1", len(got)) + } +} + +func TestReadAccessLogLine(t *testing.T) { + const size, end = 64, accessLogLineEndBytes + fits := strings.Repeat("f", size-1) + head, middle, tail := strings.Repeat("h", size), strings.Repeat("m", 3*end), strings.Repeat("t", end) + + // the last line fills the buffer exactly and ends the input without a newline + r := bufio.NewReaderSize(strings.NewReader(fits+"\n"+head+"x\r\n\n"+head+middle+tail+"\n"+head), size) + for i, want := range []string{fits, head + "x", "", head + tail, head} { + got, err := readAccessLogLine(r) + if err != nil || got != want { + t.Errorf("line %d: got %d bytes %.20q (err %v), want %d bytes %.20q", i, len(got), got, err, len(want), want) + } + } + if _, err := readAccessLogLine(r); !errors.Is(err, io.EOF) { + t.Errorf("after the last line: err = %v, want io.EOF", err) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 8fa7db3ca..294625def 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1477,6 +1477,37 @@ func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWG return logs } +// Xray's HTTP inbound logs the requested URL whole, so an access-log line has +// no length bound; GetXrayLogs keeps this much of a line's start and of its end. +const ( + maxAccessLogLineBytes = 64 << 10 + accessLogLineEndBytes = 1 << 10 +) + +// readAccessLogLine returns the next line of r. A line longer than r's buffer +// loses its middle and keeps its end (route, email) instead of failing the read. +func readAccessLogLine(r *bufio.Reader) (string, error) { + chunk, more, err := r.ReadLine() + if err != nil { + return "", err + } + line := string(chunk) + + var end []byte + for more { + chunk, more, err = r.ReadLine() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + end = append(end, chunk...) + end = end[max(0, len(end)-accessLogLineEndBytes):] + } + return line + string(end), nil +} + func (s *ServerService) GetXrayLogs( count string, filter string, @@ -1509,10 +1540,17 @@ func (s *ServerService) GetXrayLogs( } defer file.Close() - scanner := bufio.NewScanner(file) + reader := bufio.NewReaderSize(file, maxAccessLogLineBytes) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) + for { + line, err := readAccessLogLine(reader) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil + } + line = strings.TrimSpace(line) if line == "" || strings.Contains(line, "api -> api") { // skipping empty lines and api calls @@ -1551,10 +1589,6 @@ func (s *ServerService) GetXrayLogs( } } - if err := scanner.Err(); err != nil { - return nil - } - return entries }