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.
This commit is contained in:
MHSanaei
2026-11-01 13:42:12 +01:00
parent e02f51320a
commit 54339661a6
2 changed files with 101 additions and 7 deletions
@@ -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)
}
}
+41 -7
View File
@@ -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
}