diff --git a/internal/web/service/access_log_parse_test.go b/internal/web/service/access_log_parse_test.go index 2d57da48e..15830d75a 100644 --- a/internal/web/service/access_log_parse_test.go +++ b/internal/web/service/access_log_parse_test.go @@ -1,6 +1,16 @@ package service -import "testing" +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "testing" + "time" +) func TestParseAccessLogFields(t *testing.T) { malformed := []string{ @@ -43,3 +53,98 @@ func TestParseAccessLogFields(t *testing.T) { t.Error("DateTime was not parsed from a well-formed line") } } + +// writeAccessLog points Xray's config at a fresh access log holding the lines +// that line(i) yields for i in [0, n). +func writeAccessLog(t *testing.T, n int, line func(i int) string) { + t.Helper() + dir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", dir) + accessLog := filepath.Join(dir, "access.log") + if err := os.WriteFile(filepath.Join(dir, "config.json"), fmt.Appendf(nil, `{"log":{"access":%q}}`, accessLog), 0o600); err != nil { + t.Fatal(err) + } + file, err := os.Create(accessLog) + if err != nil { + t.Fatal(err) + } + defer file.Close() + writer := bufio.NewWriter(file) + for i := range n { + _, _ = writer.WriteString(line(i) + "\n") + } + if err := writer.Flush(); err != nil { + t.Fatal(err) + } +} + +func readAccessLog(count string) []LogEntry { + return (&ServerService{}).GetXrayLogs(count, "", "true", "true", "true", []string{"direct"}, []string{"blocked"}) +} + +func TestGetXrayLogsReturnsTheNewestEntriesInFileOrder(t *testing.T) { + writeAccessLog(t, 250, func(i int) string { + return fmt.Sprintf("2024/01/02 15:04:05.000000 from 1.2.3.4:%d accepted tcp:example.com:443 [in-1 >> direct]", i) + }) + + tests := []struct { + count string + want int + }{ + {"1", 1}, + {"20", 20}, + {"250", 250}, + {"1000", 250}, + {"0", 100}, + {"-1", 100}, + {"10001", 100}, + {"not-a-number", 100}, + } + for _, tt := range tests { + entries := readAccessLog(tt.count) + if len(entries) != tt.want { + t.Errorf("count %q: got %d entries, want %d", tt.count, len(entries), tt.want) + continue + } + for i, entry := range entries { + if want := fmt.Sprintf("1.2.3.4:%d", 250-tt.want+i); entry.FromAddress != want { + t.Errorf("count %q: entry %d is from %q, want %q", tt.count, i, entry.FromAddress, want) + break + } + } + } +} + +// The file grows with every request Xray refuses, so what a read of it holds in +// memory must follow the requested count, not the size of the file. +func TestGetXrayLogsMemoryDoesNotGrowWithTheFile(t *testing.T) { + line := "2024/01/02 15:04:05.000000 from 1.2.3.4:1 accepted http://example.com/?" + strings.Repeat("a", 16<<10) + " [in-1 >> direct]" + writeAccessLog(t, (64<<20)/len(line), func(int) string { return line }) + + // A low GC target keeps garbage from the scan itself out of the measurement. + defer debug.SetGCPercent(debug.SetGCPercent(10)) + heap := func() uint64 { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + return stats.HeapAlloc + } + runtime.GC() + before, peak := heap(), uint64(0) + + done := make(chan []LogEntry) + go func() { done <- readAccessLog("20") }() + for { + select { + case entries := <-done: + if len(entries) != 20 { + t.Fatalf("got %d entries, want 20", len(entries)) + } + if grown := int64(peak) - int64(before); grown > 32<<20 { + t.Fatalf("heap grew by %d MiB while 20 lines of a 64 MiB log were read", grown>>20) + } + return + case <-time.After(time.Millisecond): + peak = max(peak, heap()) + } + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index cab19c6df..57287aed6 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1482,7 +1482,10 @@ func (s *ServerService) GetXrayLogs( Proxied ) - countInt, _ := strconv.Atoi(count) + countInt, err := strconv.Atoi(count) + if err != nil || countInt < 1 || countInt > 10000 { + countInt = 100 + } var entries []LogEntry pathToAccessLog, err := xray.GetAccessLogPath() @@ -1530,17 +1533,18 @@ func (s *ServerService) GetXrayLogs( entry.Event = Proxied } + // Anyone reaching an inbound port adds lines here, so only the newest + // countInt entries are kept while reading, not one per line of the file. entries = append(entries, entry) + if len(entries) > countInt { + entries = entries[1:] + } } if err := scanner.Err(); err != nil { return nil } - if len(entries) > countInt { - entries = entries[len(entries)-countInt:] - } - return entries }